mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 04:59:41 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6570e22d | ||
|
|
0aafabd28d | ||
|
|
4add38c4b1 | ||
|
|
e6df0c4094 | ||
|
|
6bb3d66004 | ||
|
|
161f1da2eb | ||
|
|
87e787e934 | ||
|
|
3533c4332b |
@@ -1,5 +1,18 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 161f1da: Read My Mind phase 1: per-case intent profiles (docs/readmymind-plan.md). Codeman can now capture the prompts a user actually submits (from the Claude session transcript, opt-in via the new synced readMyMindEnabled setting, default OFF) into a per-case intent profile alongside user-stated goals, stored in ~/.codeman/intents.json (mode 0600, never searched). New endpoints GET/PUT/DELETE /api/sessions/:id/intent (ownership-scoped, strict schemas), a transcript:user_prompt event on TranscriptWatcher, and agent-skill coverage (SKILL.md recipe + endpoints.md rows) so agents can read and record the user's intent. Groundwork for the phase-2 predictor button: nothing is ever auto-sent.
|
||||
- Home screen and phone touch targets.
|
||||
|
||||
The desktop welcome screen now lists your open tabs as a vertical column down its left gutter, which was previously dead space: one row per live session plus any saved web tabs, in tab order so the row badges match Alt+1..9, with case, backend and state on each row. Clicking a row enters that session. The column is width-gated (1180px and up) and never moves the centered welcome content.
|
||||
|
||||
Working state now reads the same everywhere it appears. A busy session shows a pulsing green dot ringed by the same spinner a tab draws while it loads, with a green halo, on the desktop home column, the phone home screen and the tab strip alike. Phone tabs got the bigger 9px glowing dot for the same reason.
|
||||
|
||||
Phone touch targets: the brand "C" that returns you to the home screen was roughly a 12x13px hit area, well under the 44px minimum. It is now a real 44x44 button, and the phone header grew from 36px to 44px to make that possible, which gives every other header control the same 8px. The simple keyboard accessory bar also swaps /clear for Tab (/clear and /compact stay in the extended bar), flushing locally buffered text to the terminal first so completion applies to what you just typed.
|
||||
|
||||
## 1.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.16.0 (must match `package.json`)
|
||||
**Version**: 1.16.1 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -153,14 +153,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Agents** | `src/subagent-watcher.ts` ★, `team-watcher`, `bash-tool-parser`, `transcript-watcher`, `workflow-run-watcher` | `workflow-run-watcher` is STANDALONE and never touches `subagent-watcher` |
|
||||
| **AI** | `src/ai-checker-base.ts`, `ai-idle-checker.ts`, `ai-plan-checker.ts` | |
|
||||
| **Tasks** | `src/task.ts`, `task-queue.ts`, `task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts` | |
|
||||
| **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts`, `intent-store.ts` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` (pure), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns |
|
||||
| **Web tabs** | `src/webview-store.ts`, `webview-capabilities.ts`, `src/web/webview-proxy.ts` (pure), `src/web/routes/webview-routes.ts` | Dashboard URLs as tabs; NOT a SessionMode |
|
||||
| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` |
|
||||
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (20 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 26 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
|
||||
| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 27 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 20 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
@@ -210,6 +210,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny buttons are also gated on it (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
||||
|
||||
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`). The predictor/button are phase 2; nothing auto-sends, ever. User guide: `docs/readmymind.md`.
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||
@@ -244,12 +246,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
|
||||
|
||||
**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
|
||||
@@ -304,7 +308,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~200 handlers across 22 route files in `src/web/routes/`: system (45), sessions (34), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (3), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~200 handlers across 23 route files in `src/web/routes/`: system (45), sessions (34), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (3), readmymind (3), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
@@ -322,7 +326,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -434,6 +434,31 @@ re-captured), `approval:resolved` (`{ id, sessionId, kind, resolution }` with
|
||||
`resolution` one of `answered | resolved_in_terminal | superseded |
|
||||
session_ended | dismissed | expired`).
|
||||
|
||||
## Read My Mind intent profiles
|
||||
|
||||
Per-case profiles of what the user is trying to accomplish: user/agent-stated
|
||||
goals plus the user's recently submitted prompts, captured from the Claude
|
||||
session transcript while the opt-in `readMyMindEnabled` setting is on (default
|
||||
OFF). Keyed by owner + workingDir, so the profile survives `/clear`, respawns,
|
||||
and session churn. Stored in `~/.codeman/intents.json` (mode 0600); never fed
|
||||
into `/api/v1/search`. Design: [`readmymind-plan.md`](readmymind-plan.md);
|
||||
user guide: [`readmymind.md`](readmymind.md).
|
||||
|
||||
- `GET /api/v1/sessions/:id/intent` -> `{ intent: IntentProfile }` for the
|
||||
session's case. `IntentProfile`: `{ key, workingDir, updatedAt, goals,
|
||||
recentPrompts: { ts, sessionId, text }[] }` (prompts oldest first, FIFO cap
|
||||
50, each <= 500 chars). A case with nothing recorded answers an empty
|
||||
profile with `updatedAt: 0`; nothing is persisted by reads.
|
||||
- `PUT /api/v1/sessions/:id/intent` with `{ goals }` (<= 8192 chars, strict
|
||||
schema) replaces the goals text and answers the updated profile.
|
||||
`400 INVALID_INPUT` on over-long or unknown fields.
|
||||
- `DELETE /api/v1/sessions/:id/intent` -> `{ deleted: boolean }` forgets the
|
||||
case's profile entirely.
|
||||
|
||||
All three enforce session ownership in multi-user mode; a foreign session id
|
||||
answers `404 NOT_FOUND` (no existence leak), and profiles of two owners of the
|
||||
same directory are distinct by construction.
|
||||
|
||||
## Authentication
|
||||
|
||||
Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Read My Mind (design)
|
||||
|
||||
A 🧠 button that predicts the prompt you were about to type. Codeman keeps a per-case **intent profile** (your stated goals plus the real prompts you recently sent), feeds it and the live pane tail to a one-shot `claude -p`, and shows the predicted next prompt in a plan-mode-style approval dialog: **Send** / **Rethink** (with an optional steer note) / **Insert** (drop it on the composer to edit) / **Dismiss**. It is also a skill surface: the agent can read the intent profile, record intentions, and request a prediction over the HTTP API. Suggestions are **never auto-sent**; the human click is the boundary.
|
||||
|
||||
## UX flow
|
||||
|
||||
1. User hits 🧠 (desktop header button; phone: keyboard-accessory key).
|
||||
2. Modal opens with a spinner, then the top suggestion in an editable single-line field, rationale below it, up to 2 alternates as tappable rows.
|
||||
3. Buttons: **Send** (submits with `\r`), **Insert** (sends without `\r`, so the text sits unsubmitted on the CLI composer for editing, a documented mechanism), **Rethink** (optional free-text steer, e.g. "no, I meant the mobile bug", re-runs with the rejected suggestions included), **Dismiss**.
|
||||
4. Accepted prompts flow back into the intent history like any other sent prompt, so the profile self-corrects.
|
||||
|
||||
## Scope (v1)
|
||||
|
||||
- Claude mode only (capture rides Claude transcripts; external CLIs have no transcript watcher). Mirrors the approvals-inbox scoping.
|
||||
- Opt-in: `readMyMindEnabled`, synced, default **OFF**. While OFF: no capture, no UI surfaces. Privacy first, and every press costs real tokens.
|
||||
- One prediction in flight per session; the button disables while checking.
|
||||
- Sync request/response (the predictor takes 5-30s; agent-wait long-polls already hold requests longer). No new SSE events in v1.
|
||||
|
||||
## Data model
|
||||
|
||||
Per case, not per session: intentions outlive `/clear` and respawns.
|
||||
|
||||
```ts
|
||||
interface IntentProfile {
|
||||
key: string; // sha256(owner + ':' + realpath(workingDir)).slice(0, 16)
|
||||
workingDir: string;
|
||||
updatedAt: number;
|
||||
goals: string; // freeform markdown, user/agent editable, ≤ 8 KB
|
||||
recentPrompts: { ts: number; sessionId: string; text: string }[]; // FIFO cap 50, each ≤ 500 chars
|
||||
}
|
||||
```
|
||||
|
||||
Storage: `dataPath('intents.json')`, written mode 0600 (prompts can contain secrets; same posture as `users.json`). Never enters the `/api/search` index. Add to the CLAUDE.md State Files list.
|
||||
|
||||
## Intent capture
|
||||
|
||||
**Source: the session transcript, not the input paths.** `POST /api/sessions/:id/input` sees only programmatic input, and the WS channel delivers raw keystrokes (`session.write(msg.d)`), so neither yields clean submitted prompts. Claude's own JSONL transcript records every user turn as structured text, and `transcript-watcher.ts` already tails it. Add a `userPrompt` event there:
|
||||
|
||||
- Emit for `type: 'user'` entries whose content is a string or contains a text block; skip entries that are only `tool_result` blocks (tool results are wrapped as user messages).
|
||||
- Skip `<command-name>` / `<local-command-stdout>` tagged entries (local slash-command echo, not intent).
|
||||
- Skip texts < 3 chars (menu digits, Esc artifacts), truncate to 500, drop consecutive duplicates ("continue" spam from auto-resume stays but dedupes).
|
||||
|
||||
`IntentStore` (new `src/intent-store.ts`, pure core + IO wrapper, in the style of `session-order.ts`) subscribes via session wiring, gated on the setting resolved from **merged** settings per the partial-PUT rule.
|
||||
|
||||
## Context assembly (how the mind reading actually works)
|
||||
|
||||
The quality of the suggestion is decided before the model ever runs, by what we put in front of it. A new pure function `buildPredictionContext()` (in `src/readmymind-context.ts`, unit-testable with fixtures, no IO of its own; collectors inject their data) assembles a budgeted, priority-ordered prompt from every signal Codeman already has:
|
||||
|
||||
| # | Source | What it contributes | Cap |
|
||||
| - | ------ | ------------------- | --- |
|
||||
| 1 | **Pending dialog** (approvals-inbox store, when present) | If the session is sitting on an AskUserQuestion / permission / idle prompt, the honest "next prompt" is an *answer*. The dialog text + parsed options go in first and the model is told to answer it. | 2 KB |
|
||||
| 2 | **User goals** (`goals` from the intent profile) | The only fully-trusted statement of what the user wants. Highest authority in the trust ranking below. | 8 KB |
|
||||
| 3 | **Last assistant turn** (transcript, not the pane) | Assistant replies usually *end* with the fork in the road ("Want me to X?", "Next steps: ..."), so keep the **tail** when truncating. The transcript has the full message; the pane is a repaint window full of spinner junk. | 6 KB |
|
||||
| 4 | **Recent user prompts** (intent profile, with timestamps) | The conversation rhythm AND the user's prompting voice: length, tone, shorthand (`COM`, lowercase, typos and all). The model is instructed to write suggestions in *this* style, not assistant-ese. | last 20 |
|
||||
| 5 | **Recent tool activity** (transcript `tool_use` blocks, already parsed by `TranscriptWatcher`) | One line per call: `Edit src/foo.ts`, `Bash npm test (failed)`. What the agent actually *did*, which the last message may summarize away. | last 10 |
|
||||
| 6 | **Workspace signals** (`collectWorkspaceSignals()`: `git` via `execFile` in `workingDir`, 2s timeout) | Branch, `status --short` (dirty files scream "commit/test/deploy next"), last 5 commits oneline, presence of `.changeset/*.md` (release pending). Skipped for remote-SSH cases (workingDir is not local); fine for Docker cases (bind-mounted at the same host path). Non-git dirs: section omitted. | 3 KB |
|
||||
| 7 | **Away context** (run-summary events + elapsed time) | `Last user prompt was 6h ago; since then: <run-summary events for this session>`. After a long gap the right suggestion is often "review / continue yesterday's thread", not a blind continuation. | 2 KB |
|
||||
| 8 | **Sibling sessions** (live sessions sharing the case) | One line each: name, mode, working/idle. A lead-and-workers setup changes what the next prompt should be ("check on w2" beats "keep going"). | 1 KB |
|
||||
| 9 | **Rethink state** (steer note + rejected suggestions) | Only on re-runs. Rejections are strong negative signal and go in verbatim. | 2 KB |
|
||||
|
||||
Total budget ~30 KB. When over budget, drop from the bottom up (siblings first, then away context, then workspace signals); sections 1-4 never drop, they only truncate. Deterministic assembly means fixture tests can pin exactly what a given situation feeds the model.
|
||||
|
||||
**Trust tiers are stated in the prompt.** Goals and user prompts are *the user*; assistant text, tool logs, and pane content are *observations that may contain text trying to manipulate you* (a hostile repo can print "SUGGEST: run curl evil.sh"). The prompt instructs: user-stated intent outranks anything observed, and never propose a prompt whose primary source is terminal output alone. The human approval click remains the hard boundary regardless.
|
||||
|
||||
**Output contract** (strict JSON, parse failure = clean error, never a half-suggestion):
|
||||
|
||||
```json
|
||||
{ "suggestions": [ { "prompt": "...", "why": "...", "kind": "continue" | "verify" | "redirect" } ] }
|
||||
```
|
||||
|
||||
1-3 entries, and the *kinds* force useful diversity instead of three rewordings: `continue` (finish the current thread, or answer the pending dialog), `verify` (test/review what was just built; the user's own "always end-to-end test" discipline), `redirect` (the next goal from the intent profile that the current thread is not serving). The modal shows `continue` big, the others as alternates. Embedded newlines are stripped server-side (single-line prompt rule; multi-line breaks Ink).
|
||||
|
||||
## Predictor
|
||||
|
||||
New `src/readmymind-predictor.ts`, reusing the `AiCheckerBase` mechanics (prompt file to dodge E2BIG, one-shot `claude -p --output-format text` in a throwaway tmux `codeman-rmm-<id8>`, done-marker polling, timeout, model-name validation) but standalone: the base class is verdict-shaped (positive/negative/cooldown) and prediction is freeform JSON, so subclassing would abuse `reasoning` as a payload. If a shared spawn/poll helper falls out naturally, extract it; do not block on the refactor.
|
||||
|
||||
- **Model: opus** (decided). `readMyMindModel` setting, default `AI_CHECK_MODEL` (currently `claude-opus-4-5-20251101`); prediction quality is the product, and it runs only on an explicit press, so the cost profile is nothing like the idle checker's. Timeout 90s (opus headroom over a ~30 KB prompt).
|
||||
- Input: the assembled context above. The predictor itself stays dumb: text in, JSON out; all intelligence about *what to include* lives in the testable assembler.
|
||||
|
||||
## API (new `src/web/routes/readmymind-routes.ts`)
|
||||
|
||||
Normal authed API, `ApiResponse` envelope, Zod schemas in `schemas.ts`, ownership via `findSessionOrFail` (the profile key derives from the session's owner + workingDir, so multi-user scoping is structural):
|
||||
|
||||
- `GET /api/sessions/:id/intent` → the session's `IntentProfile`.
|
||||
- `PUT /api/sessions/:id/intent` body `{ goals }` (bounded) → update goals. Used by the modal's edit view and by the agent skill ("record that the user is working toward X").
|
||||
- `DELETE /api/sessions/:id/intent` → forget everything for this case (the modal's "Forget" affordance).
|
||||
- `POST /api/sessions/:id/readmymind` body `{ steer?, rejected? }` → `{ suggestions }`. 409 `INVALID_STATE` while a prediction is already running for the session; claude-mode sessions only (400 otherwise, mirroring wait-signal gating).
|
||||
|
||||
## Frontend
|
||||
|
||||
New module `readmymind-ui.js` (@loadorder 11.3, after panels-ui.js), prettier-formatted.
|
||||
|
||||
- **Desktop**: header button `btn-readmymind`, default-hidden via marker class `btn-readmymind--hidden` (the `!important` display rules require the marker-class pattern), shown by `applyHeaderVisibilitySettings()` when the setting is ON. Off phones per `test/mobile-header-buttons-policy.test.ts`.
|
||||
- **Phone**: a 🧠 key on the keyboard accessory bar (that bar is where input helpers live, and phones are where typing hurts most). Opens the same modal. Modal z-index respects the ≤768px layer rules (1300+).
|
||||
- **Send** goes server-side: `POST /api/sessions/:id/input` with `\r` appended. Deliberately NOT the browser keystroke path, so the `sendEnterKey` / local-echo-overlay trap never applies (the modal is UI chrome, not terminal typing). **Insert** is the same POST without `\r`.
|
||||
- i18n strings registered (en + zh-CN); suggestion text itself carries `data-i18n-skip`.
|
||||
|
||||
## Skill integration
|
||||
|
||||
The user-facing promise: the button is also a skill. Extend `skills/codeman`:
|
||||
|
||||
- New section "Read My Mind: intent + prediction" with the three intent verbs (read profile, append/replace goals, predict) and the guard notes (single-line prompts, never auto-send to another session without the user asking).
|
||||
- Update `reference/endpoints.md` (the endpoints.md drift test pins this).
|
||||
- The auto-injected case copy heals via the existing marker-owned `applyAgentSkill` mechanism; nothing new needed there.
|
||||
|
||||
Agent use cases this unlocks: a lead session records intentions as the user states them ("remember: shipping 1.16 is the goal"), and a returning user gets a prediction grounded in what the agent knew, not just raw prompt history.
|
||||
|
||||
## Security / privacy
|
||||
|
||||
- **The human gate is the injection mitigation**: pane output (attacker-influenceable) flows into the predictor, so its output is only ever *proposed*, rendered as text (`textContent`), and sent solely by an explicit user click. No auto-send path exists, including for the skill.
|
||||
- Intent data: 0600 file, bounded fields, per-owner keys, endpoints ownership-checked, excluded from search, cleared via DELETE.
|
||||
- Predictor spawns with the user's own credentials exactly like the AI idle/plan checkers; model name shell-validated the same way.
|
||||
- Setting OFF stops capture immediately; existing data stays until DELETE (explicit, not silent).
|
||||
|
||||
## Tests
|
||||
|
||||
- `test/intent-store.test.ts`: key derivation, caps/FIFO, consecutive-dupe skip, tag/tool_result filtering fixtures, 0600 mode, multi-user key separation.
|
||||
- `test/readmymind-context.test.ts`: fixture scenarios pinning the assembled prompt: pending-dialog-first ordering, tail-keeping truncation of the assistant turn, budget drop order (siblings before workspace signals), remote-case git skip, trust-tier framing present, rejected suggestions included only on rethink.
|
||||
- `test/readmymind-predictor.test.ts`: strict JSON parse, garbage output → error result, newline stripping, `kind` validation, rejected-suggestions threading into the prompt.
|
||||
- `test/routes/readmymind-routes.test.ts` (`app.inject`): CRUD round-trip, predict with a stubbed predictor, 409 while in flight, non-claude 400, ownership 404, Send/Insert byte assertions via the test-PTY echo (`\r` present vs absent).
|
||||
- Transcript capture: extend the transcript-watcher fixtures with user-turn entries.
|
||||
|
||||
## Phases
|
||||
|
||||
1. **Intent store + capture + intent endpoints + skill docs.** Immediately useful to agents even before any UI exists.
|
||||
2. **Context assembler + predictor + predict endpoint + desktop button/modal.** The feature as pitched. The assembler ships with all collectors it can serve from day one (transcript, intent, git, run-summary, siblings); the approvals collector activates when PR #245 lands.
|
||||
3. **Phone accessory key, rethink steering, alternates row.**
|
||||
4. Explicitly later: proactive predict-on-idle (ghost suggestion chip), auto-compaction of `recentPrompts` into `goals` via a cheap model, codex/gemini capture, cross-case "global" intent.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should Rethink's rejected-suggestion memory persist across modal closes, or reset each open?
|
||||
- Is a composer-adjacent placement (next to the toolbar Run controls) better than the header for discoverability?
|
||||
- Pending-dialog input (source #1) consumes the approvals-inbox store (PR #245, merged): the phase-2 collector reads pending items directly from `src/approval-inbox.ts`.
|
||||
|
||||
## Docs
|
||||
|
||||
- CLAUDE.md: Key Patterns entry, State Files (`intents.json`), frontend load order, route count.
|
||||
- `docs/api-reference.md`: four endpoints (additive under the 0.9.x contract).
|
||||
- `skills/codeman/reference/endpoints.md`: new rows (drift-test enforced).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Read My Mind
|
||||
|
||||
Codeman's per-case memory of what you are trying to accomplish. Each case gets an **intent profile**: a freeform `goals` text (written by you or your agent) plus the prompts you actually submitted, captured automatically while the feature is on. Phase 1 (this document) ships the profile itself, its API, and the agent-skill verbs. Phase 2 adds the 🧠 button that turns the profile into a predicted next prompt you can accept, edit, or rethink; the design for that lives in [`readmymind-plan.md`](readmymind-plan.md). Nothing is ever sent to a session automatically, in any phase.
|
||||
|
||||
## What it does today (phase 1)
|
||||
|
||||
- Captures the prompts you submit in Claude sessions into a per-case history (50 most recent, bounded).
|
||||
- Lets you (or your agent) record explicit goals per case.
|
||||
- Exposes the profile over the HTTP API, and to agents through the `codeman` skill, so an agent can ground its work in what you actually want instead of guessing from the last screenful.
|
||||
|
||||
## Turning it on
|
||||
|
||||
The synced setting `readMyMindEnabled` (default **OFF**) gates capture. There is no App Settings checkbox yet (that arrives with the phase-2 UI), so flip it over the API:
|
||||
|
||||
```bash
|
||||
curl -sk -X PUT https://localhost:3000/api/settings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"readMyMindEnabled": true}'
|
||||
```
|
||||
|
||||
Add `-u user:password` if your install has `CODEMAN_PASSWORD` set, and drop `-k`/use `http://` for a plain-HTTP dev server. Turning it OFF stops capture immediately; existing profiles stay until you delete them (below).
|
||||
|
||||
## What gets captured, exactly
|
||||
|
||||
Capture reads the Claude session transcript, not your keystrokes: when a user turn lands in the transcript, its text is folded into the case's profile. Filters applied on the way in:
|
||||
|
||||
- **Claude-mode sessions only.** Shell, OpenCode, Codex, Gemini, and Antigravity sessions are never captured (they have no transcript watcher).
|
||||
- Tool results, local slash-command echo (`/model` and friends), system wrappers, and interrupt markers are skipped.
|
||||
- Entries shorter than 3 characters are skipped (menu digits, Esc artifacts).
|
||||
- Consecutive duplicates collapse (auto-resume's "continue" spam counts once per run).
|
||||
- Each prompt is stored as one line, truncated to 500 characters; the history caps at 50 prompts FIFO.
|
||||
|
||||
Because the transcript path arrives via Claude Code hooks, capture needs hooks to reach the server, the same condition as hook-based idle detection. Docker cases against a loopback-only server need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; remote-SSH cases do not capture.
|
||||
|
||||
## What is never captured
|
||||
|
||||
- Anything while `readMyMindEnabled` is OFF (capture is not retroactive).
|
||||
- Terminal output, keystrokes, passwords typed into shells: only submitted Claude prompts are read.
|
||||
- Nothing leaves the machine, and profiles are never fed into `/api/search`.
|
||||
|
||||
## Where it lives, and how to wipe it
|
||||
|
||||
Profiles live in `~/.codeman/intents.json`, written atomically at mode 0600 (captured prompts can contain secrets). The file is per Codeman instance. Keys derive from owner + the case's resolved working directory, so profiles survive `/clear`, respawn cycles, and session churn, and in multi-user mode two owners of the same directory get separate profiles.
|
||||
|
||||
Forget one case: `DELETE /api/sessions/:id/intent` (below). Forget everything: stop the server and delete `~/.codeman/intents.json`.
|
||||
|
||||
## The API
|
||||
|
||||
Three endpoints, session-scoped so ownership is enforced by the session itself (`/api/v1/` aliases work too; full spec in [`api-reference.md`](api-reference.md)):
|
||||
|
||||
```bash
|
||||
# Read the profile for a session's case
|
||||
curl -sk https://localhost:3000/api/sessions/$SID/intent | jq '.data.intent'
|
||||
|
||||
# Record goals (REPLACES the text: read + merge if you want to append)
|
||||
curl -sk -X PUT https://localhost:3000/api/sessions/$SID/intent \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"goals":"ship 1.17; then mobile polish"}'
|
||||
|
||||
# Forget the case
|
||||
curl -sk -X DELETE https://localhost:3000/api/sessions/$SID/intent
|
||||
```
|
||||
|
||||
A case with nothing recorded answers an empty profile with `updatedAt: 0`; reads never persist anything. Goals cap at 8192 characters and the schema is strict, so unknown fields or over-long goals answer `400 INVALID_INPUT`. A session you do not own answers `404 NOT_FOUND`, indistinguishable from a nonexistent one.
|
||||
|
||||
## For agents (the skill)
|
||||
|
||||
The `codeman` agent skill documents the same three verbs (SKILL.md §3 plus `reference/endpoints.md`), with the ground rules: read the profile to understand what the user wants, record goals the user actually stated, merge instead of blind-writing (PUT replaces), and never delete a profile unprompted. It is the user's memory, not the agent's.
|
||||
|
||||
## What phase 2 adds
|
||||
|
||||
The 🧠 button and the predictor: a context assembler feeds the profile, the last assistant turn, tool activity, git state, away context, and any pending approval dialog to a one-shot opus call, and the suggested next prompt appears in an approval dialog (Send / Insert to edit / Rethink with a steer note / Dismiss). See [`readmymind-plan.md`](readmymind-plan.md) for the full design, including the trust-tier rules that keep terminal output from steering suggestions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| ------- | ----------- |
|
||||
| Profile stays empty although I am prompting | `readMyMindEnabled` was OFF at the time (capture is not retroactive), the session is not claude-mode, or hooks are not reaching the server (Docker case on a loopback bind without `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, or a remote-SSH case) |
|
||||
| Short answers I typed are missing | Entries under 3 characters are filtered by design (menu digits, Esc artifacts) |
|
||||
| My goals text vanished after an agent wrote to it | PUT replaces the whole text; the skill tells agents to read + merge, but a blind write wins. Re-state the goals; consider phrasing them in the session so capture keeps the evidence |
|
||||
| Two profiles for what I think is one case | Different owners in multi-user mode, or genuinely different directories; paths are realpath-resolved, so symlink spellings converge but distinct checkouts do not |
|
||||
| `400 INVALID_INPUT` on PUT | Goals over 8192 chars, or an extra field in the body (strict schema) |
|
||||
|
||||
## Where the code lives
|
||||
|
||||
`src/intent-store.ts` (store + pure helpers, singleton), the `transcript:user_prompt` event in `src/transcript-watcher.ts`, capture wiring in `src/web/server.ts` (`captureIntentPrompt`), routes in `src/web/routes/readmymind-routes.ts`, schema in `src/web/schemas.ts`. Tests: `test/intent-store.test.ts`, `test/routes/readmymind-routes.test.ts`, and the capture cases in `test/transcript-watcher.test.ts`.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.0",
|
||||
"version": "1.16.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.0",
|
||||
"version": "1.16.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.0",
|
||||
"version": "1.16.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -392,6 +392,24 @@ is parked resolves it within ~3 s. A session deleted mid-wait resolves in ~1 s.
|
||||
delete_session "$SID"
|
||||
```
|
||||
|
||||
**Read My Mind: read and record the user's intent.** Each case has an intent
|
||||
profile: user-stated goals plus the user's recent real prompts (captured
|
||||
server-side while the opt-in `readMyMindEnabled` setting is on). Read it to
|
||||
ground your work in what the user actually wants; write it when the user states
|
||||
an intention worth remembering ("the goal is shipping 1.17"):
|
||||
|
||||
```bash
|
||||
"${CURL[@]}" "$API/api/v1/sessions/$SELF/intent" | jq '.data.intent'
|
||||
"${CURL[@]}" -X PUT -H 'Content-Type: application/json' \
|
||||
-d '{"goals":"shipping 1.17; mobile polish next"}' "$API/api/v1/sessions/$SELF/intent"
|
||||
```
|
||||
|
||||
⚠️ PUT **replaces** the whole goals text: read it first and merge, never
|
||||
blind-write. Never write goals the user did not state, and never delete the
|
||||
profile (`DELETE .../intent`) unless the user asks: it is their memory, not
|
||||
yours. Older servers 404 these routes; treat that as "feature absent", not an
|
||||
error.
|
||||
|
||||
Everything else (endpoint tables, per-mode signal table, error codes, capacity
|
||||
limits, Docker/remote caveats): [reference/endpoints.md](reference/endpoints.md).
|
||||
Fan-out orchestration and blocked-worker handling:
|
||||
|
||||
@@ -47,6 +47,9 @@ read the status with `-w '%{http_code}'` and the raw body before assuming a bug.
|
||||
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
|
||||
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
|
||||
| background agents, global list | `GET /api/v1/subagents` (admin-only in multi-user mode) |
|
||||
| the case's intent profile (Read My Mind: user goals + recent real prompts) | `GET /api/v1/sessions/:id/intent` → `.data.intent.{goals,recentPrompts}` (empty with `updatedAt: 0` until something is recorded) |
|
||||
| replace the user-goals text on the case's intent profile | `PUT /api/v1/sessions/:id/intent` body `{"goals":"…"}` (≤ 8192 chars, strict schema; REPLACES the text, read + merge first) |
|
||||
| forget the case's intent profile (only when the user asks) | `DELETE /api/v1/sessions/:id/intent` → `.data.deleted` |
|
||||
| server status / version | `GET /api/v1/status` → `.data.version` |
|
||||
| delete one session (yours only, via `delete_session`) | `DELETE /api/v1/sessions/:id` — never call it bare; the fail-closed helper in SKILL.md §0 is the only self-protection that exists. Answers `{"success":true,"data":{}}`: an **empty** body is the success signal, there is nothing to read back |
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* @fileoverview Read My Mind intent store: per-case profiles of user intent.
|
||||
*
|
||||
* Feeds the Read My Mind predictor (`docs/readmymind-plan.md`). Each profile
|
||||
* pairs user/agent-stated `goals` with the user's recently captured prompts,
|
||||
* keyed by owner + realpath(workingDir) so the profile survives `/clear`,
|
||||
* respawns, and session churn, and so multi-user scoping is structural (two
|
||||
* owners of the same directory get distinct profiles).
|
||||
*
|
||||
* Capture rides the session transcript (`transcript:user_prompt`), not the
|
||||
* input paths: `POST /input` sees only programmatic prompts and the WS channel
|
||||
* delivers raw keystrokes, so neither yields clean submitted prompts.
|
||||
*
|
||||
* Prompts can contain secrets, so the state file is written 0600 (same posture
|
||||
* as `users.json`) and the store is never fed into `/api/search`.
|
||||
*
|
||||
* Pure helpers (`deriveIntentKey`, `sanitizePromptText`, `isCapturablePrompt`,
|
||||
* `appendPrompt`) are exported for unit tests; the `IntentStore` class adds the
|
||||
* IO. Writes are atomic (tmp + rename) and synchronous: mutations arrive at
|
||||
* human prompting pace, so there is nothing to debounce and no timer to leak.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import type { IntentProfile, IntentPromptEntry } from './types/index.js';
|
||||
|
||||
// ========== Limits ==========
|
||||
|
||||
/** Max stored profiles; lowest `updatedAt` is evicted first. */
|
||||
export const MAX_INTENT_PROFILES = 200;
|
||||
|
||||
/** Max captured prompts per profile (FIFO). */
|
||||
export const MAX_RECENT_PROMPTS = 50;
|
||||
|
||||
/** Max characters kept per captured prompt. */
|
||||
export const MAX_PROMPT_CHARS = 500;
|
||||
|
||||
/** Max characters for the `goals` field. */
|
||||
export const MAX_GOALS_CHARS = 8192;
|
||||
|
||||
/** Prompts shorter than this are menu digits / Esc artifacts, not intent. */
|
||||
const MIN_PROMPT_CHARS = 3;
|
||||
|
||||
// ========== Pure helpers ==========
|
||||
|
||||
/** Stable per-case key: owner + resolved workingDir, hashed. */
|
||||
export function deriveIntentKey(owner: string | undefined, workingDir: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${owner ?? ''}:${workingDir}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript user entries that are not typed intent: local slash-command echo,
|
||||
* hook/system wrappers, and interrupt markers.
|
||||
*/
|
||||
export function isCapturablePrompt(text: string): boolean {
|
||||
if (text.includes('<command-name>') || text.includes('<local-command-stdout>')) return false;
|
||||
if (text.startsWith('<system-reminder>')) return false;
|
||||
if (text.startsWith('Caveat: The messages below')) return false;
|
||||
if (text.startsWith('[Request interrupted')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a transcript prompt to a bounded single line, or null when it is
|
||||
* too short to mean anything (menu digits, Esc artifacts).
|
||||
*/
|
||||
export function sanitizePromptText(raw: string): string | null {
|
||||
const text = raw
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
|
||||
.trim();
|
||||
if (text.length < MIN_PROMPT_CHARS) return null;
|
||||
return text.length > MAX_PROMPT_CHARS ? text.slice(0, MAX_PROMPT_CHARS) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one prompt into a profile: consecutive duplicates collapse (auto-resume
|
||||
* "continue" spam), FIFO cap applies. Returns a new profile object.
|
||||
*/
|
||||
export function appendPrompt(profile: IntentProfile, entry: IntentPromptEntry): IntentProfile {
|
||||
const last = profile.recentPrompts[profile.recentPrompts.length - 1];
|
||||
if (last && last.text === entry.text) {
|
||||
return { ...profile, updatedAt: entry.ts };
|
||||
}
|
||||
const recentPrompts = [...profile.recentPrompts, entry].slice(-MAX_RECENT_PROMPTS);
|
||||
return { ...profile, recentPrompts, updatedAt: entry.ts };
|
||||
}
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
interface IntentStoreFile {
|
||||
version: 1;
|
||||
profiles: IntentProfile[];
|
||||
}
|
||||
|
||||
export class IntentStore {
|
||||
private profiles: Map<string, IntentProfile> | null = null;
|
||||
|
||||
private get filePath(): string {
|
||||
return dataPath('intents.json');
|
||||
}
|
||||
|
||||
// ----- Public API -----
|
||||
|
||||
/**
|
||||
* The profile for a session's case. Never persists on read: an absent
|
||||
* profile returns an empty transient one (`updatedAt: 0`).
|
||||
*/
|
||||
getProfile(owner: string | undefined, workingDir: string): IntentProfile {
|
||||
const dir = this.resolveDir(workingDir);
|
||||
const key = deriveIntentKey(owner, dir);
|
||||
return this.load().get(key) ?? this.emptyProfile(key, dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture one submitted prompt. Returns true when it was recorded (passed
|
||||
* the capturability filter and sanitization).
|
||||
*/
|
||||
recordPrompt(
|
||||
owner: string | undefined,
|
||||
workingDir: string,
|
||||
sessionId: string,
|
||||
rawText: string,
|
||||
ts: number = Date.now()
|
||||
): boolean {
|
||||
if (!isCapturablePrompt(rawText)) return false;
|
||||
const text = sanitizePromptText(rawText);
|
||||
if (text === null) return false;
|
||||
|
||||
const dir = this.resolveDir(workingDir);
|
||||
const key = deriveIntentKey(owner, dir);
|
||||
const profiles = this.load();
|
||||
const profile = profiles.get(key) ?? this.emptyProfile(key, dir);
|
||||
profiles.set(key, appendPrompt(profile, { ts, sessionId, text }));
|
||||
this.evictOverflow(profiles);
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replace the goals text (bounded). Returns the updated profile. */
|
||||
setGoals(owner: string | undefined, workingDir: string, goals: string): IntentProfile {
|
||||
const dir = this.resolveDir(workingDir);
|
||||
const key = deriveIntentKey(owner, dir);
|
||||
const profiles = this.load();
|
||||
const profile = profiles.get(key) ?? this.emptyProfile(key, dir);
|
||||
const updated: IntentProfile = { ...profile, goals: goals.slice(0, MAX_GOALS_CHARS), updatedAt: Date.now() };
|
||||
profiles.set(key, updated);
|
||||
this.evictOverflow(profiles);
|
||||
this.persist();
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Forget everything for a case. Returns true when a profile existed. */
|
||||
deleteProfile(owner: string | undefined, workingDir: string): boolean {
|
||||
const dir = this.resolveDir(workingDir);
|
||||
const key = deriveIntentKey(owner, dir);
|
||||
const profiles = this.load();
|
||||
const existed = profiles.delete(key);
|
||||
if (existed) this.persist();
|
||||
return existed;
|
||||
}
|
||||
|
||||
// ----- Internals -----
|
||||
|
||||
private emptyProfile(key: string, workingDir: string): IntentProfile {
|
||||
return { key, workingDir, updatedAt: 0, goals: '', recentPrompts: [] };
|
||||
}
|
||||
|
||||
private resolveDir(workingDir: string): string {
|
||||
try {
|
||||
return realpathSync(workingDir);
|
||||
} catch {
|
||||
return workingDir;
|
||||
}
|
||||
}
|
||||
|
||||
private load(): Map<string, IntentProfile> {
|
||||
if (this.profiles) return this.profiles;
|
||||
this.profiles = new Map();
|
||||
try {
|
||||
if (existsSync(this.filePath)) {
|
||||
const parsed = JSON.parse(readFileSync(this.filePath, 'utf-8')) as IntentStoreFile;
|
||||
if (parsed && Array.isArray(parsed.profiles)) {
|
||||
for (const profile of parsed.profiles) {
|
||||
if (profile && typeof profile.key === 'string') this.profiles.set(profile.key, profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[IntentStore] Failed to load ${this.filePath}, starting empty:`, err);
|
||||
}
|
||||
return this.profiles;
|
||||
}
|
||||
|
||||
private evictOverflow(profiles: Map<string, IntentProfile>): void {
|
||||
while (profiles.size > MAX_INTENT_PROFILES) {
|
||||
let oldestKey: string | null = null;
|
||||
let oldestAt = Infinity;
|
||||
for (const [key, profile] of profiles) {
|
||||
if (profile.updatedAt < oldestAt) {
|
||||
oldestAt = profile.updatedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey === null) return;
|
||||
profiles.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
if (!this.profiles) return;
|
||||
const file: IntentStoreFile = { version: 1, profiles: [...this.profiles.values()] };
|
||||
const tmpPath = `${this.filePath}.tmp`;
|
||||
try {
|
||||
// dataPath()'s own mkdir is once-per-process; per-file test HOMEs need this.
|
||||
mkdirSync(dirname(this.filePath), { recursive: true });
|
||||
// 0600: captured prompts can contain secrets (same posture as users.json).
|
||||
writeFileSync(tmpPath, JSON.stringify(file, null, 2), { mode: 0o600 });
|
||||
renameSync(tmpPath, this.filePath);
|
||||
} catch (err) {
|
||||
console.warn(`[IntentStore] Failed to persist ${this.filePath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level singleton, same pattern as `approvalInbox` (web/approval-inbox.ts). */
|
||||
export const intentStore = new IntentStore();
|
||||
@@ -6,6 +6,7 @@
|
||||
* - Tool execution state
|
||||
* - Error conditions
|
||||
* - Plan mode prompts
|
||||
* - User-authored prompts (`transcript:user_prompt`, Read My Mind intent capture)
|
||||
*
|
||||
* The transcript path is provided by Claude Code hooks in the `transcript_path` field.
|
||||
*/
|
||||
@@ -372,12 +373,23 @@ export class TranscriptWatcher extends EventEmitter {
|
||||
this.state.errorMessage = null;
|
||||
|
||||
const content = entry.message?.content;
|
||||
if (typeof content === 'string') {
|
||||
if (content.trim()) this.emit('transcript:user_prompt', content, entry.timestamp);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(content)) return;
|
||||
let promptText = '';
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result') {
|
||||
this.handleToolResult(block);
|
||||
} else if (block.type === 'text' && block.text) {
|
||||
promptText += (promptText ? ' ' : '') + block.text;
|
||||
}
|
||||
}
|
||||
// Text blocks mean a typed prompt; tool_result-only entries are Claude's own
|
||||
// tool plumbing, not intent. Filtering of command echo / system wrappers is
|
||||
// the intent store's job (`isCapturablePrompt`), not the watcher's.
|
||||
if (promptText.trim()) this.emit('transcript:user_prompt', promptText, entry.timestamp);
|
||||
}
|
||||
|
||||
private handleToolResult(block: TranscriptContentBlock): void {
|
||||
|
||||
@@ -71,3 +71,4 @@ export * from './workflow-run.js';
|
||||
export * from './search.js';
|
||||
export * from './user.js';
|
||||
export * from './webview.js';
|
||||
export * from './intent.js';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @fileoverview Read My Mind intent types.
|
||||
*
|
||||
* An intent profile is per CASE (owner + workingDir), not per session:
|
||||
* intentions outlive `/clear`, respawn cycles, and individual sessions.
|
||||
* See `docs/readmymind-plan.md`.
|
||||
*/
|
||||
|
||||
/** One captured user prompt, as it appeared in the session transcript. */
|
||||
export interface IntentPromptEntry {
|
||||
/** Capture time (ms epoch). */
|
||||
ts: number;
|
||||
/** Codeman session the prompt was sent in. */
|
||||
sessionId: string;
|
||||
/** The prompt text, sanitized and bounded. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Per-case profile of what the user is trying to accomplish. */
|
||||
export interface IntentProfile {
|
||||
/** Stable key: sha256(owner + ':' + realpath(workingDir)), first 16 hex chars. */
|
||||
key: string;
|
||||
/** The case working directory the profile belongs to (realpath-resolved). */
|
||||
workingDir: string;
|
||||
/** Last mutation (ms epoch). 0 for a never-persisted empty profile. */
|
||||
updatedAt: number;
|
||||
/** User/agent-stated goals, freeform markdown, bounded. */
|
||||
goals: string;
|
||||
/** Most recent captured prompts, oldest first, FIFO-capped. */
|
||||
recentPrompts: IntentPromptEntry[];
|
||||
}
|
||||
@@ -3686,6 +3686,8 @@ class CodemanApp {
|
||||
// (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks)
|
||||
// already funnels through here. No-ops unless that surface is showing.
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
// Same deal for the desktop home screen's tab column.
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* @fileoverview Desktop home screen session list: the open tabs as a vertical
|
||||
* column down the left of the welcome overlay.
|
||||
*
|
||||
* The welcome screen centers ~560px of content in a window that is usually
|
||||
* 1400px+, so the two gutters are dead space. The left one now carries the same
|
||||
* list a phone gets on its home screen (mobile-overview.js), turned vertical:
|
||||
* one row per live tab, in TAB ORDER (not sorted by state) so it reads as the
|
||||
* tab strip rotated, and so Alt+1..9 still matches what you see.
|
||||
*
|
||||
* DESKTOP ONLY, and only in a wide enough window: the column is absolutely
|
||||
* positioned so the centered welcome content never moves, which means it can
|
||||
* only exist where the gutter is genuinely wider than the column. Below
|
||||
* `HOME_SESSIONS_MIN_WIDTH` nothing renders; on a phone the mobile overview owns
|
||||
* the home screen entirely and this surface stays out of its way.
|
||||
*
|
||||
* The working state is deliberately identical to the phone's: a pulsing green
|
||||
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
|
||||
* from styles.css), plus a green halo. Same signal, same motion, both surfaces.
|
||||
*
|
||||
* Everything renders from state the page already holds (`this.sessions`,
|
||||
* `this.cases`, `this.pendingHooks`, `this.webviews`) — no endpoint, no SSE
|
||||
* event, no schema. State classification and case matching are reused from
|
||||
* mobile-overview.js rather than re-derived, so the two home screens can never
|
||||
* disagree about what "working" means.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
|
||||
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
||||
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
|
||||
* @dependency mobile-handlers.js (MobileDetection)
|
||||
* @loadorder 12.56 of 16, after mobile-overview.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* Narrowest window that gets the column. The welcome content is 560px wide and
|
||||
* centered, so at 1180px each gutter is 310px — enough for the 256px column plus
|
||||
* its 20px offset and still a visible gap. Anything narrower would overlap the
|
||||
* search panel, which is why this is a width gate and not a device-type gate.
|
||||
*/
|
||||
const HOME_SESSIONS_MIN_WIDTH = 1180;
|
||||
|
||||
/** Pill copy per state. Same words as the phone overview, same reasons. */
|
||||
const HOME_SESSIONS_PILL_LABEL = {
|
||||
needs: 'needs you',
|
||||
error: 'error',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
done: 'done',
|
||||
};
|
||||
|
||||
/** Short backend badge, mirroring `.tab-mode` in the tab strip. */
|
||||
const HOME_SESSIONS_MODE_BADGE = {
|
||||
shell: 'sh',
|
||||
opencode: 'oc',
|
||||
codex: 'cx',
|
||||
gemini: 'gm',
|
||||
antigravity: 'ag',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Gate + visibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Width-driven, like every other layout decision in the app. Explicitly yields
|
||||
* to the phone overview: that surface already lists the same sessions, and two
|
||||
* lists of the same thing on one screen is worse than none.
|
||||
*/
|
||||
shouldShowHomeSessions() {
|
||||
if (this.isSoloWindow) return false;
|
||||
if (this.shouldUseMobileOverview?.()) return false;
|
||||
return window.innerWidth >= HOME_SESSIONS_MIN_WIDTH;
|
||||
},
|
||||
|
||||
/** True while the column is the visible home surface. */
|
||||
isHomeSessionsVisible() {
|
||||
const el = document.getElementById('homeSessions');
|
||||
return !!el && !el.hidden;
|
||||
},
|
||||
|
||||
showHomeSessions() {
|
||||
const el = document.getElementById('homeSessions');
|
||||
if (!el) return;
|
||||
this._wireHomeSessions(el);
|
||||
if (!this.shouldShowHomeSessions()) {
|
||||
el.hidden = true;
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
this.renderHomeSessions();
|
||||
},
|
||||
|
||||
hideHomeSessions() {
|
||||
const el = document.getElementById('homeSessions');
|
||||
if (el) el.hidden = true;
|
||||
},
|
||||
|
||||
/** Re-render only when showing (called from the tab renderer's tail). */
|
||||
_refreshHomeSessionsIfVisible() {
|
||||
if (!this.isHomeSessionsVisible()) return;
|
||||
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row, plus a width listener so
|
||||
* resizing the window while on the home screen adds or drops the column
|
||||
* instead of leaving it overlapping the content it was sized to clear.
|
||||
*/
|
||||
_wireHomeSessions(el) {
|
||||
if (this._homeSessionsWired) return;
|
||||
this._homeSessionsWired = true;
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target?.closest?.('[data-hs-action]');
|
||||
if (!target) return;
|
||||
if (target.dataset.hsAction === 'session') {
|
||||
void this.selectSession(target.dataset.hsSession);
|
||||
} else if (target.dataset.hsAction === 'webview') {
|
||||
void this.openWebview?.(target.dataset.hsWebview);
|
||||
}
|
||||
});
|
||||
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
||||
const onChange = () => {
|
||||
// Only relevant while the welcome screen is up; entering a session
|
||||
// re-decides through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (!overlay || !overlay.classList.contains('visible')) return;
|
||||
this.showHomeSessions();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Model
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* One row per live session, in the user's tab order. State classification is
|
||||
* `_mobileOverviewState()` (mobile-overview.js) so both home screens agree on
|
||||
* what counts as needing you; the ORDER differs on purpose — the phone sorts
|
||||
* by urgency because it shows one screenful at a time, this column mirrors the
|
||||
* tab strip so the number badges line up with Alt+1..9.
|
||||
* @returns {Array<object>} row descriptors, ready to render
|
||||
*/
|
||||
buildHomeSessionRows() {
|
||||
const cases = Array.isArray(this.cases) ? this.cases : [];
|
||||
const order = Array.isArray(this.sessionOrder) ? this.sessionOrder : [];
|
||||
const ids = order.filter((id) => this.sessions?.has(id));
|
||||
// A session created before the order list caught up would otherwise be
|
||||
// invisible here while its tab already exists.
|
||||
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
|
||||
|
||||
return ids.map((id, index) => {
|
||||
const session = this.sessions.get(id);
|
||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
||||
const mode = session.mode || 'claude';
|
||||
return {
|
||||
id,
|
||||
index,
|
||||
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
|
||||
mode,
|
||||
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Render
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderHomeSessions() {
|
||||
const el = document.getElementById('homeSessions');
|
||||
if (!el) return;
|
||||
|
||||
const rows = this.buildHomeSessionRows();
|
||||
const webviews = (this.webviewOrder || []).map((id) => this.webviews?.get(id)).filter(Boolean);
|
||||
|
||||
// Nothing open means nothing to list: an empty framed box next to a
|
||||
// first-run welcome screen is noise, not information.
|
||||
if (!rows.length && !webviews.length) {
|
||||
el.hidden = true;
|
||||
el.replaceChildren();
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
|
||||
el.replaceChildren();
|
||||
el.appendChild(this._buildHomeSessionsHeader(rows.length + webviews.length));
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'home-sessions-list';
|
||||
for (const row of rows) list.appendChild(this._buildHomeSessionRow(row));
|
||||
for (const webview of webviews) list.appendChild(this._buildHomeSessionsWebviewRow(webview));
|
||||
el.appendChild(list);
|
||||
},
|
||||
|
||||
_buildHomeSessionsHeader(count) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'home-sessions-header';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'home-sessions-title';
|
||||
label.textContent = 'Open tabs';
|
||||
header.appendChild(label);
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'home-sessions-count';
|
||||
badge.setAttribute('data-i18n-skip', '');
|
||||
badge.textContent = String(count);
|
||||
header.appendChild(badge);
|
||||
|
||||
return header;
|
||||
},
|
||||
|
||||
/**
|
||||
* A session row. The state class drives the same visual language as the
|
||||
* session tabs and the phone overview: green dot when it is fine (pulsing and
|
||||
* ringed by the load spinner while working), a yellow row when it wants input,
|
||||
* a red row when it asked a question.
|
||||
*/
|
||||
_buildHomeSessionRow(row) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'home-sessions-row home-sessions-row--' + row.state;
|
||||
item.dataset.hsAction = 'session';
|
||||
item.dataset.hsSession = row.id;
|
||||
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
||||
|
||||
if (row.index < 9) {
|
||||
const number = document.createElement('span');
|
||||
number.className = 'home-sessions-number';
|
||||
number.setAttribute('data-i18n-skip', '');
|
||||
number.textContent = String(row.index + 1);
|
||||
item.appendChild(number);
|
||||
}
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'home-sessions-dot home-sessions-dot--' + row.state;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
const body = document.createElement('span');
|
||||
body.className = 'home-sessions-row-body';
|
||||
|
||||
const line1 = document.createElement('span');
|
||||
line1.className = 'home-sessions-row-title';
|
||||
if (row.modeBadge) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `home-sessions-mode ${row.mode}`;
|
||||
badge.setAttribute('data-i18n-skip', '');
|
||||
badge.textContent = row.modeBadge;
|
||||
line1.appendChild(badge);
|
||||
}
|
||||
const name = document.createElement('span');
|
||||
// .session-name is in the i18n skip list: a session name is user content.
|
||||
name.className = 'session-name';
|
||||
name.textContent = row.name;
|
||||
line1.appendChild(name);
|
||||
body.appendChild(line1);
|
||||
|
||||
const line2 = document.createElement('span');
|
||||
line2.className = 'home-sessions-row-sub';
|
||||
line2.setAttribute('data-i18n-skip', '');
|
||||
line2.textContent = row.caseName || row.dir || row.mode;
|
||||
body.appendChild(line2);
|
||||
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'home-sessions-pill home-sessions-pill--' + row.state;
|
||||
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
|
||||
// that collide with state strings on other surfaces.
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
pill.textContent = row.pill;
|
||||
item.appendChild(pill);
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
/** A saved dashboard, listed after the sessions exactly as in the tab strip. */
|
||||
_buildHomeSessionsWebviewRow(webview) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'home-sessions-row home-sessions-row--web';
|
||||
item.dataset.hsAction = 'webview';
|
||||
item.dataset.hsWebview = webview.id;
|
||||
item.title = webview.url || webview.name;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'home-sessions-dot home-sessions-dot--web';
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
const body = document.createElement('span');
|
||||
body.className = 'home-sessions-row-body';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'home-sessions-row-title';
|
||||
const name = document.createElement('span');
|
||||
// A dashboard name is user content.
|
||||
name.className = 'case-name';
|
||||
name.textContent = webview.name;
|
||||
title.appendChild(name);
|
||||
body.appendChild(title);
|
||||
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'home-sessions-row-sub';
|
||||
sub.setAttribute('data-i18n-skip', '');
|
||||
sub.textContent = webview.url || '';
|
||||
body.appendChild(sub);
|
||||
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'home-sessions-pill home-sessions-pill--web';
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
pill.textContent = 'web';
|
||||
item.appendChild(pill);
|
||||
|
||||
return item;
|
||||
},
|
||||
});
|
||||
@@ -387,6 +387,9 @@
|
||||
'在手机上,点击 C 图标打开会话概览(需要你 / 空间 / 空闲),而不是欢迎页',
|
||||
Phone: '手机',
|
||||
|
||||
// Desktop home screen tab column (home-sessions.js)
|
||||
'Open tabs': '打开的标签',
|
||||
|
||||
// Session/case dialogs
|
||||
'Session Options': '会话选项',
|
||||
'Session Name': '会话名称',
|
||||
|
||||
@@ -322,6 +322,11 @@
|
||||
|
||||
<!-- Welcome Overlay (shown when no session active) -->
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
<!-- Open tabs as a vertical column in the left gutter (home-sessions.js).
|
||||
Absolutely positioned so the centered content below never moves, and
|
||||
therefore only rendered where the gutter is wider than the column;
|
||||
ships `hidden` and only that module reveals it. -->
|
||||
<aside class="home-sessions" id="homeSessions" hidden></aside>
|
||||
<div class="welcome-content">
|
||||
<h1 class="welcome-title">Codeman</h1>
|
||||
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
|
||||
@@ -2769,6 +2774,7 @@
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
* Defines three exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
||||
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
|
||||
* bar adds /clear, /compact, Shift+Tab and more). Tab flushes any locally-buffered
|
||||
* prompt text to the PTY before sending \t, so completion applies to what was typed.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||
@@ -432,7 +434,7 @@ const KeyboardAccessoryBar = {
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
@@ -562,9 +564,26 @@ const KeyboardAccessoryBar = {
|
||||
case 'opt-enter':
|
||||
this.sendKey('\x1b\r');
|
||||
break;
|
||||
case 'tab':
|
||||
this.sendKey('\t');
|
||||
case 'tab': {
|
||||
// Tab means "complete what I just typed", but with local echo the typed
|
||||
// text is still buffered in the overlay and has never reached the PTY —
|
||||
// a bare \t would ask the CLI to complete an empty composer. Flush the
|
||||
// pending text first (same steps as the Shift+Enter branch in
|
||||
// terminal-ui.js), then send \t after the sendCommand settle delay.
|
||||
const overlay = app._localEchoOverlay;
|
||||
const pending = (app._localEchoEnabled && overlay?.pendingText) || '';
|
||||
if (pending) {
|
||||
overlay.clear();
|
||||
overlay.suppressBufferDetection?.();
|
||||
app._flushedOffsets?.delete(app.activeSessionId);
|
||||
app._flushedTexts?.delete(app.activeSessionId);
|
||||
app.sendInput(pending);
|
||||
setTimeout(() => this.sendKey('\t'), 120);
|
||||
} else {
|
||||
this.sendKey('\t');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'shift-tab':
|
||||
this.sendKey('\x1b[Z');
|
||||
break;
|
||||
|
||||
+66
-10
@@ -350,16 +350,53 @@ html.mobile-init .file-browser-panel {
|
||||
Phone Breakpoint (<430px)
|
||||
============================================================================ */
|
||||
@media (max-width: 430px) {
|
||||
/* Phones get a 44px header, up from 36px. Every header control is a touch
|
||||
target and 44px is the floor for one; the brand "C" that gets you home is
|
||||
the one that matters most. Redefined as the TOKEN rather than a literal so
|
||||
the panels positioned off `var(--header-height)` (file browser, insights,
|
||||
plan overlays in styles.css) follow it instead of drifting 8px under the
|
||||
header. Costs 8px of terminal height on a phone. */
|
||||
:root {
|
||||
--header-height: 44px;
|
||||
}
|
||||
|
||||
/* Phone brand collapses to a single "C" home button: hide the wordmark,
|
||||
keep the tap target */
|
||||
.header-brand {
|
||||
padding-right: 0.25rem;
|
||||
margin-right: 0.2rem;
|
||||
padding-right: 0;
|
||||
margin-right: 0.1rem;
|
||||
border-right: none;
|
||||
/* styles.css sizes this to the FULL header height, which is taller than the
|
||||
header's padding box; centred by the header's `align-items: center` above,
|
||||
that overflow is symmetric and the button lands flush with both edges.
|
||||
Do not "fix" it with `height: 100%`: the header sets min/max-height and no
|
||||
height, so the percentage has no definite containing block to resolve
|
||||
against and silently falls back to auto. */
|
||||
}
|
||||
|
||||
/* The "C" was a 0.85rem inline span — about a 12x13px hit area, far under the
|
||||
44px minimum, on the one control that gets you back to the home screen.
|
||||
It is now a real 44x44 button: 44px wide, and the full height of the phone
|
||||
header, which is itself 44px for exactly this reason. The negative margin
|
||||
spends the header's OWN left padding on the target instead of pushing the
|
||||
tab strip right. */
|
||||
.header-brand .logo {
|
||||
font-size: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
/* Taller than its parent on purpose: centred in the padded brand box, this
|
||||
makes the button fill all 36 header pixels edge to edge. */
|
||||
height: var(--header-height);
|
||||
margin-left: -0.3rem;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
border-radius: 8px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.header-brand .logo:active {
|
||||
background: rgba(96, 165, 250, 0.16);
|
||||
}
|
||||
|
||||
.header-brand .logo .logo-text {
|
||||
@@ -404,8 +441,12 @@ html.mobile-init .file-browser-panel {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
min-height: var(--header-height);
|
||||
max-height: var(--header-height);
|
||||
/* styles.css top-aligns header children. That read as centred while the bar
|
||||
was 36px and its contents ~31px; in a 44px bar it leaves a visible gap
|
||||
under everything. */
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.3rem;
|
||||
padding-left: calc(0.3rem + var(--safe-area-left));
|
||||
padding-right: calc(0.3rem + var(--safe-area-right));
|
||||
@@ -420,8 +461,8 @@ html.mobile-init .file-browser-panel {
|
||||
/* iOS safe area adjustment for fixed header - header extends into notch area */
|
||||
.ios-device .header {
|
||||
padding-top: calc(0.15rem + var(--safe-area-top));
|
||||
min-height: calc(36px + var(--safe-area-top));
|
||||
max-height: calc(36px + var(--safe-area-top));
|
||||
min-height: calc(var(--header-height) + var(--safe-area-top));
|
||||
max-height: calc(var(--header-height) + var(--safe-area-top));
|
||||
}
|
||||
|
||||
/* Push ALL content below fixed header (not just .main) so banners
|
||||
@@ -431,11 +472,13 @@ html.mobile-init .file-browser-panel {
|
||||
when keyboard is visible, and resetLayout() clears the inline
|
||||
style to re-expose this CSS value. */
|
||||
.app {
|
||||
padding-top: 42px;
|
||||
/* Header height plus its 1px border and a little slack. Derived from the
|
||||
token so the offset cannot fall out of step with the bar it clears. */
|
||||
padding-top: calc(var(--header-height) + 6px);
|
||||
}
|
||||
|
||||
.ios-device .app {
|
||||
padding-top: calc(42px + var(--safe-area-top));
|
||||
padding-top: calc(var(--header-height) + 6px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
.main {
|
||||
@@ -621,6 +664,16 @@ html.mobile-init .file-browser-panel {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
|
||||
let the pulsing green "working" dot read from arm's length. !important on
|
||||
the glow because the skin block's no-halo rule (styles.css, nested under
|
||||
html:not([data-skin="og"])) outranks any plain class rule here. */
|
||||
.session-tab .tab-status.busy {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Truncate tab names more aggressively on mobile */
|
||||
.session-tab .tab-name {
|
||||
max-width: 50px;
|
||||
@@ -2680,10 +2733,13 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
/* Same as .session-tab .tab-status: green when the session is fine, and the
|
||||
shared `pulse` keyframes while it is working. */
|
||||
shared `pulse` keyframes while it is working. The halo matches the busy tab
|
||||
dot and the desktop home column (.home-sessions-dot--working, styles.css):
|
||||
working reads identically on every surface or it reads as three features. */
|
||||
.mobile-overview-dot--working {
|
||||
background: var(--green);
|
||||
animation: pulse 1.5s infinite;
|
||||
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
|
||||
@@ -13922,3 +13922,299 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
color: #8b93a1;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
Home screen: open tabs in the left gutter (home-sessions.js)
|
||||
|
||||
The welcome content is 560px wide and centered, so this column lives in dead
|
||||
space. It is `position: absolute` precisely so that stays true: the centered
|
||||
content does not move by a pixel whether the column renders or not. That in
|
||||
turn is why the width gate below has to exist — in a narrow window there is
|
||||
no gutter to sit in, and an absolute box would simply overlap the search
|
||||
panel. JS gates on the same 1180px so the two can never disagree.
|
||||
|
||||
The working dot is the phone's, exactly: pulsing green ringed by the very
|
||||
same `tab-load-spin` a tab shows while it loads (reused from above, never
|
||||
re-declared), plus a green halo. One signal, one motion, both home screens.
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.home-sessions {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 256px;
|
||||
max-height: calc(100% - 3rem);
|
||||
text-align: left;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* `hidden` has to be re-asserted over the display above, or the module's only
|
||||
lever (el.hidden) does nothing. */
|
||||
.home-sessions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Belt and braces with shouldShowHomeSessions(): a resize that outruns the
|
||||
matchMedia listener must never leave the column overlapping the content. */
|
||||
@media (max-width: 1179px) {
|
||||
.home-sessions {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.home-sessions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.home-sessions-title {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.home-sessions-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 16px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.home-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 2px 2px 6px;
|
||||
}
|
||||
|
||||
.home-sessions-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.home-sessions-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.home-sessions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border-radius: 9px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-size: 0.76rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-smooth), border-color var(--transition-smooth), color var(--transition-smooth);
|
||||
}
|
||||
|
||||
.home-sessions-row:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.home-sessions-row:active {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.home-sessions-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.home-sessions-dot {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.home-sessions-dot--needs,
|
||||
.home-sessions-dot--error {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.home-sessions-dot--waiting {
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.home-sessions-dot--idle {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.home-sessions-dot--done {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.home-sessions-dot--web {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
.home-sessions-dot--working {
|
||||
background: var(--green);
|
||||
animation: pulse 1.5s infinite;
|
||||
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.home-sessions-dot--working::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border: 2px solid color-mix(in srgb, var(--green) 25%, transparent);
|
||||
border-top-color: var(--green);
|
||||
border-radius: 50%;
|
||||
animation: tab-load-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.home-sessions-row-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.home-sessions-row-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.home-sessions-row-title .session-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-sessions-row-sub {
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-sessions-mode {
|
||||
flex-shrink: 0;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.home-sessions-pill {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-sessions-pill--needs,
|
||||
.home-sessions-pill--error {
|
||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--red) 45%, transparent);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.home-sessions-pill--waiting {
|
||||
background: color-mix(in srgb, var(--yellow) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--yellow) 45%, transparent);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.home-sessions-pill--working,
|
||||
.home-sessions-pill--idle {
|
||||
background: color-mix(in srgb, var(--green) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--green) 40%, transparent);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Row accents: same language as the session tabs and the phone overview — red
|
||||
means a question is pending, yellow means it wants input, green means work is
|
||||
happening. Nothing else on this screen may reuse these colors. */
|
||||
.home-sessions-row--needs,
|
||||
.home-sessions-row--error {
|
||||
border-color: color-mix(in srgb, var(--red) 50%, transparent);
|
||||
animation: home-sessions-blink-red 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-sessions-row--waiting {
|
||||
border-color: color-mix(in srgb, var(--yellow) 50%, transparent);
|
||||
animation: home-sessions-blink-yellow 3.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-sessions-row--working {
|
||||
border-color: color-mix(in srgb, var(--green) 35%, transparent);
|
||||
}
|
||||
|
||||
@keyframes home-sessions-blink-red {
|
||||
0%, 100% { border-color: color-mix(in srgb, var(--red) 50%, transparent); }
|
||||
50% { border-color: color-mix(in srgb, var(--red) 95%, transparent); }
|
||||
}
|
||||
|
||||
@keyframes home-sessions-blink-yellow {
|
||||
0%, 100% { border-color: color-mix(in srgb, var(--yellow) 45%, transparent); }
|
||||
50% { border-color: color-mix(in srgb, var(--yellow) 90%, transparent); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-sessions-row,
|
||||
.home-sessions-dot,
|
||||
.home-sessions-dot::after {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1427,6 +1427,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.shouldUseMobileOverview?.()) {
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) overlay.classList.remove('visible');
|
||||
this.hideHomeSessions?.();
|
||||
this.showMobileOverview();
|
||||
this._updateCjkInputState?.();
|
||||
return;
|
||||
@@ -1439,6 +1440,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.applyWelcomeCliVisibility();
|
||||
this.loadHistorySessions();
|
||||
this.initSearchPanel();
|
||||
// Open tabs down the left gutter. Self-gating: a window too narrow to hold
|
||||
// the column without overlapping the content leaves it hidden.
|
||||
this.showHomeSessions?.();
|
||||
}
|
||||
// Home screen has no input target — hide the CJK textarea (activeSessionId
|
||||
// is null by the time we get here). Guarded: defined on the app object.
|
||||
@@ -1447,6 +1451,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
hideWelcome() {
|
||||
this.hideMobileOverview?.();
|
||||
this.hideHomeSessions?.();
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
|
||||
@@ -11,6 +11,7 @@ export { registerCronRoutes } from './cron-routes.js';
|
||||
export { registerSystemRoutes } from './system-routes.js';
|
||||
export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerApprovalRoutes } from './approval-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @fileoverview Read My Mind intent routes.
|
||||
*
|
||||
* Per-case intent profiles feeding the Read My Mind predictor
|
||||
* (docs/readmymind-plan.md):
|
||||
* - `GET /api/sessions/:id/intent`: the profile for the session's case
|
||||
* - `PUT /api/sessions/:id/intent`: replace the goals text
|
||||
* - `DELETE /api/sessions/:id/intent`: forget the case's profile
|
||||
*
|
||||
* The profile is keyed by owner + workingDir, so multi-user scoping is
|
||||
* structural; session ownership is still enforced via `findSessionOrFail`
|
||||
* (with `req`, so a foreign session id 404s) to keep the session-routes
|
||||
* no-existence-leak policy.
|
||||
*
|
||||
* Deliberately session-scoped rather than a raw `/api/intents/:key` surface:
|
||||
* the session resolves owner + workingDir server-side, so a caller can never
|
||||
* address another case's profile by guessing keys.
|
||||
*
|
||||
* Registrations use the bare `app.<method>('path', ...)` + `req.params as`
|
||||
* shape (session-routes style): these endpoints are documented in the agent
|
||||
* skill, and the endpoints.md drift test's scanner does not see registrations
|
||||
* with a generic between the method and the path.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { IntentGoalsSchema } from '../schemas.js';
|
||||
import { parseBody, findSessionOrFail } from '../route-helpers.js';
|
||||
import { intentStore } from '../../intent-store.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
app.get('/api/sessions/:id/intent', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
return { success: true, data: { intent: intentStore.getProfile(session.owner, session.workingDir) } };
|
||||
});
|
||||
|
||||
app.put('/api/sessions/:id/intent', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(IntentGoalsSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
return { success: true, data: { intent: intentStore.setGoals(session.owner, session.workingDir, body.goals) } };
|
||||
});
|
||||
|
||||
app.delete('/api/sessions/:id/intent', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
return { success: true, data: { deleted: intentStore.deleteProfile(session.owner, session.workingDir) } };
|
||||
});
|
||||
}
|
||||
@@ -700,6 +700,16 @@ export const ApprovalAnswerSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Body of PUT /api/sessions/:id/intent (Read My Mind). The 8192 cap mirrors
|
||||
* MAX_GOALS_CHARS in intent-store.ts.
|
||||
*/
|
||||
export const IntentGoalsSchema = z
|
||||
.object({
|
||||
goals: z.string().max(8192),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ========== Configuration ==========
|
||||
|
||||
/**
|
||||
@@ -809,6 +819,13 @@ export const SettingsUpdateSchema = z
|
||||
* already pending immediately.
|
||||
*/
|
||||
approvalsInboxEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
|
||||
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
|
||||
* captured prompts are sensitive). OFF stops capture immediately; already
|
||||
* stored profiles stay until DELETE /api/sessions/:id/intent.
|
||||
*/
|
||||
readMyMindEnabled: z.boolean().optional(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
// Action field (NOT persisted): explicit per-request acknowledgment that the
|
||||
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
|
||||
|
||||
+26
-1
@@ -85,7 +85,8 @@ import {
|
||||
attachSessionListeners,
|
||||
detachSessionListeners,
|
||||
} from './session-listener-wiring.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
import { sessionWaits, hooksAvailableForMode } from './session-wait-registry.js';
|
||||
import { intentStore } from '../intent-store.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
@@ -149,6 +150,7 @@ import {
|
||||
registerScheduledRoutes,
|
||||
registerHookEventRoutes,
|
||||
registerApprovalRoutes,
|
||||
registerReadMyMindRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
@@ -955,6 +957,7 @@ export class WebServer extends EventEmitter {
|
||||
registerScheduledRoutes(this.app, ctx);
|
||||
registerHookEventRoutes(this.app, ctx);
|
||||
registerApprovalRoutes(this.app, ctx);
|
||||
registerReadMyMindRoutes(this.app, ctx);
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
@@ -1022,6 +1025,10 @@ export class WebServer extends EventEmitter {
|
||||
console.error(`[Transcript] Error for session ${sessionId}:`, error.message);
|
||||
});
|
||||
|
||||
watcher.on('transcript:user_prompt', (text: string) => {
|
||||
void this.captureIntentPrompt(sessionId, text);
|
||||
});
|
||||
|
||||
this.transcriptWatchers.set(sessionId, watcher);
|
||||
}
|
||||
|
||||
@@ -1029,6 +1036,24 @@ export class WebServer extends EventEmitter {
|
||||
watcher.updatePath(transcriptPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read My Mind intent capture: fold one transcript user prompt into the
|
||||
* case's intent profile (docs/readmymind-plan.md). Opt-in via
|
||||
* `readMyMindEnabled` (default OFF) and claude-only; the mode gate is
|
||||
* belt-and-braces since only hook-fed sessions have a transcript watcher.
|
||||
*/
|
||||
private async captureIntentPrompt(sessionId: string, text: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !hooksAvailableForMode(session.mode)) return;
|
||||
try {
|
||||
const settings = await this.readSettings();
|
||||
if (settings.readMyMindEnabled !== true) return;
|
||||
intentStore.recordPrompt(session.owner, session.workingDir, sessionId, text);
|
||||
} catch (err) {
|
||||
console.warn(`[IntentStore] Capture failed for session ${sessionId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the transcript watcher for a session.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// Port: none (pure model + static markup assertions — no browser, no server).
|
||||
//
|
||||
// The desktop home screen's tab column (src/web/public/home-sessions.js) fills
|
||||
// the welcome overlay's left gutter. Two things about it can silently go wrong
|
||||
// and are pinned here: the row ORDER (it mirrors the tab strip, unlike the phone
|
||||
// overview which sorts by urgency, and the number badges are only correct if it
|
||||
// does), and the WIDTH GATE, which lives in two places at once — the JS constant
|
||||
// and a CSS media query — because the column is absolutely positioned and would
|
||||
// overlap the search panel in a narrow window.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
/** Minimal fake DOM node — enough surface for the programmatic row builders. */
|
||||
function fakeElement(): any {
|
||||
const el: any = {
|
||||
className: '',
|
||||
type: '',
|
||||
title: '',
|
||||
textContent: '',
|
||||
dataset: {},
|
||||
style: {},
|
||||
children: [] as any[],
|
||||
setAttribute() {},
|
||||
appendChild(child: any) {
|
||||
el.children.push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` /
|
||||
* `shouldUseMobileOverview` from mobile-overview.js, so both files run in the
|
||||
* same context — which is also the point: if that reuse ever breaks, these
|
||||
* tests stop loading rather than quietly testing a divergent copy.
|
||||
*/
|
||||
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window: { innerWidth },
|
||||
document: {
|
||||
getElementById: () => null,
|
||||
createElement: () => fakeElement(),
|
||||
createElementNS: () => fakeElement(),
|
||||
},
|
||||
MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') },
|
||||
});
|
||||
for (const file of ['mobile-overview.js', 'home-sessions.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.getSessionName = (session: any) => session.name || session.id.slice(0, 8);
|
||||
app._shortenHomePath = (p: string) => (p || '').replace(/^\/home\/[^/]+\//, '~/');
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
Object.assign(app, overrides);
|
||||
return app;
|
||||
}
|
||||
|
||||
const CASES = [{ name: 'claudeman', path: '/home/arkon/default/claudeman', location: 'local' }];
|
||||
|
||||
function sessionMap(list: Array<Record<string, any>>) {
|
||||
return new Map(
|
||||
list.map((over) => {
|
||||
const s = { id: 'x', status: 'idle', mode: 'claude', workingDir: '/home/arkon/default/claudeman', ...over };
|
||||
return [s.id, s];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('home sessions column: model', () => {
|
||||
it('lists rows in TAB order, not by urgency, so the number badges match Alt+1..9', () => {
|
||||
// The phone overview would hoist 'needy' to the top; this surface must not,
|
||||
// because its badges are the Alt+N indices.
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]),
|
||||
sessionOrder: ['first', 'needy', 'third'],
|
||||
cases: CASES,
|
||||
pendingHooks: new Map([['needy', new Set(['permission_prompt'])]]),
|
||||
});
|
||||
|
||||
const rows = app.buildHomeSessionRows();
|
||||
expect(rows.map((r: any) => r.id)).toEqual(['first', 'needy', 'third']);
|
||||
expect(rows.map((r: any) => r.index)).toEqual([0, 1, 2]);
|
||||
expect(rows[1].state).toBe('needs');
|
||||
expect(rows[1].pill).toBe('needs you');
|
||||
});
|
||||
|
||||
it('shows a session that is not in the order list yet', () => {
|
||||
// A freshly created session exists in this.sessions before the order array
|
||||
// catches up; its tab is already on screen, so its row must be too.
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'known' }, { id: 'fresh' }]),
|
||||
sessionOrder: ['known'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
expect(app.buildHomeSessionRows().map((r: any) => r.id)).toEqual(['known', 'fresh']);
|
||||
});
|
||||
|
||||
it('classifies state through the shared phone-overview helper', () => {
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([
|
||||
{ id: 'w', status: 'busy' },
|
||||
{ id: 'i', status: 'idle' },
|
||||
{ id: 'd', status: 'stopped' },
|
||||
{ id: 'e', status: 'error' },
|
||||
]),
|
||||
sessionOrder: ['w', 'i', 'd', 'e'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([
|
||||
['working', 'working'],
|
||||
['idle', 'idle'],
|
||||
['done', 'done'],
|
||||
['error', 'error'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels a row with its case and a short backend badge', () => {
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'a', name: 'w1-claudeman', mode: 'codex' }]),
|
||||
sessionOrder: ['a'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const [row] = app.buildHomeSessionRows();
|
||||
expect(row.caseName).toBe('claudeman');
|
||||
expect(row.modeBadge).toBe('cx');
|
||||
// claude is the default backend and gets no badge — the strip does the same.
|
||||
const plain = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'a', mode: 'claude' }]),
|
||||
sessionOrder: ['a'],
|
||||
cases: CASES,
|
||||
});
|
||||
expect(plain.buildHomeSessionRows()[0].modeBadge).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('home sessions column: gate', () => {
|
||||
it('renders on a wide desktop', () => {
|
||||
const app = loadHomeSessionsApp({}, 1512);
|
||||
expect(app.shouldShowHomeSessions()).toBe(true);
|
||||
});
|
||||
|
||||
it('stays out of a window too narrow to hold it beside the centered content', () => {
|
||||
// Absolutely positioned: below the gate it would overlap the search panel
|
||||
// rather than push it aside.
|
||||
expect(loadHomeSessionsApp({}, 1100).shouldShowHomeSessions()).toBe(false);
|
||||
expect(loadHomeSessionsApp({}, 1179).shouldShowHomeSessions()).toBe(false);
|
||||
expect(loadHomeSessionsApp({}, 1180).shouldShowHomeSessions()).toBe(true);
|
||||
});
|
||||
|
||||
it('yields to the phone overview, which already lists the same sessions', () => {
|
||||
const app = loadHomeSessionsApp({}, 390);
|
||||
expect(app.shouldUseMobileOverview()).toBe(true);
|
||||
expect(app.shouldShowHomeSessions()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays out of a popped-out solo window', () => {
|
||||
expect(loadHomeSessionsApp({ isSoloWindow: true }, 1512).shouldShowHomeSessions()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('home sessions column: wiring', () => {
|
||||
const js = readFileSync(resolve(PUBLIC, 'home-sessions.js'), 'utf8');
|
||||
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
|
||||
it('keeps the JS width gate and the CSS media query in agreement', () => {
|
||||
// Two gates for one decision: the JS one hides the element, the CSS one is
|
||||
// the backstop for a resize that outruns the matchMedia listener. Drift
|
||||
// means a column that overlaps the welcome content at some widths.
|
||||
const jsMin = Number(/HOME_SESSIONS_MIN_WIDTH = (\d+)/.exec(js)?.[1]);
|
||||
const cssMax = Number(/@media \(max-width: (\d+)px\) \{\s*\.home-sessions \{/.exec(css)?.[1]);
|
||||
expect(jsMin).toBeGreaterThan(0);
|
||||
expect(cssMax).toBe(jsMin - 1);
|
||||
});
|
||||
|
||||
it('re-asserts [hidden] over the flex display', () => {
|
||||
// .home-sessions is display:flex, which defeats the `hidden` attribute — the
|
||||
// module's only visibility lever — unless this rule exists.
|
||||
expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/);
|
||||
});
|
||||
|
||||
it('reuses the tab-load spinner rather than declaring a second one', () => {
|
||||
// The working ring is the same motion a tab shows while it loads, on both
|
||||
// home screens. Re-declaring the keyframes here is how they drift apart.
|
||||
expect(js).toContain('tab-load-spin');
|
||||
expect(css).toMatch(/\.home-sessions-dot--working::after[\s\S]*?animation: tab-load-spin/);
|
||||
expect(css).not.toMatch(/@keyframes home-sessions-load-spin/);
|
||||
const mobileCss = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
|
||||
expect(mobileCss).toMatch(/\.mobile-overview-dot--working::after[\s\S]*?animation: tab-load-spin/);
|
||||
});
|
||||
|
||||
it('gives the working dot the same green halo on both home screens', () => {
|
||||
const halo = /box-shadow: 0 0 8px 2px color-mix\(in srgb, var\(--green\) 55%, transparent\)/;
|
||||
expect(css).toMatch(halo);
|
||||
expect(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')).toMatch(halo);
|
||||
});
|
||||
|
||||
it('ships the container hidden, inside the welcome overlay, loaded after mobile-overview.js', () => {
|
||||
expect(html).toMatch(/<aside class="home-sessions" id="homeSessions" hidden><\/aside>/);
|
||||
const overlayStart = html.indexOf('id="welcomeOverlay"');
|
||||
const aside = html.indexOf('id="homeSessions"');
|
||||
const content = html.indexOf('class="welcome-content"');
|
||||
expect(overlayStart).toBeGreaterThan(-1);
|
||||
expect(aside).toBeGreaterThan(overlayStart);
|
||||
expect(aside).toBeLessThan(content);
|
||||
// Load order: the module reuses prototype methods installed by
|
||||
// mobile-overview.js. Compare the <script> tags, not any mention: both
|
||||
// files are named in explanatory comments earlier in the document.
|
||||
expect(html.indexOf('src="home-sessions.js"')).toBeGreaterThan(html.indexOf('src="mobile-overview.js"'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the Read My Mind intent store (src/intent-store.ts).
|
||||
*
|
||||
* Pure helpers (key derivation, capturability filter, sanitization, append fold)
|
||||
* plus the IO layer against a per-test temp data dir (CODEMAN_DATA_DIR) so
|
||||
* nothing touches the real ~/.codeman. No server, no tmux.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { statSync, existsSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
appendPrompt,
|
||||
deriveIntentKey,
|
||||
IntentStore,
|
||||
isCapturablePrompt,
|
||||
MAX_GOALS_CHARS,
|
||||
MAX_INTENT_PROFILES,
|
||||
MAX_PROMPT_CHARS,
|
||||
MAX_RECENT_PROMPTS,
|
||||
sanitizePromptText,
|
||||
} from '../src/intent-store.js';
|
||||
import type { IntentProfile } from '../src/types/index.js';
|
||||
|
||||
let tmpDir: string;
|
||||
let savedDataDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-intents-'));
|
||||
savedDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
process.env.CODEMAN_DATA_DIR = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = savedDataDir;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const intentsFile = () => path.join(tmpDir, 'intents.json');
|
||||
|
||||
function makeProfile(overrides: Partial<IntentProfile> = {}): IntentProfile {
|
||||
return { key: 'k', workingDir: '/w', updatedAt: 0, goals: '', recentPrompts: [], ...overrides };
|
||||
}
|
||||
|
||||
describe('deriveIntentKey', () => {
|
||||
it('is stable and 16 lowercase hex chars', () => {
|
||||
const a = deriveIntentKey('alice', '/home/alice/proj');
|
||||
expect(a).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(deriveIntentKey('alice', '/home/alice/proj')).toBe(a);
|
||||
});
|
||||
|
||||
it('separates owners and directories', () => {
|
||||
expect(deriveIntentKey('alice', '/p')).not.toBe(deriveIntentKey('bob', '/p'));
|
||||
expect(deriveIntentKey('alice', '/p')).not.toBe(deriveIntentKey('alice', '/q'));
|
||||
expect(deriveIntentKey(undefined, '/p')).not.toBe(deriveIntentKey('alice', '/p'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCapturablePrompt', () => {
|
||||
it('rejects local command echo and system wrappers', () => {
|
||||
expect(isCapturablePrompt('<command-name>/model</command-name>')).toBe(false);
|
||||
expect(isCapturablePrompt('before <local-command-stdout>out</local-command-stdout>')).toBe(false);
|
||||
expect(isCapturablePrompt('<system-reminder>context</system-reminder>')).toBe(false);
|
||||
expect(isCapturablePrompt('Caveat: The messages below were generated…')).toBe(false);
|
||||
expect(isCapturablePrompt('[Request interrupted by user]')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a normal prompt', () => {
|
||||
expect(isCapturablePrompt('fix the login bug and add a test')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizePromptText', () => {
|
||||
it('collapses newlines and strips control chars', () => {
|
||||
expect(sanitizePromptText('line one\nline two\r\nthree')).toBe('line one line two three');
|
||||
expect(sanitizePromptText('a\x1b[31mred\x1b[0mb end')).toBe('a[31mred[0mb end');
|
||||
});
|
||||
|
||||
it('returns null for menu-digit noise', () => {
|
||||
expect(sanitizePromptText('1')).toBeNull();
|
||||
expect(sanitizePromptText(' \n ')).toBeNull();
|
||||
});
|
||||
|
||||
it('truncates to the cap', () => {
|
||||
const out = sanitizePromptText('x'.repeat(MAX_PROMPT_CHARS + 100));
|
||||
expect(out).toHaveLength(MAX_PROMPT_CHARS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendPrompt', () => {
|
||||
it('collapses consecutive duplicates but keeps non-adjacent ones', () => {
|
||||
let p = makeProfile();
|
||||
p = appendPrompt(p, { ts: 1, sessionId: 's', text: 'continue' });
|
||||
p = appendPrompt(p, { ts: 2, sessionId: 's', text: 'continue' });
|
||||
expect(p.recentPrompts).toHaveLength(1);
|
||||
expect(p.updatedAt).toBe(2);
|
||||
p = appendPrompt(p, { ts: 3, sessionId: 's', text: 'run tests' });
|
||||
p = appendPrompt(p, { ts: 4, sessionId: 's', text: 'continue' });
|
||||
expect(p.recentPrompts.map((e) => e.text)).toEqual(['continue', 'run tests', 'continue']);
|
||||
});
|
||||
|
||||
it('FIFO-caps at MAX_RECENT_PROMPTS, dropping the oldest', () => {
|
||||
let p = makeProfile();
|
||||
for (let i = 0; i < MAX_RECENT_PROMPTS + 5; i++) {
|
||||
p = appendPrompt(p, { ts: i, sessionId: 's', text: `prompt number ${i}` });
|
||||
}
|
||||
expect(p.recentPrompts).toHaveLength(MAX_RECENT_PROMPTS);
|
||||
expect(p.recentPrompts[0].text).toBe('prompt number 5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IntentStore', () => {
|
||||
it('records a prompt, persists 0600, and reloads from disk', () => {
|
||||
const store = new IntentStore();
|
||||
expect(store.recordPrompt('alice', tmpDir, 'sess1', 'ship the release')).toBe(true);
|
||||
expect(existsSync(intentsFile())).toBe(true);
|
||||
expect(statSync(intentsFile()).mode & 0o777).toBe(0o600);
|
||||
|
||||
const reloaded = new IntentStore();
|
||||
const profile = reloaded.getProfile('alice', tmpDir);
|
||||
expect(profile.recentPrompts.map((e) => e.text)).toEqual(['ship the release']);
|
||||
expect(profile.updatedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('getProfile on an absent case returns an empty transient profile without persisting', () => {
|
||||
const store = new IntentStore();
|
||||
const profile = store.getProfile('alice', tmpDir);
|
||||
expect(profile.updatedAt).toBe(0);
|
||||
expect(profile.goals).toBe('');
|
||||
expect(profile.recentPrompts).toEqual([]);
|
||||
expect(existsSync(intentsFile())).toBe(false);
|
||||
});
|
||||
|
||||
it('filters uncapturable and too-short prompts', () => {
|
||||
const store = new IntentStore();
|
||||
expect(store.recordPrompt('a', tmpDir, 's', '<command-name>/clear</command-name>')).toBe(false);
|
||||
expect(store.recordPrompt('a', tmpDir, 's', '2')).toBe(false);
|
||||
expect(existsSync(intentsFile())).toBe(false);
|
||||
});
|
||||
|
||||
it('keys by resolved directory so path spellings converge', () => {
|
||||
const store = new IntentStore();
|
||||
store.recordPrompt('a', `${tmpDir}${path.sep}.`, 's', 'same case either way');
|
||||
const profile = store.getProfile('a', tmpDir);
|
||||
expect(profile.recentPrompts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('separates owners of the same directory', () => {
|
||||
const store = new IntentStore();
|
||||
store.recordPrompt('alice', tmpDir, 's', 'alice private plan');
|
||||
expect(store.getProfile('bob', tmpDir).recentPrompts).toEqual([]);
|
||||
});
|
||||
|
||||
it('setGoals bounds the text and deleteProfile forgets the case', () => {
|
||||
const store = new IntentStore();
|
||||
const updated = store.setGoals('a', tmpDir, 'g'.repeat(MAX_GOALS_CHARS + 50));
|
||||
expect(updated.goals).toHaveLength(MAX_GOALS_CHARS);
|
||||
|
||||
expect(store.deleteProfile('a', tmpDir)).toBe(true);
|
||||
expect(store.deleteProfile('a', tmpDir)).toBe(false);
|
||||
expect(store.getProfile('a', tmpDir).goals).toBe('');
|
||||
});
|
||||
|
||||
it('evicts the least-recently-updated profile past the cap', () => {
|
||||
const store = new IntentStore();
|
||||
for (let i = 0; i <= MAX_INTENT_PROFILES; i++) {
|
||||
store.setGoals('a', `${tmpDir}/case-${i}`, `goal ${i}`);
|
||||
}
|
||||
const reloaded = new IntentStore();
|
||||
expect(reloaded.getProfile('a', `${tmpDir}/case-0`).goals).toBe('');
|
||||
expect(reloaded.getProfile('a', `${tmpDir}/case-${MAX_INTENT_PROFILES}`).goals).toBe(`goal ${MAX_INTENT_PROFILES}`);
|
||||
});
|
||||
|
||||
it('starts empty on a corrupted state file', () => {
|
||||
const store = new IntentStore();
|
||||
store.setGoals('a', tmpDir, 'valid');
|
||||
return fs.writeFile(intentsFile(), '{ not json').then(() => {
|
||||
const reloaded = new IntentStore();
|
||||
expect(reloaded.getProfile('a', tmpDir).goals).toBe('');
|
||||
expect(reloaded.recordPrompt('a', tmpDir, 's', 'recover cleanly')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -143,3 +143,48 @@ describe('Mobile header button policy (static guard)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The flip side of the policy above: the ONE header control phones do keep has
|
||||
// to be pressable. The brand "C" is the way back to the home screen and was a
|
||||
// 0.85rem inline span — roughly a 12x13px target, well under the 44px minimum.
|
||||
describe('Phone home button tap target (static guard)', () => {
|
||||
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
|
||||
|
||||
/** Declarations applying to `.header-brand .logo` inside a phone media query. */
|
||||
function phoneLogoDecls(): Map<string, string> {
|
||||
const decls = new Map<string, string>();
|
||||
postcss.parse(css).walkAtRules('media', (atRule) => {
|
||||
if (!appliesToPhone(atRule.params)) return;
|
||||
atRule.walkRules((rule) => {
|
||||
if (!/\.header-brand\s+\.logo\s*$/.test(rule.selector)) return;
|
||||
rule.walkDecls((decl) => decls.set(decl.prop, decl.value));
|
||||
});
|
||||
});
|
||||
return decls;
|
||||
}
|
||||
|
||||
it('gives the brand button a 44x44 hit area on phones', () => {
|
||||
const decls = phoneLogoDecls();
|
||||
expect(decls.get('min-width'), 'the "C" home button needs an explicit 44px min-width on phones').toBe('44px');
|
||||
// A bare inline span ignores width entirely — the box only exists once it
|
||||
// stops being inline.
|
||||
expect(decls.get('display')).toBe('inline-flex');
|
||||
// The other axis is the header's, so the two have to be read together: the
|
||||
// button is only 44 tall because the phone header is.
|
||||
expect(decls.get('height')).toBe('var(--header-height)');
|
||||
});
|
||||
|
||||
it('keeps the phone header at 44px, the height that makes that target square', () => {
|
||||
// The bar was 36px. Shrinking it again silently takes 8px back off every
|
||||
// header touch target, the home button included.
|
||||
let phoneHeaderHeight: string | undefined;
|
||||
postcss.parse(css).walkAtRules('media', (atRule) => {
|
||||
if (!appliesToPhone(atRule.params)) return;
|
||||
atRule.walkRules((rule) => {
|
||||
if (rule.selector.trim() !== ':root') return;
|
||||
rule.walkDecls('--header-height', (decl) => (phoneHeaderHeight = decl.value.trim()));
|
||||
});
|
||||
});
|
||||
expect(phoneHeaderHeight, '--header-height must be redefined for phones in mobile.css').toBe('44px');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,7 +469,9 @@ describe('Virtual Keyboard', () => {
|
||||
(button) => (button as HTMLElement).dataset.action
|
||||
);
|
||||
});
|
||||
expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'clear', 'paste', 'dismiss']);
|
||||
// Tab replaced /clear in the simple bar; /clear and /compact live in the
|
||||
// extended bar only.
|
||||
expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'tab', 'paste', 'esc', 'dismiss']);
|
||||
});
|
||||
|
||||
it('double-tap confirm on /clear button', async () => {
|
||||
@@ -477,9 +479,11 @@ describe('Virtual Keyboard', () => {
|
||||
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
|
||||
|
||||
// handleAction() early-returns if app.activeSessionId is falsy — mock it
|
||||
// handleAction() early-returns if app.activeSessionId is falsy — mock it.
|
||||
// /clear only exists in the extended bar now, so switch modes first.
|
||||
await page.evaluate(`
|
||||
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
|
||||
KeyboardAccessoryBar.setMode('extended');
|
||||
`);
|
||||
|
||||
// Click via JS since the button is positioned outside the viewport
|
||||
@@ -503,6 +507,8 @@ describe('Virtual Keyboard', () => {
|
||||
return btn?.textContent?.trim();
|
||||
});
|
||||
expect(text).toBe('Tap again');
|
||||
|
||||
await page.evaluate(`KeyboardAccessoryBar.setMode('simple');`);
|
||||
});
|
||||
|
||||
it('double-tap expires after 2s', async () => {
|
||||
@@ -511,6 +517,7 @@ describe('Virtual Keyboard', () => {
|
||||
|
||||
await page.evaluate(`
|
||||
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
|
||||
KeyboardAccessoryBar.setMode('extended');
|
||||
`);
|
||||
|
||||
// First tap on clear via JS
|
||||
@@ -535,6 +542,8 @@ describe('Virtual Keyboard', () => {
|
||||
return btn?.classList.contains('confirming') ?? false;
|
||||
});
|
||||
expect(afterExpiry).toBe(false);
|
||||
|
||||
await page.evaluate(`KeyboardAccessoryBar.setMode('simple');`);
|
||||
});
|
||||
|
||||
it('dismiss button blurs active element', async () => {
|
||||
@@ -766,6 +775,76 @@ describe('Virtual Keyboard', () => {
|
||||
expect(activeClass).toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
// Regression guard for the phone-keyboard blocker reduced in #173 and re-hit
|
||||
// by #244. selectSession() ends with scrollToLastNonEmptyLine(), which parks
|
||||
// the viewport ABOVE the bottom for any session whose buffer is taller than
|
||||
// the screen and ends in blank rows, i.e. every real session after a tab
|
||||
// switch. A tap-routing scheme that treats "viewport is scrolled up" as a
|
||||
// reason to blur strands document.activeElement on <body> with no way to
|
||||
// raise the keyboard, and the prompt row is no exception. Suppressing the
|
||||
// MOUSE REPORT while scrolled up is correct and pinned below; suppressing
|
||||
// FOCUS is not. Measured against PR #244 on 2026-08-09: body vs textarea.
|
||||
//
|
||||
// Must be a dispatched gesture: calling the touchend handler directly
|
||||
// bypasses touchstart's preventDefault, which is half of what closes the
|
||||
// focus path, so a direct call reports the right intent and still misses.
|
||||
it('keeps the terminal input focusable after a tab switch parks the viewport off-bottom', async () => {
|
||||
const probe = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-offbottom-tap-test';
|
||||
app.sessions.set('mobile-offbottom-tap-test', {
|
||||
id: 'mobile-offbottom-tap-test',
|
||||
mode: 'claude',
|
||||
cliVersion: '2.1.220',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
|
||||
// Taller than the viewport, ending in the trailing blank rows that make
|
||||
// scrollToLastNonEmptyLine() stop short of the bottom.
|
||||
const lines: string[] = [];
|
||||
for (let i = 1; i <= app.terminal.rows * 3; i++) lines.push(`Transcript row ${i}`);
|
||||
lines.push('', '❯ ', '', '');
|
||||
await new Promise<void>((resolve) => app.terminal.write(lines.join('\r\n'), resolve));
|
||||
|
||||
app.scrollToLastNonEmptyLine(); // what selectSession() does on every tab switch
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
const buffer = app.terminal.buffer.active;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height * 5.5,
|
||||
atBottom: buffer.viewportY >= buffer.baseY,
|
||||
};
|
||||
});
|
||||
|
||||
expect(probe).not.toBeNull();
|
||||
// The guard only means anything if the viewport really did park off-bottom.
|
||||
expect(probe!.atBottom).toBe(false);
|
||||
|
||||
await page.touchscreen.tap(probe!.x, probe!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
||||
// SGR coordinates are meaningless off-bottom, so the tap must stay silent.
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
|
||||
const calls = await page.evaluate(async () => {
|
||||
app.activeSessionId = 'mobile-touch-scroll-test';
|
||||
|
||||
@@ -45,11 +45,12 @@ function loadKeyboardModule() {
|
||||
insertTerminalText: vi.fn(),
|
||||
sendInput: vi.fn(),
|
||||
};
|
||||
const fetchMock = vi.fn(() => Promise.resolve({ ok: true }));
|
||||
const context = vm.createContext({
|
||||
app,
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
URLSearchParams,
|
||||
fetch: vi.fn(),
|
||||
fetch: fetchMock,
|
||||
document: {},
|
||||
setTimeout: (fn: () => void) => {
|
||||
fn();
|
||||
@@ -63,11 +64,66 @@ function loadKeyboardModule() {
|
||||
);
|
||||
return {
|
||||
app,
|
||||
fetchMock,
|
||||
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
|
||||
picker: (context as unknown as { __picker: { open: ReturnType<typeof vi.fn> } }).__picker,
|
||||
};
|
||||
}
|
||||
|
||||
describe('accessory Tab key', () => {
|
||||
it('replaced /clear in the simple bar; /clear stays extended-only', () => {
|
||||
const simple = keyboardSource.slice(
|
||||
keyboardSource.indexOf('_simpleButtons'),
|
||||
keyboardSource.indexOf('_extendedButtons')
|
||||
);
|
||||
expect(simple).toContain('data-action="tab"');
|
||||
expect(simple).not.toContain('data-action="clear" title="/clear"');
|
||||
expect(simple).not.toContain('data-action="compact"');
|
||||
});
|
||||
|
||||
it('sends a bare \\t when nothing is buffered locally', () => {
|
||||
const { app, bar, fetchMock } = loadKeyboardModule();
|
||||
bar.handleAction('tab');
|
||||
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/sessions/session-1/input');
|
||||
expect(JSON.parse(init.body)).toEqual({ input: '\t' });
|
||||
});
|
||||
|
||||
it('flushes locally-buffered prompt text to the PTY before sending Tab', () => {
|
||||
const { app, bar, fetchMock } = loadKeyboardModule() as ReturnType<typeof loadKeyboardModule> & {
|
||||
app: Record<string, unknown>;
|
||||
};
|
||||
const overlay = {
|
||||
pendingText: 'git sta',
|
||||
clear: vi.fn(),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
};
|
||||
Object.assign(app, {
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: overlay,
|
||||
_flushedOffsets: new Map([['session-1', 3]]),
|
||||
_flushedTexts: new Map([['session-1', 'git']]),
|
||||
});
|
||||
|
||||
bar.handleAction('tab');
|
||||
|
||||
expect(overlay.clear).toHaveBeenCalledOnce();
|
||||
expect(overlay.suppressBufferDetection).toHaveBeenCalledOnce();
|
||||
expect((app as { _flushedOffsets: Map<string, number> })._flushedOffsets.has('session-1')).toBe(false);
|
||||
expect((app as { _flushedTexts: Map<string, string> })._flushedTexts.has('session-1')).toBe(false);
|
||||
expect(app.sendInput).toHaveBeenCalledWith('git sta');
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ input: '\t' });
|
||||
// Text must reach the PTY before the completion request.
|
||||
const sendInputOrder = (app.sendInput as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
|
||||
const fetchOrder = fetchMock.mock.invocationCallOrder[0];
|
||||
expect(sendInputOrder).toBeLessThan(fetchOrder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile filesystem picker actions', () => {
|
||||
it('keeps clear-input separate from the destructive /clear command', () => {
|
||||
const { app, bar } = loadKeyboardModule();
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @fileoverview Read My Mind intent route tests (src/web/routes/readmymind-routes.ts)
|
||||
* via app.inject(), no live port.
|
||||
*
|
||||
* The routes read the process-wide `intentStore` singleton, whose data file
|
||||
* resolves under this test file's temp HOME (test/setup.ts). The singleton's
|
||||
* in-memory map lives for the whole file, so each test uses a distinct
|
||||
* session workingDir to stay isolated.
|
||||
*
|
||||
* Port: SessionPort.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { registerReadMyMindRoutes } from '../../src/web/routes/readmymind-routes.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
|
||||
const SESSION_ID = 'test-session-1';
|
||||
|
||||
let harness: RouteTestHarness;
|
||||
let caseCounter = 0;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerReadMyMindRoutes);
|
||||
// Unique (nonexistent) workingDir per test: resolveDir falls back to the raw
|
||||
// string, so the key is stable and no other test's profile bleeds in.
|
||||
caseCounter++;
|
||||
sessionUnderTest().workingDir = `/nonexistent/readmymind-case-${caseCounter}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
function sessionUnderTest(): { workingDir: string; owner?: string } {
|
||||
return harness.ctx.sessions.get(SESSION_ID) as unknown as { workingDir: string; owner?: string };
|
||||
}
|
||||
|
||||
describe('GET /api/sessions/:id/intent', () => {
|
||||
it('returns an empty transient profile for a fresh case', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.intent.goals).toBe('');
|
||||
expect(body.data.intent.recentPrompts).toEqual([]);
|
||||
expect(body.data.intent.updatedAt).toBe(0);
|
||||
});
|
||||
|
||||
it('404s an unknown session id', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions/nope/intent' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/sessions/:id/intent', () => {
|
||||
it('round-trips goals through the store', async () => {
|
||||
const put = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${SESSION_ID}/intent`,
|
||||
payload: { goals: 'ship 1.17 with the readmymind phase 1' },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().data.intent.goals).toBe('ship 1.17 with the readmymind phase 1');
|
||||
expect(put.json().data.intent.updatedAt).toBeGreaterThan(0);
|
||||
|
||||
const get = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
|
||||
expect(get.json().data.intent.goals).toBe('ship 1.17 with the readmymind phase 1');
|
||||
});
|
||||
|
||||
it('rejects over-long goals and unknown keys (strict schema)', async () => {
|
||||
const tooLong = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${SESSION_ID}/intent`,
|
||||
payload: { goals: 'x'.repeat(8193) },
|
||||
});
|
||||
expect(tooLong.statusCode).toBe(400);
|
||||
|
||||
const extraKey = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${SESSION_ID}/intent`,
|
||||
payload: { goals: 'ok', recentPrompts: [] },
|
||||
});
|
||||
expect(extraKey.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/sessions/:id/intent', () => {
|
||||
it('forgets the case and reports whether anything existed', async () => {
|
||||
await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${SESSION_ID}/intent`,
|
||||
payload: { goals: 'temporary' },
|
||||
});
|
||||
|
||||
const first = await harness.app.inject({ method: 'DELETE', url: `/api/sessions/${SESSION_ID}/intent` });
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.json().data.deleted).toBe(true);
|
||||
|
||||
const second = await harness.app.inject({ method: 'DELETE', url: `/api/sessions/${SESSION_ID}/intent` });
|
||||
expect(second.json().data.deleted).toBe(false);
|
||||
|
||||
const get = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
|
||||
expect(get.json().data.intent.goals).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-user scoping', () => {
|
||||
let savedMultiuser: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedMultiuser = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedMultiuser === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = savedMultiuser;
|
||||
});
|
||||
|
||||
it("404s (never 403s) another user's session", async () => {
|
||||
const scoped = await createRouteTestHarness(registerReadMyMindRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
try {
|
||||
(scoped.ctx.sessions.get(SESSION_ID) as unknown as { owner?: string }).owner = 'alice';
|
||||
const res = await scoped.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
|
||||
expect(res.statusCode).toBe(404);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('serves the owner normally', async () => {
|
||||
const scoped = await createRouteTestHarness(registerReadMyMindRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
try {
|
||||
const session = scoped.ctx.sessions.get(SESSION_ID) as unknown as { owner?: string; workingDir: string };
|
||||
session.owner = 'bob';
|
||||
session.workingDir = `/nonexistent/readmymind-owned-${Date.now()}`;
|
||||
const res = await scoped.app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/intent` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -232,6 +232,88 @@ describe('TranscriptWatcher', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('User prompt capture (Read My Mind)', () => {
|
||||
it('emits transcript:user_prompt with the raw text for string content', async () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
|
||||
const promptHandler = vi.fn();
|
||||
watcher.on('transcript:user_prompt', promptHandler);
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
appendFileSync(
|
||||
testFile,
|
||||
JSON.stringify({ type: 'user', timestamp: ts, message: { role: 'user', content: 'fix the login bug' } }) + '\n'
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(promptHandler).toHaveBeenCalledWith('fix the login bug', ts);
|
||||
});
|
||||
});
|
||||
|
||||
it('emits joined text blocks but stays silent for tool_result-only entries', async () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
|
||||
const promptHandler = vi.fn();
|
||||
watcher.on('transcript:user_prompt', promptHandler);
|
||||
|
||||
appendFileSync(
|
||||
testFile,
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok', is_error: false }],
|
||||
},
|
||||
}) + '\n'
|
||||
);
|
||||
appendFileSync(
|
||||
testFile,
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'run the tests' },
|
||||
{ type: 'text', text: 'then push' },
|
||||
],
|
||||
},
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(promptHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(promptHandler).toHaveBeenCalledWith('run the tests then push', expect.any(String));
|
||||
});
|
||||
|
||||
it('does not emit for whitespace-only string content', async () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
|
||||
const promptHandler = vi.fn();
|
||||
watcher.on('transcript:user_prompt', promptHandler);
|
||||
|
||||
appendFileSync(
|
||||
testFile,
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: 'user', content: ' ' },
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
// Wait for the entry to be processed, then assert no emission happened.
|
||||
await vi.waitFor(() => {
|
||||
expect(watcher.getState().entryCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(promptHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should return a copy of state', () => {
|
||||
const state1 = watcher.getState();
|
||||
|
||||
Reference in New Issue
Block a user