mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-21 09:29:33 +02:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fa88cd187 | ||
|
|
613eb25302 | ||
|
|
8c0c94540c | ||
|
|
abb6447f66 | ||
|
|
cc7c0e5dcb | ||
|
|
368fc20fc2 | ||
|
|
9cc310e843 | ||
|
|
aa991ece8f | ||
|
|
3b4106c349 | ||
|
|
c2867be77f | ||
|
|
a1b66f3510 | ||
|
|
98ba1fd49c | ||
|
|
9df310c30a | ||
|
|
50b8f1d9a0 | ||
|
|
11bacf67a0 | ||
|
|
509595b837 | ||
|
|
c95e94e4cb | ||
|
|
19139837e4 | ||
|
|
9afaccc85d | ||
|
|
95df96e06a | ||
|
|
1255e28f6f | ||
|
|
9d12fc7f94 | ||
|
|
5d406c9705 | ||
|
|
a8782b364f | ||
|
|
d8da1bd3ff | ||
|
|
06871eb7e3 | ||
|
|
5d59c1764d | ||
|
|
cfcd9d288b |
No files matched your search
@@ -1,5 +1,94 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.2.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Centralize terminal history/scrollback/buffer retention limits into config (PR #137, COD-80).
|
||||||
|
|
||||||
|
New `src/config/terminal-history.ts` is now the single source of truth for the terminal scrollback lines, tmux `history-limit`, and server PTY buffer byte caps that were previously scattered as hardcoded literals across `buffer-limits.ts`, `tmux-manager.ts`, and `session.ts`. Each value is overridable (env var or the settings object) and bounds-clamped via a pure `resolveTerminalHistoryConfig()`.
|
||||||
|
|
||||||
|
This change is behavior-neutral: the defaults intentionally match the prior hardcoded values (tmux history-limit 50,000; terminal scrollback 50,000; PTY buffer max 2 MB; trim 1.5 MB) and the existing `CODEMAN_MAX_TERMINAL_BUFFER` / `CODEMAN_TRIM_TERMINAL_TO` env overrides are preserved, so runtime behavior is unchanged on its own. It is the mechanism half of a stacked change; a follow-up raises the defaults.
|
||||||
|
- `buffer-limits.ts` sources `MAX_TERMINAL_BUFFER_SIZE` / `TRIM_TERMINAL_TO` from the resolver.
|
||||||
|
- `tmux-manager.ts` uses `DEFAULT_TMUX_HISTORY_LIMIT` in place of the hardcoded `history-limit 50000`, gains `setHistoryLimit()` (mux-interface + impl) so a settings change applies to live sessions, and re-applies the limit on `respawnPane` so it survives a respawn.
|
||||||
|
- `session.ts` threads a per-session `tmuxHistoryLimit` into the tmux spawn calls; `server.ts` exposes `getTerminalHistoryConfig()` on the route ctx and `system-routes.ts` applies a changed `tmuxHistoryLimit` to live sessions immediately.
|
||||||
|
- `schemas.ts` adds four optional, bounds-clamped settings keys (`terminalScrollbackLines`, `tmuxHistoryLimit`, `terminalBufferMaxBytes`, `terminalBufferTrimBytes`) with a `trim <= max` cross-field check.
|
||||||
|
- New tests: `test/terminal-history.test.ts` (resolver defaults / clamping / trim<=max / non-number fallback) and `test/terminal-history-schema.test.ts` (settings-schema validation).
|
||||||
|
|
||||||
|
## 1.2.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Fix local echo on iOS Safari when switching into a tab whose session already has output. The on-screen-keyboard "heal" (refit + scroll-to-bottom + overlay re-render + one-shot resize) only ran on a keyboard visibility transition, so switching into a tab while the keyboard was already up never triggered it — leaving the local-echo overlay rendering against stale, off-bottom terminal state. Typed characters were invisible (or mispositioned at the cursor row, far below the actual `❯` prompt) until the user manually hid and re-showed the keyboard. `selectSession` now replicates that heal when the keyboard is already visible, so local echo paints correctly on the first keystroke after a keyboard-up tab switch.
|
||||||
|
|
||||||
|
## 1.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Merge four feature PRs and harden them for release.
|
||||||
|
|
||||||
|
**Gemini run mode (PR #134, COD-36)** — a third external-CLI backend alongside Codex and OpenCode (`SessionMode` adds `'gemini'`). New `gemini-cli-resolver.ts`, `buildGeminiCommand()` (`--skip-trust`, `--approval-mode {default|auto_edit|yolo|plan}` defaulting to `yolo`, `--model`, `--resume`), `setGeminiEnvVars()` (socket-scoped `tmux setenv` of `GEMINI_*`/`GOOGLE_*` auth incl. Vertex AI), `GET /api/gemini/status` with an install hint (`npm install -g @google/gemini-cli`), run-mode dropdown + welcome "Run Gemini" button + "Run GM" label, `GeminiConfigSchema`, and `GEMINI_*`/`GOOGLE_*` added to the env-override allowlist. Requires tmux (no PTY fallback), like Codex.
|
||||||
|
|
||||||
|
**Cross-session search (PR #133, COD-113)** — `GET /api/search?q=&types=&limit=` federates an in-memory search across session metadata, run-summary events, and attachment-history file entries (substring match, hard caps, no FS reads); history-panel search box in the frontend.
|
||||||
|
|
||||||
|
**Away digest (PR #136, COD-41)** — `GET /api/away-digest` aggregates "what happened while you were away" (lifecycle log, run summaries, live sessions, daily token stats, recent subagents) into categorized sections behind a header-button modal (hidden on phones).
|
||||||
|
|
||||||
|
**Ralph todo-config (PR #135, COD-79)** — per-session `maxTodos` and `todoExpirationMinutes` via `POST /api/sessions/:id/ralph-config`; now persisted in `RalphTrackerState` and read back into the Session Options modal (mirrors `maxIterations` round-trip).
|
||||||
|
|
||||||
|
**Review fixes applied on merge:**
|
||||||
|
- Gemini: fixed two `{success,data}` envelope bugs in `runGemini()` (status check and new-session selection) that made the Run-Gemini button non-functional; fixed `setGeminiEnvVars()` to use the socket-scoped tmux command so Google-auth env injection actually reaches the session.
|
||||||
|
- Gemini parity: tab-mode badge, kill-dialog label, `codeman doctor` registry entry, `isGeminiAvailable` barrel export, `COLORTERM=truecolor`, and alt-screen/scrollback stripping (Ink TUI, like Codex/Claude).
|
||||||
|
- Restored four envelope-shape test assertions weakened during the Gemini PR; added a `runGemini()` regression test covering the envelope path.
|
||||||
|
- Ralph todo-config values now persist across restart and read back correctly instead of always reverting to defaults.
|
||||||
|
|
||||||
|
## 1.1.17
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Fix the connection indicator flashing "Sending 1B…" on every keystroke. The reliable input-delivery layer (1.1.16) marks each keystroke as briefly pending until its ACK arrives a few milliseconds later, which made the indicator flash on every character while typing on a healthy connection. The indicator is now hidden whenever the connection is healthy and only appears for an actual problem (reconnecting/offline), where it still shows the queued byte count so you know buffered input will be sent.
|
||||||
|
|
||||||
|
## 1.1.16
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Mobile image uploads, reliable input delivery, and gesture window dragging.
|
||||||
|
|
||||||
|
**Mobile image uploads (camera-roll picker / drag-drop / paste).** The "🖼 Image" button now handles real photo batches: up to 20 images per batch uploaded with bounded concurrency and a live "Uploading N/M…" progress toast (with a summary of successes, failures, and whether the 20-cap trimmed the selection). The per-file limit is raised from 10MB to 50MB (`MAX_PASTE_IMAGE_BYTES`, env-overridable via `CODEMAN_MAX_PASTE_IMAGE_BYTES`) so full-resolution phone photos and large screenshots are accepted. Very large images are downscaled to ≤4096px on the longest edge before upload, fixing iOS Safari's ~16.7M-px `<canvas>` limit that previously made huge photos fail to re-encode. Also fixes a latent concurrency bug the batch path exposed where the first parallel uploads to a session raced on creating `.claude-images/` and failed with EEXIST.
|
||||||
|
|
||||||
|
**Reliable, exactly-once input delivery.** A "sent" prompt could be silently lost on a flaky connection (e.g. a train): a half-open WebSocket accepts `ws.send()` without error while discarding the frame, and nothing was queued or resent. Input is now recorded durably (localStorage) with a stable clientId + monotonic per-session sequence before delivery, and only dropped once the server ACKs it — delivered over the WebSocket (acked via `{t:'ia',seq}`) or, when the socket is down, over POST in order. A 2s sweep force-reconnects a half-open socket; pending input survives reconnects and page reloads. The server applies each `(clientId, seq)` at most once (`Session.shouldApplyInput`), so an at-least-once resend can never type the prompt twice. Untagged input (curl/legacy) is unchanged. See `docs/reliable-input-delivery.md`.
|
||||||
|
|
||||||
|
**Gesture beta: drag agent windows.** With the camera hand-tracking overlay, you can now pinch and move the floating subagent and ultracode run/transcript windows. They keep their glowing connector line to the session tab while moving and can travel across a multi-monitor seam.
|
||||||
|
|
||||||
|
## 1.1.15
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Security: harden all frontend inline `onclick`/`ondblclick` handlers against a stored-XSS double-context bug.
|
||||||
|
|
||||||
|
Many inline handlers interpolated values as `'${escapeHtml(value)}'` — a JavaScript string literal sitting inside an HTML attribute. The browser HTML-decodes the attribute value _before_ parsing the handler source, so `escapeHtml`'s `'` reverts to a literal `'` and a quote-bearing id/name/path/URL breaks out of the JS string into executable code. `escapeHtml` alone is insufficient for this JS-string-within-HTML-attribute context.
|
||||||
|
|
||||||
|
All affected handlers now use `escapeHtml(JSON.stringify(value))`: `JSON.stringify` JS-encodes and quote-wraps the value, then `escapeHtml` handles the HTML-attribute layer, so the value round-trips as a single inert string argument.
|
||||||
|
- ultracode run/agent cards and minimized-tab badges (`ultracode-panel.js`, `ultracode-windows.js`) — PR #132.
|
||||||
|
- Session tabs (click/rename/gear/detach/close), notifications, subagent windows + dropdowns, the agents/tools/log-viewer/image-popup panels, mux-session monitor rows, and case-management buttons (`app.js`, `notification-manager.js`, `subagent-windows.js`, `panels-ui.js`, `session-ui.js`).
|
||||||
|
- Two non-`escapeHtml` variants of the same class: a pre-escaped mux-session id in `panels-ui.js` (`selectSession`/`killMuxSession`) and a fully raw, unescaped `phase.id` in `orchestrator-panel.js` (`orchestratorSkipPhase`/`orchestratorRetryPhase`).
|
||||||
|
|
||||||
|
The most realistic exploitation vector was file paths in the project-insights log-viewer link, since filenames can legally contain a single quote. Purely numeric interpolations and developer-literal handler strings were left unchanged.
|
||||||
|
|
||||||
|
## 1.1.14
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Ultracode (Workflow-tool) floating windows — agent transcripts in-page, and minimize-to-tab.
|
||||||
|
- **Agent transcripts open in-page, connected, instead of a detached browser popup.** Clicking an agent card (in a run window or the dock panel) now opens the agent's live transcript as its own draggable floating window, tied by a connector line to its parent run window (falling back to the run's session tab if that window has since closed) — the same line idiom the run windows use. Re-clicking a card focuses the existing window; closing it removes the window and its line. (Previously this spawned a separate `window.open` browser popup.)
|
||||||
|
- **The window "−" button now minimizes into the originating session tab**, mirroring the subagent-window idiom. The window genie-animates into its tab and is tracked there; the tab shows an `ULTRA` badge whose hover/click dropdown lists each minimized item (🧬 run windows, 📄 agent transcripts). Click an item to restore its floating window, or dismiss it with ×. A run minimized while still active keeps tracking in the background and its badge auto-clears shortly after the run finishes. Both run windows and agent-transcript windows minimize into the same merged badge.
|
||||||
|
- Removed the old collapse-to-header behavior that the "−" button previously triggered (now superseded by minimize-to-tab).
|
||||||
|
|
||||||
|
## 1.1.13
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Keep the `/compact` button in the extended (full) mobile keyboard accessory bar; only the simple bar drops it. (1.1.12 had removed it from both.)
|
||||||
|
|
||||||
## 1.1.12
|
## 1.1.12
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ 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.
|
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.1.12 (must match `package.json`)
|
**Version**: 1.2.2 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
|
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
|
||||||
|
|
||||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, and Codex (OpenAI) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex'`).
|
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), and Gemini (Google) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'`).
|
||||||
|
|
||||||
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
|
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
|
||||||
|
|
||||||
@@ -100,12 +100,12 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
|
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.)
|
||||||
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
||||||
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
||||||
- **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, and Codex (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
|
- **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, Codex, and Gemini (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts` / `gemini-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional — Vertex AI auth uses `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc.; it's the loosest allowlist entry, affecting only the user's own spawned CLI). When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the resolver design pattern
|
||||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
|
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
|
||||||
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs` (the `xterm-zerolag-input` esbuild step, for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
||||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
|
- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
|
||||||
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
||||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — `scripts/capture-real-overview.mjs` (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: **(1) DSF=2 doubles the console font.** xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under `deviceScaleFactor: 2`, while STILL reporting nominal cell dims (`terminal.cols`/`_renderService.dimensions.css.cell` say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to **DSF=1** (script does); the image is 1× res but the font is true-to-browser. **(2) Stable filenames → stale renders.** Overwriting a fixed path (`claude-overview.png`) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/`immutable` cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped `claude-overview-<ts>.png` per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: `file-routes` previews send `Cache-Control: no-cache` and `/api/screenshots/:name` sends none. The one real Codeman-side footgun: `server.ts` serves non-content-hashed static assets `public, max-age=31536000, immutable`, and `cacheBustAssets()` only rewrites `.js`/`.css` refs — a stable-named **image** referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed `localStorage` `codeman:skin`, `codeman-font-size`, and the desktop `codeman-app-settings` blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal).
|
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — `scripts/capture-real-overview.mjs` (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: **(1) DSF=2 doubles the console font.** xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under `deviceScaleFactor: 2`, while STILL reporting nominal cell dims (`terminal.cols`/`_renderService.dimensions.css.cell` say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to **DSF=1** (script does); the image is 1× res but the font is true-to-browser. **(2) Stable filenames → stale renders.** Overwriting a fixed path (`claude-overview.png`) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/`immutable` cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped `claude-overview-<ts>.png` per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: `file-routes` previews send `Cache-Control: no-cache` and `/api/screenshots/:name` sends none. The one real Codeman-side footgun: `server.ts` serves non-content-hashed static assets `public, max-age=31536000, immutable`, and `cacheBustAssets()` only rewrites `.js`/`.css` refs — a stable-named **image** referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed `localStorage` `codeman:skin`, `codeman-font-size`, and the desktop `codeman-app-settings` blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal).
|
||||||
@@ -131,9 +131,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||||
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
|
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
|
||||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
|
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
|
||||||
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (16 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
|
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (17 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
|
||||||
| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||||
| **Types** | `src/types/index.ts` (barrel) → 16 domain files (incl. `workflow-run.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
| **Types** | `src/types/index.ts` (barrel) → 17 domain files (incl. `workflow-run.ts`, `search.ts`); 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`.
|
★ = 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`.
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
**Input**: `session.writeViaMux()` for programmatic input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only.
|
**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt.
|
||||||
|
|
||||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv`, never on the spawn command line: OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars` in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM).
|
**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM).
|
||||||
|
|
||||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
||||||
|
|
||||||
@@ -174,7 +174,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups *identical* in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals.
|
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups *identical* in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals.
|
||||||
|
|
||||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a run-state JSON per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json`. `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; disjoint directory tree, separate singleton) globs that tree via periodic poll + per-run chokidar watcher with per-file mtime skip, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is only started when the setting is on (`server.ts` gates on `showUltracodeAgents`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on the same `showUltracodeAgents` setting), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a run-state JSON per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json`. `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; disjoint directory tree, separate singleton) globs that tree via periodic poll + per-run chokidar watcher with per-file mtime skip, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `_updateWorkflowWatcher` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||||
|
|
||||||
|
**Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core in `search-service.ts` (`harvestSources()` gathers, `searchSources()` substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal). `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`.
|
||||||
|
|
||||||
|
**Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range.
|
||||||
|
|
||||||
|
**Ralph todo-config** (COD-79/#135): per-session `maxTodos` (FIFO-eviction cap, default 500 = `MAX_TODOS_PER_SESSION`) + `todoExpirationMinutes` (auto-expiry, default 60) set via `POST /api/sessions/:id/ralph-config` (`RalphConfigSchema`, both `.int().positive()`). Stored on the tracker (`setMaxTodos`/`setTodoExpirationMinutes`) and **persisted/read-back via `RalphTrackerState`** (surfaced in the `loopState` getter → `toState()` + SSE broadcast → modal `populateRalphForm`), mirroring how `maxIterations` round-trips. Claude-only (skipped by `isExternalCliMode`).
|
||||||
|
|
||||||
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
|
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
|
||||||
|
|
||||||
@@ -218,11 +224,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
### SSE Event Registry
|
### SSE Event Registry
|
||||||
|
|
||||||
~120 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
~127 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||||
|
|
||||||
### API Routes
|
### API Routes
|
||||||
|
|
||||||
~146 handlers across 16 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
~150 handlers across 17 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), 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`).
|
**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`).
|
||||||
|
|
||||||
@@ -262,7 +268,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
|
|||||||
|
|
||||||
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
||||||
|
|
||||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
|
**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
@@ -278,7 +284,7 @@ Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/scree
|
|||||||
|
|
||||||
## Performance & Limits
|
## Performance & Limits
|
||||||
|
|
||||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 2MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 2MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||||
|
|
||||||
**Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npm test -- test/memory-leak-prevention.test.ts`.
|
**Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npm test -- test/memory-leak-prevention.test.ts`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Reliable input delivery (exactly-once, durable)
|
||||||
|
|
||||||
|
## The bug this fixes
|
||||||
|
|
||||||
|
With local echo on, pressing Enter cleared the overlay and then sent the prompt
|
||||||
|
over the WebSocket **fire-and-forget** (`ws.send({t:'i',d})`). On a flaky link
|
||||||
|
(e.g. a moving train) the socket is frequently *half-open*: `readyState === OPEN`
|
||||||
|
so `ws.send()` does **not** throw, but the underlying TCP is dead, so the frame is
|
||||||
|
silently discarded. Nothing was enqueued (the send "succeeded"), the on-screen
|
||||||
|
prompt was already wiped, and `navigator.onLine` stays `true` — so a long typed
|
||||||
|
prompt vanished with no trace and no resend.
|
||||||
|
|
||||||
|
## The guarantee
|
||||||
|
|
||||||
|
Every byte of user input is **recorded durably before delivery** and **only
|
||||||
|
dropped once the server ACKs it** — so a half-open socket, a reconnect, or a page
|
||||||
|
reload can never lose input. Redelivery is **exactly-once**: the server applies
|
||||||
|
each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### Client (`app.js`)
|
||||||
|
|
||||||
|
- A stable **`clientId`** (`localStorage['codeman:clientId']`) identifies this
|
||||||
|
browser to the server's dedup across reconnects and reloads.
|
||||||
|
- Each input frame gets a **monotonic per-session `seq`**. Frame records
|
||||||
|
(`{seq,data,useMux,ts,tries,sentAt}`) live in `_pendingDeliveries`
|
||||||
|
(`Map<sessionId, record[]>`), persisted (debounced, + flushed on `pagehide`/
|
||||||
|
`visibilitychange`) to `localStorage['codeman:pendingInput']`. The seq counters
|
||||||
|
persist too, so seqs stay monotonic across reloads (never reset — a reset would
|
||||||
|
let the server treat fresh input as an already-applied duplicate).
|
||||||
|
- **Delivery** (`_drainSession`):
|
||||||
|
- **WS path** — when the socket is `OPEN` for the session, send each not-yet-sent
|
||||||
|
record (`sentAt === 0`) in seq order over the single ordered stream. Records
|
||||||
|
stay pending until the server's `{t:'ia',seq}` ACK removes them.
|
||||||
|
- **POST path** — when no WS, POST records in order, awaiting each (the HTTP 2xx
|
||||||
|
*is* the ACK). A 404/410 (session gone) drops the record rather than retry
|
||||||
|
forever.
|
||||||
|
- **Half-open recovery** (`_redeliverSweep`, every 2s): if the active WS session's
|
||||||
|
oldest record is unacked past `_reliableAckTimeoutMs` (4s), the socket is assumed
|
||||||
|
dead — `ws.close()` forces a fast reconnect; `onopen` (`_onWsReady`) resets
|
||||||
|
`sentAt = 0` and re-sends everything pending. Also re-drains background sessions
|
||||||
|
over POST, and fires on SSE-reconnect / `online`.
|
||||||
|
- The connection indicator shows pending count/bytes (`_pendingBytes`).
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
- **`Session.shouldApplyInput(clientId, seq)`** — returns `true` exactly once per
|
||||||
|
`(clientId, seq)`: the first time a seq strictly greater than that client's
|
||||||
|
last-applied is seen. A replayed/lower seq returns `false`. Bounded MRU map
|
||||||
|
(`MAX_INPUT_DEDUP_CLIENTS = 256`).
|
||||||
|
- **WS route** (`ws-routes.ts`) — parses optional `cid`/`seq` on `{t:'i'}`; applies
|
||||||
|
via `shouldApplyInput` (skips a duplicate, still ACKs with `{t:'ia',seq}` so the
|
||||||
|
client drops it). Untagged frames apply unconditionally (no behavior change).
|
||||||
|
- **POST route** (`/api/sessions/:id/input`) — optional `seq`/`clientId` in
|
||||||
|
`SessionInputWithLimitSchema`; a deduped duplicate returns 200 without writing
|
||||||
|
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||||
|
apply.
|
||||||
|
|
||||||
|
## Known limitation
|
||||||
|
|
||||||
|
Dedup state is in-memory on the server. A **server restart** between a write and
|
||||||
|
the client's redelivery of that same seq could re-apply it (a rare duplicate).
|
||||||
|
This is a deliberate trade-off: favor *never losing input* over a rare duplicate
|
||||||
|
across the narrow restart window.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `test/reliable-input-dedup.test.ts` — `Session.shouldApplyInput` exactly-once
|
||||||
|
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
||||||
|
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
||||||
|
`(clientId, seq)` once on redelivery; untagged input always applies.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.12",
|
"version": "1.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.12",
|
"version": "1.2.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.1.12",
|
"version": "1.2.2",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -17,6 +17,13 @@
|
|||||||
// • Panel "re-grab" — pinch an existing floating panel and move it anywhere;
|
// • Panel "re-grab" — pinch an existing floating panel and move it anywhere;
|
||||||
// release over the tab strip to re-dock it (panel goes away, the tab stays).
|
// release over the tab strip to re-dock it (panel goes away, the tab stays).
|
||||||
// This is the capability the old OS-window detach lost.
|
// This is the capability the old OS-window detach lost.
|
||||||
|
// • Agent-window "grab-to-move" — pinch any floating *subagent* or *ultracode*
|
||||||
|
// run/transcript window (the dashboard's own `.subagent-window` /
|
||||||
|
// `.ultracode-window` floats) and move it anywhere. These windows stay owned
|
||||||
|
// by app.js — we only nudge their `style.left/top` and ask app.js to redraw
|
||||||
|
// the glowing connector line back to their session tab (its redraw reads live
|
||||||
|
// rects, so the line tracks without us touching app.js internals). This is the
|
||||||
|
// multi-monitor verb that lets these windows cross the physical monitor seam.
|
||||||
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
|
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
|
||||||
// in place → fires the button's real click handler. Drift too far first and
|
// in place → fires the button's real click handler. Drift too far first and
|
||||||
// it's treated as a stray move, not a tap.
|
// it's treated as a stray move, not a tap.
|
||||||
@@ -36,12 +43,29 @@ import type { HandState } from '../gesture/types.ts';
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__codemanGesture?: GestureBridge;
|
__codemanGesture?: GestureBridge;
|
||||||
|
/** The Codeman dashboard singleton (app.js, `window.app`). The gesture layer
|
||||||
|
* reaches into it to redraw the floating-window connector lines and bump a
|
||||||
|
* grabbed window's z-order while moving the subagent / ultracode windows.
|
||||||
|
* Loosely typed — only the few members we touch. */
|
||||||
|
app?: {
|
||||||
|
updateConnectionLines?: () => void;
|
||||||
|
saveSubagentWindowStates?: () => void;
|
||||||
|
subagentWindowZIndex?: number;
|
||||||
|
ultracodeWindowZIndex?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_SELECTOR = '.session-tab';
|
const TAB_SELECTOR = '.session-tab';
|
||||||
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
|
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
|
||||||
const PANEL_SELECTOR = '.cg-float';
|
const PANEL_SELECTOR = '.cg-float';
|
||||||
|
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
|
||||||
|
* transcript windows). All three carry one of these classes, position via
|
||||||
|
* `style.left/top`, and redraw their connector line from
|
||||||
|
* `window.app.updateConnectionLines()` — so the hand can pick one up and move it
|
||||||
|
* without app.js knowing. (`.ultracode-agent-window` also carries
|
||||||
|
* `.ultracode-window`, so this matches it too.) */
|
||||||
|
const WINDOW_SELECTOR = '.subagent-window, .ultracode-window';
|
||||||
/** The session-tab strip; dropping a moved panel over it re-docks the session. */
|
/** The session-tab strip; dropping a moved panel over it re-docks the session. */
|
||||||
const DOCK_SELECTOR = '.session-tabs';
|
const DOCK_SELECTOR = '.session-tabs';
|
||||||
/** Toolbar buttons a pinch can "tap": Run (#runBtn → app.run()) and Run Shell
|
/** Toolbar buttons a pinch can "tap": Run (#runBtn → app.run()) and Run Shell
|
||||||
@@ -93,6 +117,17 @@ type Grab =
|
|||||||
dy: number;
|
dy: number;
|
||||||
/** Cursor currently over the tab strip → releasing re-docks. */
|
/** Cursor currently over the tab strip → releasing re-docks. */
|
||||||
overDock: boolean;
|
overDock: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** A dashboard-owned floating agent window (subagent / ultracode) being
|
||||||
|
* moved. We never remove or re-parent it — just reposition + redraw its
|
||||||
|
* connector. The element ref can go stale mid-grab (SSE reconnect tears
|
||||||
|
* ultracode windows down), so every move guards on `el.isConnected`. */
|
||||||
|
kind: 'window';
|
||||||
|
el: HTMLElement;
|
||||||
|
/** Cursor→window-top-left offset at grab, so it doesn't snap. */
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Live state for one hand pinching a toolbar button (Run / Run Shell). */
|
/** Live state for one hand pinching a toolbar button (Run / Run Shell). */
|
||||||
@@ -122,6 +157,8 @@ class GestureBridge {
|
|||||||
private taps = new Map<string, Tap>();
|
private taps = new Map<string, Tap>();
|
||||||
/** Live floating panels, keyed by session id (idempotent per id). */
|
/** Live floating panels, keyed by session id (idempotent per id). */
|
||||||
private floats = new Map<string, FloatingPanel>();
|
private floats = new Map<string, FloatingPanel>();
|
||||||
|
/** rAF coalescing for connector-line redraws while dragging an agent window. */
|
||||||
|
private connectorRedrawScheduled = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
injectStyles();
|
injectStyles();
|
||||||
@@ -187,7 +224,7 @@ class GestureBridge {
|
|||||||
await this.gc.start();
|
await this.gc.start();
|
||||||
this.running = true;
|
this.running = true;
|
||||||
this.button.classList.add('on');
|
this.button.classList.add('on');
|
||||||
this.status.textContent = 'on — pinch a tab or button';
|
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
|
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
|
||||||
// (number/string), so `(err as Error).message` was logging "undefined".
|
// (number/string), so `(err as Error).message` was logging "undefined".
|
||||||
@@ -242,6 +279,22 @@ class GestureBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A dashboard-owned floating agent window (subagent / ultracode run or
|
||||||
|
// transcript) → pick it up and move it. Priority below cg-float panels
|
||||||
|
// (which sit far above), above tabs/buttons. We grab anywhere on the window
|
||||||
|
// (not just its titlebar) since the hand is choosing the whole window.
|
||||||
|
const win = this.hitClosest(x, y, WINDOW_SELECTOR);
|
||||||
|
if (win) {
|
||||||
|
const rect = win.getBoundingClientRect();
|
||||||
|
// Match app.js's own drag: drop any bottom-anchor so left/top take effect.
|
||||||
|
win.style.bottom = 'auto';
|
||||||
|
win.classList.add('cg-win-grabbed');
|
||||||
|
this.bringWindowToFront(win);
|
||||||
|
this.grabs.set(hand, { kind: 'window', el: win, dx: x - rect.left, dy: y - rect.top });
|
||||||
|
this.status.textContent = 'moving window';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
|
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
|
||||||
const tab = this.hitClosest(x, y, TAB_SELECTOR);
|
const tab = this.hitClosest(x, y, TAB_SELECTOR);
|
||||||
const id = tab?.dataset.id;
|
const id = tab?.dataset.id;
|
||||||
@@ -292,12 +345,16 @@ class GestureBridge {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (grab?.kind === 'window') {
|
||||||
|
this.moveWindow(grab.el, x - grab.dx, y - grab.dy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// A button pinch that drifts too far is a stray move, not a tap — cancel it.
|
// A button pinch that drifts too far is a stray move, not a tap — cancel it.
|
||||||
const tap = this.taps.get(hand);
|
const tap = this.taps.get(hand);
|
||||||
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
|
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
|
||||||
tap.el.classList.remove('cg-tap-armed');
|
tap.el.classList.remove('cg-tap-armed');
|
||||||
this.taps.delete(hand);
|
this.taps.delete(hand);
|
||||||
this.status.textContent = 'on — pinch a tab or button';
|
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +376,23 @@ class GestureBridge {
|
|||||||
else this.flash('placed');
|
else this.flash('placed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (grab?.kind === 'window') {
|
||||||
|
this.grabs.delete(hand);
|
||||||
|
grab.el.classList.remove('cg-win-grabbed');
|
||||||
|
// Clear the coalescer so the final placement always redraws, even if a
|
||||||
|
// mid-drag rAF was throttled (tab briefly backgrounded) and left it latched.
|
||||||
|
this.connectorRedrawScheduled = false;
|
||||||
|
this.redrawWindowConnectors();
|
||||||
|
// Persist subagent-window positions like app.js's own drag end does
|
||||||
|
// (a no-op for ultracode windows, which aren't position-persisted).
|
||||||
|
try {
|
||||||
|
window.app?.saveSubagentWindowStates?.();
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
this.flash('placed window');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Release over the same button → fire its real click handler.
|
// Release over the same button → fire its real click handler.
|
||||||
const tap = this.taps.get(hand);
|
const tap = this.taps.get(hand);
|
||||||
if (tap) {
|
if (tap) {
|
||||||
@@ -373,6 +447,59 @@ class GestureBridge {
|
|||||||
float.el.style.top = `${t}px`;
|
float.el.style.top = `${t}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
|
||||||
|
* redraw its connector line. The window self-positions via `style.left/top` and
|
||||||
|
* app.js's connector redraw reads live rects, so this tracks without touching
|
||||||
|
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
|
||||||
|
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
|
||||||
|
* which equals the *spanned* viewport in a multi-monitor window — so the window
|
||||||
|
* can still travel across the physical monitor seam, just not off-screen. */
|
||||||
|
private moveWindow(el: HTMLElement, left: number, top: number): void {
|
||||||
|
if (!el.isConnected) return;
|
||||||
|
const w = el.offsetWidth || 380;
|
||||||
|
const h = el.offsetHeight || 320;
|
||||||
|
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w - 4));
|
||||||
|
const t = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h - 4));
|
||||||
|
el.style.left = `${l}px`;
|
||||||
|
el.style.top = `${t}px`;
|
||||||
|
this.redrawWindowConnectors();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
|
||||||
|
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
|
||||||
|
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
|
||||||
|
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
|
||||||
|
private redrawWindowConnectors(): void {
|
||||||
|
if (this.connectorRedrawScheduled) return;
|
||||||
|
this.connectorRedrawScheduled = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.connectorRedrawScheduled = false;
|
||||||
|
try {
|
||||||
|
window.app?.updateConnectionLines?.();
|
||||||
|
} catch {
|
||||||
|
/* app.js may not expose it (standalone playground) */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
|
||||||
|
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
|
||||||
|
private bringWindowToFront(el: HTMLElement): void {
|
||||||
|
const app = window.app;
|
||||||
|
if (!app) return;
|
||||||
|
try {
|
||||||
|
if (el.classList.contains('ultracode-window')) {
|
||||||
|
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1000) + 1;
|
||||||
|
el.style.zIndex = String(app.ultracodeWindowZIndex);
|
||||||
|
} else {
|
||||||
|
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1000) + 1;
|
||||||
|
el.style.zIndex = String(app.subagentWindowZIndex);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* cosmetic only */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private positionGhost(ghost: HTMLElement, x: number, y: number): void {
|
private positionGhost(ghost: HTMLElement, x: number, y: number): void {
|
||||||
ghost.style.left = `${x}px`;
|
ghost.style.left = `${x}px`;
|
||||||
ghost.style.top = `${y}px`;
|
ghost.style.top = `${y}px`;
|
||||||
@@ -385,17 +512,19 @@ class GestureBridge {
|
|||||||
if (grab.kind === 'tab') {
|
if (grab.kind === 'tab') {
|
||||||
grab.ghost.remove();
|
grab.ghost.remove();
|
||||||
grab.tab.classList.remove('cg-grabbed');
|
grab.tab.classList.remove('cg-grabbed');
|
||||||
} else {
|
} else if (grab.kind === 'panel') {
|
||||||
grab.panel.el.style.pointerEvents = '';
|
grab.panel.el.style.pointerEvents = '';
|
||||||
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
|
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
|
||||||
|
} else {
|
||||||
|
grab.el.classList.remove('cg-win-grabbed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.grabs.clear();
|
this.grabs.clear();
|
||||||
for (const tap of this.taps.values()) tap.el.classList.remove('cg-tap-armed');
|
for (const tap of this.taps.values()) tap.el.classList.remove('cg-tap-armed');
|
||||||
this.taps.clear();
|
this.taps.clear();
|
||||||
document
|
document
|
||||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`)
|
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
|
||||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed'));
|
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private onStatus(fps: number, hands: HandState[]): void {
|
private onStatus(fps: number, hands: HandState[]): void {
|
||||||
@@ -491,6 +620,10 @@ function injectStyles(): void {
|
|||||||
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
||||||
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
||||||
|
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
|
||||||
|
outline: 2px solid #4ade80 !important; outline-offset: -2px;
|
||||||
|
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
|
||||||
|
}
|
||||||
.cg-float {
|
.cg-float {
|
||||||
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
||||||
z-index: ${Z}; display: flex; flex-direction: column; overflow: hidden;
|
z-index: ${Z}; display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
|||||||
@@ -9,11 +9,13 @@
|
|||||||
* - Terminal buffer: 2MB max × 20 = 40MB worst case
|
* - Terminal buffer: 2MB max × 20 = 40MB worst case
|
||||||
* - Text output: 1MB max × 20 = 20MB worst case
|
* - Text output: 1MB max × 20 = 20MB worst case
|
||||||
* - Messages: ~1KB each × 1000 × 20 = 20MB worst case
|
* - Messages: ~1KB each × 1000 × 20 = 20MB worst case
|
||||||
* - Total buffer overhead: ~80MB (acceptable for long-running server)
|
* - Total buffer overhead: ~80MB (acceptable for a long-running server)
|
||||||
*
|
*
|
||||||
* @module config/buffer-limits
|
* @module config/buffer-limits
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_TERMINAL_BUFFER_MAX_BYTES, DEFAULT_TERMINAL_BUFFER_TRIM_BYTES } from './terminal-history.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Terminal Buffer Limits
|
// Terminal Buffer Limits
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -21,17 +23,17 @@
|
|||||||
/**
|
/**
|
||||||
* Maximum terminal buffer size in characters.
|
* Maximum terminal buffer size in characters.
|
||||||
* Contains raw terminal output with ANSI escape sequences.
|
* Contains raw terminal output with ANSI escape sequences.
|
||||||
* Reduced from 5MB to 2MB for better render performance.
|
* Sourced from terminal-history config (env/settings overridable).
|
||||||
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
|
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
|
||||||
*/
|
*/
|
||||||
export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024;
|
export const MAX_TERMINAL_BUFFER_SIZE = DEFAULT_TERMINAL_BUFFER_MAX_BYTES;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Size to trim terminal buffer to when max is exceeded.
|
* Size to trim terminal buffer to when max is exceeded.
|
||||||
* Keeps the most recent portion to preserve context.
|
* Keeps the most recent portion to preserve context.
|
||||||
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
|
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
|
||||||
*/
|
*/
|
||||||
export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024;
|
export const TRIM_TERMINAL_TO = DEFAULT_TERMINAL_BUFFER_TRIM_BYTES;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Text Output Buffer Limits
|
// Text Output Buffer Limits
|
||||||
@@ -96,3 +98,18 @@ export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
|
|||||||
* which is enough to extract metadata from the first few JSONL lines.
|
* which is enough to extract metadata from the first few JSONL lines.
|
||||||
*/
|
*/
|
||||||
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Paste-Image Upload Limits
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum size (bytes) of a single image uploaded via POST
|
||||||
|
* /api/sessions/:id/paste-image. The mobile picker / drag-drop / paste paths
|
||||||
|
* send one file per request (the client uploads up to MAX_PASTE_IMAGES of them
|
||||||
|
* per batch), so this caps each individual file, not the batch. Generous enough
|
||||||
|
* for full-resolution phone photos and large screenshots; the client downscales
|
||||||
|
* very large images before upload, so legitimate uploads land well under this.
|
||||||
|
* Override: CODEMAN_MAX_PASTE_IMAGE_BYTES (bytes)
|
||||||
|
*/
|
||||||
|
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||||
@@ -90,6 +90,14 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
|||||||
usedBy: ['Codex sessions'],
|
usedBy: ['Codex sessions'],
|
||||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
|
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini',
|
||||||
|
label: 'Gemini CLI',
|
||||||
|
category: 'core',
|
||||||
|
required: false,
|
||||||
|
usedBy: ['Gemini sessions'],
|
||||||
|
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'libreoffice',
|
id: 'libreoffice',
|
||||||
label: 'LibreOffice',
|
label: 'LibreOffice',
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Defaults, bounds, and resolution for terminal history retention.
|
||||||
|
*
|
||||||
|
* Centralizes the terminal scrollback, tmux history-limit, and server PTY buffer
|
||||||
|
* byte caps that were previously scattered as hardcoded literals. Each value is
|
||||||
|
* overridable (env var or the settings object) and clamped to a sane range via
|
||||||
|
* resolveTerminalHistoryConfig(). Defaults intentionally match the prior
|
||||||
|
* hardcoded values, so introducing this module is behavior-neutral.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 50_000;
|
||||||
|
export const DEFAULT_TMUX_HISTORY_LIMIT = 50_000;
|
||||||
|
export const DEFAULT_TERMINAL_BUFFER_MAX_BYTES =
|
||||||
|
parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '', 10) || 2 * 1024 * 1024;
|
||||||
|
export const DEFAULT_TERMINAL_BUFFER_TRIM_BYTES =
|
||||||
|
parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '', 10) || 1.5 * 1024 * 1024;
|
||||||
|
|
||||||
|
export const MIN_TERMINAL_SCROLLBACK_LINES = 1_000;
|
||||||
|
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||||
|
export const MIN_TERMINAL_BUFFER_BYTES = 1024 * 1024;
|
||||||
|
export const MAX_TERMINAL_BUFFER_BYTES = 128 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface TerminalHistoryConfig {
|
||||||
|
terminalScrollbackLines: number;
|
||||||
|
tmuxHistoryLimit: number;
|
||||||
|
terminalBufferMaxBytes: number;
|
||||||
|
terminalBufferTrimBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedInt(value: unknown, fallback: number, min: number, max: number): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, Math.trunc(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTerminalHistoryConfig(settings: Record<string, unknown> = {}): TerminalHistoryConfig {
|
||||||
|
const terminalBufferMaxBytes = boundedInt(
|
||||||
|
settings.terminalBufferMaxBytes,
|
||||||
|
DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
|
||||||
|
MIN_TERMINAL_BUFFER_BYTES,
|
||||||
|
MAX_TERMINAL_BUFFER_BYTES
|
||||||
|
);
|
||||||
|
const terminalBufferTrimBytes = boundedInt(
|
||||||
|
settings.terminalBufferTrimBytes,
|
||||||
|
Math.min(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES, terminalBufferMaxBytes),
|
||||||
|
MIN_TERMINAL_BUFFER_BYTES,
|
||||||
|
terminalBufferMaxBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
terminalScrollbackLines: boundedInt(
|
||||||
|
settings.terminalScrollbackLines,
|
||||||
|
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MAX_TERMINAL_SCROLLBACK_LINES
|
||||||
|
),
|
||||||
|
tmuxHistoryLimit: boundedInt(
|
||||||
|
settings.tmuxHistoryLimit,
|
||||||
|
DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
|
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MAX_TERMINAL_SCROLLBACK_LINES
|
||||||
|
),
|
||||||
|
terminalBufferMaxBytes,
|
||||||
|
terminalBufferTrimBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
CodexConfig,
|
CodexConfig,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
|
GeminiConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,12 +65,15 @@ export interface CreateSessionOptions {
|
|||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** tmux history-limit (scrollback lines) to set for this session. */
|
||||||
|
historyLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for respawning a dead pane. */
|
/** Options for respawning a dead pane. */
|
||||||
@@ -83,12 +87,15 @@ export interface RespawnPaneOptions {
|
|||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
/** Resume a previous Claude conversation when respawning */
|
/** Resume a previous Claude conversation when respawning */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** tmux history-limit (scrollback lines) to set for this session after respawn. */
|
||||||
|
historyLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +174,9 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
/** Update Ralph enabled state for a session */
|
/** Update Ralph enabled state for a session */
|
||||||
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
||||||
|
|
||||||
|
/** Apply a tmux history-limit to all tracked sessions. */
|
||||||
|
setHistoryLimit(limit: number): Promise<void>;
|
||||||
|
|
||||||
// ========== Discovery ==========
|
// ========== Discovery ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+47
-3
@@ -467,6 +467,12 @@ export class RalphTracker extends EventEmitter {
|
|||||||
/** Timestamp of last cleanup check for throttling */
|
/** Timestamp of last cleanup check for throttling */
|
||||||
private _lastCleanupTime: number = 0;
|
private _lastCleanupTime: number = 0;
|
||||||
|
|
||||||
|
/** Maximum number of todos retained for this session (defaults to global cap) */
|
||||||
|
private _maxTodos: number = MAX_TODOS_PER_SESSION;
|
||||||
|
|
||||||
|
/** Todo auto-expiry duration in milliseconds (defaults to global constant) */
|
||||||
|
private _todoExpiryMs: number = TODO_EXPIRY_MS;
|
||||||
|
|
||||||
/** Debouncer for todoUpdate events */
|
/** Debouncer for todoUpdate events */
|
||||||
private _todoDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
private _todoDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
||||||
|
|
||||||
@@ -1053,6 +1059,10 @@ export class RalphTracker extends EventEmitter {
|
|||||||
planVersion: this.planTracker.planVersion,
|
planVersion: this.planTracker.planVersion,
|
||||||
planHistoryLength: this.planTracker.getPlanHistory().length,
|
planHistoryLength: this.planTracker.getPlanHistory().length,
|
||||||
completionConfidence: this._lastCompletionConfidence,
|
completionConfidence: this._lastCompletionConfidence,
|
||||||
|
// Surface the live todo-config so it persists (toState) and reads back into
|
||||||
|
// the Session Options modal (broadcast) — mirrors maxIterations round-trip.
|
||||||
|
maxTodos: this._maxTodos,
|
||||||
|
todoExpirationMinutes: this.todoExpirationMinutes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1840,7 +1850,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (this._todos.size >= MAX_TODOS_PER_SESSION) {
|
while (this._todos.size >= this._maxTodos) {
|
||||||
const oldest = this.findOldestTodo();
|
const oldest = this.findOldestTodo();
|
||||||
if (oldest) {
|
if (oldest) {
|
||||||
this._todos.delete(oldest.id);
|
this._todos.delete(oldest.id);
|
||||||
@@ -2164,14 +2174,14 @@ export class RalphTracker extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove todo items older than TODO_EXPIRY_MS.
|
* Remove todo items older than the configured expiry duration.
|
||||||
*/
|
*/
|
||||||
private cleanupExpiredTodos(): void {
|
private cleanupExpiredTodos(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const toDelete: string[] = [];
|
const toDelete: string[] = [];
|
||||||
|
|
||||||
for (const [id, todo] of this._todos) {
|
for (const [id, todo] of this._todos) {
|
||||||
if (now - todo.detectedAt > TODO_EXPIRY_MS) {
|
if (now - todo.detectedAt > this._todoExpiryMs) {
|
||||||
toDelete.push(id);
|
toDelete.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2211,6 +2221,34 @@ export class RalphTracker extends EventEmitter {
|
|||||||
this.emit('loopUpdate', this.loopState);
|
this.emit('loopUpdate', this.loopState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maximum number of todos retained for this session. */
|
||||||
|
get maxTodos(): number {
|
||||||
|
return this._maxTodos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Todo auto-expiry duration in minutes for this session. */
|
||||||
|
get todoExpirationMinutes(): number {
|
||||||
|
return Math.round(this._todoExpiryMs / 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the maximum number of retained todos (external API).
|
||||||
|
* Ignores non-positive values.
|
||||||
|
*/
|
||||||
|
setMaxTodos(maxTodos: number): void {
|
||||||
|
if (!Number.isFinite(maxTodos) || maxTodos <= 0) return;
|
||||||
|
this._maxTodos = Math.floor(maxTodos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the todo auto-expiry duration (external API), specified in minutes.
|
||||||
|
* Converts to milliseconds internally. Ignores non-positive values.
|
||||||
|
*/
|
||||||
|
setTodoExpirationMinutes(minutes: number): void {
|
||||||
|
if (!Number.isFinite(minutes) || minutes <= 0) return;
|
||||||
|
this._todoExpiryMs = Math.floor(minutes) * 60000;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure the tracker from external state.
|
* Configure the tracker from external state.
|
||||||
*/
|
*/
|
||||||
@@ -2311,6 +2349,12 @@ export class RalphTracker extends EventEmitter {
|
|||||||
...loopState,
|
...loopState,
|
||||||
enabled: loopState.enabled ?? false,
|
enabled: loopState.enabled ?? false,
|
||||||
};
|
};
|
||||||
|
// Restore the per-session todo-config into the live fields used by the hot
|
||||||
|
// paths (eviction cap + expiry). Setters ignore non-positive values.
|
||||||
|
if (typeof loopState.maxTodos === 'number') this.setMaxTodos(loopState.maxTodos);
|
||||||
|
if (typeof loopState.todoExpirationMinutes === 'number') {
|
||||||
|
this.setTodoExpirationMinutes(loopState.todoExpirationMinutes);
|
||||||
|
}
|
||||||
this._todos.clear();
|
this._todos.clear();
|
||||||
for (const todo of todos) {
|
for (const todo of todos) {
|
||||||
this._todos.set(todo.id, {
|
this._todos.set(todo.id, {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Pure cross-session federated search core (COD-9).
|
||||||
|
*
|
||||||
|
* `searchSources()` is the testable heart of `GET /api/search`: it takes a
|
||||||
|
* normalized query plus already-collected, in-memory source data and returns
|
||||||
|
* grouped, ranked, and capped results. It performs NO I/O — the route wrapper
|
||||||
|
* (`src/web/routes/search-routes.ts`) is responsible for harvesting the source
|
||||||
|
* arrays from the live server stores (sessions, run-summary trackers, attachment
|
||||||
|
* histories) in a bounded way before calling this.
|
||||||
|
*
|
||||||
|
* v1 scope (do not expand here): three sources — sessions/cases, run-summary
|
||||||
|
* events, file paths. Terminal-buffer scanning and any persisted index are
|
||||||
|
* explicitly deferred.
|
||||||
|
*
|
||||||
|
* Ranking: results are grouped by source type in the fixed order
|
||||||
|
* sessions → events → files. Within each group, exact (case-insensitive)
|
||||||
|
* name/path matches come first, then recency (newest timestamp first) as the
|
||||||
|
* tiebreak. There is no relevance-scoring pass in v1.
|
||||||
|
*
|
||||||
|
* Safety: file results only ever expose a workspace-relative path — server-
|
||||||
|
* private absolute paths are never placed in a result. Per-group and total caps
|
||||||
|
* bound the output so a broad query cannot return an unbounded payload.
|
||||||
|
*
|
||||||
|
* Key exports:
|
||||||
|
* - searchSources() — the pure core.
|
||||||
|
* - SEARCH_TOTAL_CAP / SEARCH_PER_GROUP_CAP — the output bounds.
|
||||||
|
* - SearchSources and the *Input row types — the source-data contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SearchResult, SearchResultGroup, SearchResponseData, SearchSourceType } from './types/search.js';
|
||||||
|
|
||||||
|
/** Maximum results returned across all groups combined. */
|
||||||
|
export const SEARCH_TOTAL_CAP = 60;
|
||||||
|
/** Maximum results returned within any single source group. */
|
||||||
|
export const SEARCH_PER_GROUP_CAP = 25;
|
||||||
|
/** Maximum characters in a result snippet. */
|
||||||
|
export const SEARCH_SNIPPET_MAX = 200;
|
||||||
|
|
||||||
|
/** A live-session row harvested for the session/case source. */
|
||||||
|
export interface SessionSearchInput {
|
||||||
|
sessionId: string;
|
||||||
|
sessionName: string;
|
||||||
|
workingDir: string;
|
||||||
|
/** Recency timestamp (e.g. lastActivityAt or createdAt). */
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A run-summary timeline event harvested for the event source. */
|
||||||
|
export interface EventSearchInput {
|
||||||
|
sessionId: string;
|
||||||
|
sessionName: string;
|
||||||
|
eventId: string;
|
||||||
|
title: string;
|
||||||
|
details: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A per-session attachment harvested for the file source. */
|
||||||
|
export interface FileSearchInput {
|
||||||
|
sessionId: string;
|
||||||
|
sessionName: string;
|
||||||
|
fileName: string;
|
||||||
|
/** Workspace-relative path, if known. Absolute/external paths are never passed in. */
|
||||||
|
relativePath: string | undefined;
|
||||||
|
timestamp: number;
|
||||||
|
/** Attachment history item id, used as the jump-to target. */
|
||||||
|
itemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full set of in-memory source data the pure core searches over. */
|
||||||
|
export interface SearchSources {
|
||||||
|
sessions: SessionSearchInput[];
|
||||||
|
events: EventSearchInput[];
|
||||||
|
files: FileSearchInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed group/render order. */
|
||||||
|
const GROUP_ORDER: SearchSourceType[] = ['session', 'event', 'file'];
|
||||||
|
|
||||||
|
function truncate(text: string, max = SEARCH_SNIPPET_MAX): string {
|
||||||
|
const trimmed = text.trim().replace(/\s+/g, ' ');
|
||||||
|
return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort a group's results: exact matches first, then newest timestamp first.
|
||||||
|
* Stable for equal keys.
|
||||||
|
*/
|
||||||
|
function sortGroup(rows: SearchResult[]): SearchResult[] {
|
||||||
|
return rows
|
||||||
|
.map((result, index) => ({ result, index }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.result.exactMatch !== b.result.exactMatch) {
|
||||||
|
return a.result.exactMatch ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (a.result.timestamp !== b.result.timestamp) {
|
||||||
|
return b.result.timestamp - a.result.timestamp;
|
||||||
|
}
|
||||||
|
return a.index - b.index;
|
||||||
|
})
|
||||||
|
.map((r) => r.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the provided in-memory sources for `query`.
|
||||||
|
*
|
||||||
|
* @param query Raw query string (already length-validated by the route). Blank
|
||||||
|
* queries return an empty result set.
|
||||||
|
* @param sources Harvested, bounded source arrays.
|
||||||
|
*/
|
||||||
|
export function searchSources(query: string, sources: SearchSources): SearchResponseData {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (needle.length === 0) {
|
||||||
|
return { query: query.trim(), groups: [], totalResults: 0, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const contains = (s: string | undefined): boolean => !!s && s.toLowerCase().includes(needle);
|
||||||
|
const isExact = (s: string | undefined): boolean => !!s && s.toLowerCase() === needle;
|
||||||
|
|
||||||
|
// -- Source: sessions/cases --
|
||||||
|
const sessionRows: SearchResult[] = [];
|
||||||
|
for (const s of sources.sessions) {
|
||||||
|
if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) {
|
||||||
|
sessionRows.push({
|
||||||
|
type: 'session',
|
||||||
|
sessionId: s.sessionId,
|
||||||
|
sessionName: s.sessionName,
|
||||||
|
timestamp: s.timestamp,
|
||||||
|
snippet: truncate(s.workingDir ? `${s.sessionName} — ${s.workingDir}` : s.sessionName),
|
||||||
|
exactMatch: isExact(s.sessionName),
|
||||||
|
jumpTo: { kind: 'session', sessionId: s.sessionId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Source: run-summary events --
|
||||||
|
const eventRows: SearchResult[] = [];
|
||||||
|
for (const e of sources.events) {
|
||||||
|
if (contains(e.title) || contains(e.details)) {
|
||||||
|
const snippetBase = e.details && contains(e.details) ? `${e.title}: ${e.details}` : e.title;
|
||||||
|
eventRows.push({
|
||||||
|
type: 'event',
|
||||||
|
sessionId: e.sessionId,
|
||||||
|
sessionName: e.sessionName,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
snippet: truncate(snippetBase),
|
||||||
|
exactMatch: isExact(e.title),
|
||||||
|
jumpTo: { kind: 'run-summary', sessionId: e.sessionId, targetId: e.eventId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Source: file paths --
|
||||||
|
const fileRows: SearchResult[] = [];
|
||||||
|
for (const f of sources.files) {
|
||||||
|
if (contains(f.fileName) || contains(f.relativePath)) {
|
||||||
|
fileRows.push({
|
||||||
|
type: 'file',
|
||||||
|
sessionId: f.sessionId,
|
||||||
|
sessionName: f.sessionName,
|
||||||
|
timestamp: f.timestamp,
|
||||||
|
snippet: truncate(f.relativePath ?? f.fileName),
|
||||||
|
// Exact match keys off the safe path (or filename) — never an absolute path.
|
||||||
|
exactMatch: isExact(f.relativePath) || isExact(f.fileName),
|
||||||
|
jumpTo: {
|
||||||
|
kind: 'file-preview',
|
||||||
|
sessionId: f.sessionId,
|
||||||
|
targetId: f.itemId,
|
||||||
|
// Only ever expose a relative path; absolute/external paths are not passed in.
|
||||||
|
relativePath: f.relativePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byType: Record<SearchSourceType, SearchResult[]> = {
|
||||||
|
session: sortGroup(sessionRows),
|
||||||
|
event: sortGroup(eventRows),
|
||||||
|
file: sortGroup(fileRows),
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups: SearchResultGroup[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
for (const type of GROUP_ORDER) {
|
||||||
|
const all = byType[type];
|
||||||
|
if (all.length === 0) continue;
|
||||||
|
|
||||||
|
// Per-group cap.
|
||||||
|
let capped = all.slice(0, SEARCH_PER_GROUP_CAP);
|
||||||
|
if (all.length > capped.length) truncated = true;
|
||||||
|
|
||||||
|
// Total cap (never exceed the global budget).
|
||||||
|
const remaining = SEARCH_TOTAL_CAP - total;
|
||||||
|
if (capped.length > remaining) {
|
||||||
|
capped = capped.slice(0, Math.max(0, remaining));
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
if (capped.length === 0) continue;
|
||||||
|
|
||||||
|
groups.push({ type, results: capped });
|
||||||
|
total += capped.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { query: query.trim(), groups, totalResults: total, truncated };
|
||||||
|
}
|
||||||
+88
-8
@@ -48,6 +48,7 @@ import {
|
|||||||
type OpenCodeConfig,
|
type OpenCodeConfig,
|
||||||
type CodexConfig,
|
type CodexConfig,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
|
type GeminiConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||||
@@ -69,6 +70,7 @@ import {
|
|||||||
MAX_MESSAGES,
|
MAX_MESSAGES,
|
||||||
MAX_LINE_BUFFER_SIZE,
|
MAX_LINE_BUFFER_SIZE,
|
||||||
} from './config/buffer-limits.js';
|
} from './config/buffer-limits.js';
|
||||||
|
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||||
import {
|
import {
|
||||||
buildInteractiveArgs,
|
buildInteractiveArgs,
|
||||||
@@ -133,7 +135,22 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
|||||||
|
|
||||||
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
||||||
export function isExternalCliMode(mode: SessionMode): boolean {
|
export function isExternalCliMode(mode: SessionMode): boolean {
|
||||||
return mode === 'opencode' || mode === 'codex';
|
return mode === 'opencode' || mode === 'codex' || mode === 'gemini';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModeLabel(mode: SessionMode): string {
|
||||||
|
switch (mode) {
|
||||||
|
case 'opencode':
|
||||||
|
return 'OpenCode';
|
||||||
|
case 'codex':
|
||||||
|
return 'Codex';
|
||||||
|
case 'gemini':
|
||||||
|
return 'Gemini';
|
||||||
|
case 'shell':
|
||||||
|
return 'Shell';
|
||||||
|
case 'claude':
|
||||||
|
return 'Claude';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,14 +158,15 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
|||||||
* that we strip so the browser keeps everything in the main buffer with scrollback
|
* that we strip so the browser keeps everything in the main buffer with scrollback
|
||||||
* reachable (the strip runs on both the live stream and the buffer replay).
|
* reachable (the strip runs on both the live stream and the buffer replay).
|
||||||
*
|
*
|
||||||
* Codex and Claude Code are known, controlled TUIs that repaint via cursor
|
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
||||||
* positioning, so dropping the alt-screen switch is safe — content stays in the
|
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
||||||
* normal buffer. Excluded: `shell` (arbitrary programs like vim/less/htop
|
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
||||||
* legitimately need the alt screen) and `opencode` (renders its own TUI that
|
* vim/less/htop legitimately need the alt screen) and `opencode` (renders its own
|
||||||
* may rely on it). Keep parity with the replay-side strip in session-routes.ts.
|
* TUI that may rely on it). Keep parity with the replay-side strip in
|
||||||
|
* session-routes.ts.
|
||||||
*/
|
*/
|
||||||
export function isAltScreenStripMode(mode: SessionMode): boolean {
|
export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||||
return mode === 'codex' || mode === 'claude';
|
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||||
@@ -351,6 +369,8 @@ export class Session extends EventEmitter {
|
|||||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||||
// Codex configuration (only for mode === 'codex')
|
// Codex configuration (only for mode === 'codex')
|
||||||
private _codexConfig: CodexConfig | undefined;
|
private _codexConfig: CodexConfig | undefined;
|
||||||
|
// Gemini configuration (only for mode === 'gemini')
|
||||||
|
private _geminiConfig: GeminiConfig | undefined;
|
||||||
private _resumeSessionId: string | undefined;
|
private _resumeSessionId: string | undefined;
|
||||||
|
|
||||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||||
@@ -362,6 +382,9 @@ export class Session extends EventEmitter {
|
|||||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||||
private _effort: EffortLevel | undefined;
|
private _effort: EffortLevel | undefined;
|
||||||
|
|
||||||
|
// tmux history-limit (scrollback lines) applied to this session's pane.
|
||||||
|
private readonly _tmuxHistoryLimit: number;
|
||||||
|
|
||||||
// Session color for visual differentiation
|
// Session color for visual differentiation
|
||||||
private _color: import('./types.js').SessionColor = 'default';
|
private _color: import('./types.js').SessionColor = 'default';
|
||||||
|
|
||||||
@@ -421,12 +444,16 @@ export class Session extends EventEmitter {
|
|||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
/** Codex configuration (only for mode === 'codex') */
|
/** Codex configuration (only for mode === 'codex') */
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
/** Gemini configuration (only for mode === 'gemini') */
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
/** Resume a previous Claude conversation (used after server reboot) */
|
/** Resume a previous Claude conversation (used after server reboot) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** tmux history-limit (scrollback lines) for this session's pane. */
|
||||||
|
tmuxHistoryLimit?: number;
|
||||||
/** Restored per-session attachment history. May include server-private external paths. */
|
/** Restored per-session attachment history. May include server-private external paths. */
|
||||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||||
}
|
}
|
||||||
@@ -481,6 +508,11 @@ export class Session extends EventEmitter {
|
|||||||
this._codexConfig = config.codexConfig;
|
this._codexConfig = config.codexConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply Gemini configuration
|
||||||
|
if (config.geminiConfig) {
|
||||||
|
this._geminiConfig = config.geminiConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||||
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||||
@@ -495,6 +527,7 @@ export class Session extends EventEmitter {
|
|||||||
if (config.effort && isEffortLevel(config.effort)) {
|
if (config.effort && isEffortLevel(config.effort)) {
|
||||||
this._effort = config.effort;
|
this._effort = config.effort;
|
||||||
}
|
}
|
||||||
|
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||||
}
|
}
|
||||||
@@ -985,6 +1018,7 @@ export class Session extends EventEmitter {
|
|||||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
codexConfig: this._codexConfig,
|
codexConfig: this._codexConfig,
|
||||||
|
geminiConfig: this._geminiConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||||
@@ -1224,7 +1258,7 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
this._resetBuffers();
|
this._resetBuffers();
|
||||||
|
|
||||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude';
|
const modeLabel = getModeLabel(this.mode);
|
||||||
console.log(
|
console.log(
|
||||||
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||||
);
|
);
|
||||||
@@ -1243,9 +1277,11 @@ export class Session extends EventEmitter {
|
|||||||
allowedTools: this._allowedTools,
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
codexConfig: this._codexConfig,
|
codexConfig: this._codexConfig,
|
||||||
|
geminiConfig: this._geminiConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -1258,9 +1294,11 @@ export class Session extends EventEmitter {
|
|||||||
allowedTools: this._allowedTools,
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
codexConfig: this._codexConfig,
|
codexConfig: this._codexConfig,
|
||||||
|
geminiConfig: this._geminiConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'mux attachment',
|
spawnErrLabel: 'mux attachment',
|
||||||
});
|
});
|
||||||
@@ -1331,6 +1369,10 @@ export class Session extends EventEmitter {
|
|||||||
if (this.mode === 'codex') {
|
if (this.mode === 'codex') {
|
||||||
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
|
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
|
||||||
}
|
}
|
||||||
|
// Gemini sessions require tmux for Gemini/Google auth env injection via setenv
|
||||||
|
if (this.mode === 'gemini') {
|
||||||
|
throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Pass --session-id to use the SAME ID as the Codeman session
|
// Pass --session-id to use the SAME ID as the Codeman session
|
||||||
// This ensures subagents can be directly matched to the correct tab
|
// This ensures subagents can be directly matched to the correct tab
|
||||||
@@ -1594,6 +1636,7 @@ export class Session extends EventEmitter {
|
|||||||
mode: 'shell',
|
mode: 'shell',
|
||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -1602,6 +1645,7 @@ export class Session extends EventEmitter {
|
|||||||
name: this._name,
|
name: this._name,
|
||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'shell mux attachment',
|
spawnErrLabel: 'shell mux attachment',
|
||||||
});
|
});
|
||||||
@@ -2213,6 +2257,42 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-client highest-applied input sequence, for exactly-once input delivery.
|
||||||
|
* Keyed by the web client's stable `clientId`. Bounded so many devices over a
|
||||||
|
* long-lived session can't grow it without limit (insertion order = MRU, so
|
||||||
|
* eviction drops the least-recently-active client).
|
||||||
|
*/
|
||||||
|
private _appliedInputSeq = new Map<string, number>();
|
||||||
|
private static readonly MAX_INPUT_DEDUP_CLIENTS = 256;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether an input frame should be applied to the PTY or skipped as a
|
||||||
|
* duplicate redelivery. Returns true exactly once per (clientId, seq): the
|
||||||
|
* first time a seq strictly greater than the client's last-applied is seen.
|
||||||
|
* A redelivery of an already-applied seq (the client never got our ACK and
|
||||||
|
* resent) returns false. Callers should ACK regardless — a duplicate is, from
|
||||||
|
* the client's view, "delivered" — and only `write()` the PTY when this is
|
||||||
|
* true. Relies on the client delivering one client's frames in seq order over
|
||||||
|
* a single ordered stream, so `seq <= last` ⇒ already applied.
|
||||||
|
*
|
||||||
|
* Without this, the client's at-least-once redelivery (needed because a
|
||||||
|
* half-open socket silently drops frames with no error) would type a prompt
|
||||||
|
* twice whenever an ACK is lost after the write landed.
|
||||||
|
*/
|
||||||
|
shouldApplyInput(clientId: string, seq: number): boolean {
|
||||||
|
const last = this._appliedInputSeq.get(clientId);
|
||||||
|
if (last !== undefined && seq <= last) return false;
|
||||||
|
// Re-insert to move this client to the MRU end for fair eviction.
|
||||||
|
if (last !== undefined) this._appliedInputSeq.delete(clientId);
|
||||||
|
this._appliedInputSeq.set(clientId, seq);
|
||||||
|
if (this._appliedInputSeq.size > Session.MAX_INPUT_DEDUP_CLIENTS) {
|
||||||
|
const oldest = this._appliedInputSeq.keys().next().value;
|
||||||
|
if (oldest !== undefined) this._appliedInputSeq.delete(oldest);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends input via the terminal multiplexer's direct input mechanism.
|
* Sends input via the terminal multiplexer's direct input mechanism.
|
||||||
*
|
*
|
||||||
|
|||||||
+141
-4
@@ -41,9 +41,17 @@ import {
|
|||||||
type OpenCodeConfig,
|
type OpenCodeConfig,
|
||||||
type CodexConfig,
|
type CodexConfig,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
|
type GeminiConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||||
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir } from './utils/index.js';
|
import {
|
||||||
|
wrapWithNice,
|
||||||
|
SAFE_PATH_PATTERN,
|
||||||
|
findClaudeDir,
|
||||||
|
resolveOpenCodeDir,
|
||||||
|
resolveCodexDir,
|
||||||
|
resolveGeminiDir,
|
||||||
|
} from './utils/index.js';
|
||||||
import type {
|
import type {
|
||||||
TerminalMultiplexer,
|
TerminalMultiplexer,
|
||||||
MuxSession,
|
MuxSession,
|
||||||
@@ -57,6 +65,7 @@ import type {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||||
|
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||||
|
|
||||||
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
||||||
const TMUX_CREATION_WAIT_MS = 100;
|
const TMUX_CREATION_WAIT_MS = 100;
|
||||||
@@ -571,6 +580,35 @@ export function buildCodexCommand(config?: CodexConfig): string {
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Gemini CLI command with appropriate flags.
|
||||||
|
*
|
||||||
|
* `--skip-trust` avoids a first-run workspace trust prompt inside Codeman.
|
||||||
|
* Approval mode defaults to `yolo` for parity with Codeman's Claude default
|
||||||
|
* of `--dangerously-skip-permissions`; users can override it later through
|
||||||
|
* Gemini config once Codeman exposes richer Gemini settings.
|
||||||
|
*/
|
||||||
|
function buildGeminiCommand(config?: GeminiConfig): string {
|
||||||
|
const parts = ['gemini', '--skip-trust'];
|
||||||
|
|
||||||
|
const approvalMode = config?.approvalMode || 'yolo';
|
||||||
|
if (['default', 'auto_edit', 'yolo', 'plan'].includes(approvalMode)) {
|
||||||
|
parts.push('--approval-mode', approvalMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.model) {
|
||||||
|
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||||
|
if (safeModel) parts.push('--model', safeModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.resumeSession) {
|
||||||
|
const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSession) ? config.resumeSession : undefined;
|
||||||
|
if (safeId) parts.push('--resume', safeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the spawn command for any session mode.
|
* Build the spawn command for any session mode.
|
||||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||||
@@ -597,6 +635,7 @@ function buildSpawnCommand(options: {
|
|||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
}): string {
|
}): string {
|
||||||
@@ -624,6 +663,9 @@ function buildSpawnCommand(options: {
|
|||||||
if (options.mode === 'codex') {
|
if (options.mode === 'codex') {
|
||||||
return buildCodexCommand(options.codexConfig);
|
return buildCodexCommand(options.codexConfig);
|
||||||
}
|
}
|
||||||
|
if (options.mode === 'gemini') {
|
||||||
|
return buildGeminiCommand(options.geminiConfig);
|
||||||
|
}
|
||||||
return '$SHELL';
|
return '$SHELL';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,6 +716,38 @@ function setCodexEnvVars(tmuxCmd: string, muxName: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set sensitive environment variables for Gemini on a tmux session via setenv.
|
||||||
|
* Gemini Pro/Ultra users usually authenticate via cached Google login; these
|
||||||
|
* variables cover API-key and Vertex AI paths without putting secrets in ps.
|
||||||
|
*/
|
||||||
|
function setGeminiEnvVars(tmuxCmd: string, muxName: string): void {
|
||||||
|
const sensitiveVars = [
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'GEMINI_MODEL',
|
||||||
|
'GOOGLE_API_KEY',
|
||||||
|
'GOOGLE_CLOUD_PROJECT',
|
||||||
|
'GOOGLE_CLOUD_LOCATION',
|
||||||
|
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||||
|
'GOOGLE_GENAI_USE_VERTEXAI',
|
||||||
|
];
|
||||||
|
for (const key of sensitiveVars) {
|
||||||
|
const val = process.env[key];
|
||||||
|
if (val) {
|
||||||
|
const escaped = val.replace(/'/g, "'\\''");
|
||||||
|
try {
|
||||||
|
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* Non-critical — key may not be needed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
||||||
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
||||||
@@ -856,8 +930,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const exports = [
|
const exports = [
|
||||||
'export LANG=en_US.UTF-8',
|
'export LANG=en_US.UTF-8',
|
||||||
'export LC_ALL=en_US.UTF-8',
|
'export LC_ALL=en_US.UTF-8',
|
||||||
mode === 'codex' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
|
mode === 'codex' || mode === 'gemini' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
|
||||||
...(mode === 'codex' ? ['unset NO_COLOR'] : []),
|
...(mode === 'codex' || mode === 'gemini' ? ['unset NO_COLOR'] : []),
|
||||||
'export CODEMAN_MUX=1',
|
'export CODEMAN_MUX=1',
|
||||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||||
@@ -930,6 +1004,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const dir = resolveCodexDir();
|
const dir = resolveCodexDir();
|
||||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||||
}
|
}
|
||||||
|
if (mode === 'gemini') {
|
||||||
|
const dir = resolveGeminiDir();
|
||||||
|
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||||
|
}
|
||||||
return { pathExport: '', dir: null };
|
return { pathExport: '', dir: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,6 +1031,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
setCodexEnvVars(this.tmux(), muxName);
|
setCodexEnvVars(this.tmux(), muxName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Gemini-specific environment on a tmux session.
|
||||||
|
*/
|
||||||
|
private _configureGemini(muxName: string): void {
|
||||||
|
setGeminiEnvVars(this.tmux(), muxName);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||||
* In test mode: creates an in-memory session only (no real tmux session).
|
* In test mode: creates an in-memory session only (no real tmux session).
|
||||||
@@ -969,9 +1054,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
|
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
} = options;
|
} = options;
|
||||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||||
|
|
||||||
@@ -1007,6 +1094,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (mode === 'opencode' && !cliDir) {
|
if (mode === 'opencode' && !cliDir) {
|
||||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||||
}
|
}
|
||||||
|
if (mode === 'codex' && !cliDir) {
|
||||||
|
throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex');
|
||||||
|
}
|
||||||
|
if (mode === 'gemini' && !cliDir) {
|
||||||
|
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
||||||
|
}
|
||||||
|
|
||||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||||
|
|
||||||
@@ -1018,6 +1111,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
});
|
});
|
||||||
@@ -1067,6 +1161,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
} else if (mode === 'codex') {
|
} else if (mode === 'codex') {
|
||||||
this._configureCodex(muxName);
|
this._configureCodex(muxName);
|
||||||
}
|
}
|
||||||
|
// For Gemini: set Gemini/Google auth env vars via tmux setenv
|
||||||
|
if (mode === 'gemini') {
|
||||||
|
this._configureGemini(muxName);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
||||||
// so secret values stay off the bash command line. Must run before respawn-pane.
|
// so secret values stay off the bash command line. Must run before respawn-pane.
|
||||||
@@ -1105,7 +1203,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}),
|
}),
|
||||||
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
||||||
// more context. Matches the xterm-side default in constants.js.
|
// more context. Matches the xterm-side default in constants.js.
|
||||||
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
|
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
})
|
||||||
.then(() => {})
|
.then(() => {})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* Non-critical — falls back to tmux default */
|
/* Non-critical — falls back to tmux default */
|
||||||
@@ -1224,9 +1324,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
|
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
} = options;
|
} = options;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
@@ -1234,6 +1336,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
|
|
||||||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
||||||
|
|
||||||
|
// Re-apply the configured tmux history-limit after respawn (kept in sync
|
||||||
|
// with the live setting via setHistoryLimit()).
|
||||||
|
if (!IS_TEST_MODE) {
|
||||||
|
await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, {
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
}).catch(() => {
|
||||||
|
/* Non-critical — keeps existing tmux history-limit */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve CLI binary directory based on mode
|
// Resolve CLI binary directory based on mode
|
||||||
const { pathExport } = this.buildPathExport(mode);
|
const { pathExport } = this.buildPathExport(mode);
|
||||||
|
|
||||||
@@ -1247,6 +1359,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
});
|
});
|
||||||
@@ -1261,6 +1374,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
} else if (mode === 'codex') {
|
} else if (mode === 'codex') {
|
||||||
this._configureCodex(muxName);
|
this._configureCodex(muxName);
|
||||||
}
|
}
|
||||||
|
// For Gemini: set Gemini/Google auth env vars via tmux setenv before respawn
|
||||||
|
if (mode === 'gemini') {
|
||||||
|
this._configureGemini(muxName);
|
||||||
|
}
|
||||||
|
|
||||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||||
this.applyEnvOverrides(muxName, envOverrides);
|
this.applyEnvOverrides(muxName, envOverrides);
|
||||||
@@ -1833,6 +1950,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a tmux history-limit to all tracked sessions (e.g. when the user
|
||||||
|
* changes the terminal-history setting). Invalid limits fall back to the
|
||||||
|
* default. Best-effort per session.
|
||||||
|
*/
|
||||||
|
async setHistoryLimit(limit: number): Promise<void> {
|
||||||
|
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
||||||
|
|
||||||
|
if (IS_TEST_MODE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = Array.from(this.sessions.values()).map((session) =>
|
||||||
|
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.allSettled(updates);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send input directly to a tmux session using `send-keys`.
|
* Send input directly to a tmux session using `send-keys`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -68,3 +68,4 @@ export * from './plan.js';
|
|||||||
export * from './orchestrator.js';
|
export * from './orchestrator.js';
|
||||||
export * from './update.js';
|
export * from './update.js';
|
||||||
export * from './workflow-run.js';
|
export * from './workflow-run.js';
|
||||||
|
export * from './search.js';
|
||||||
@@ -90,6 +90,10 @@ export interface RalphTrackerState {
|
|||||||
cycleCount: number;
|
cycleCount: number;
|
||||||
/** Maximum iterations if detected */
|
/** Maximum iterations if detected */
|
||||||
maxIterations: number | null;
|
maxIterations: number | null;
|
||||||
|
/** Max todos retained for this session before FIFO eviction (persisted; default = global cap) */
|
||||||
|
maxTodos?: number;
|
||||||
|
/** Todo auto-expiry in minutes (persisted; default = global TODO_EXPIRY_MS) */
|
||||||
|
todoExpirationMinutes?: number;
|
||||||
/** Timestamp of last activity */
|
/** Timestamp of last activity */
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
/** Elapsed hours if detected */
|
/** Elapsed hours if detected */
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Cross-session federated search types (COD-9).
|
||||||
|
*
|
||||||
|
* Defines the typed shapes for `GET /api/search` — a bounded, in-memory
|
||||||
|
* federated search across three v1 sources: live sessions/cases, run-summary
|
||||||
|
* timeline events, and per-session attachment file paths. Terminal-buffer scans
|
||||||
|
* and any persisted index are explicitly out of scope for v1.
|
||||||
|
*
|
||||||
|
* Key exports:
|
||||||
|
* - SearchSourceType — the federated source kinds, also the group order key.
|
||||||
|
* - SearchResult — a single typed result card (source, session id/name,
|
||||||
|
* timestamp, snippet, jump-to action target).
|
||||||
|
* - SearchJumpTarget — where the frontend should navigate when a card is opened.
|
||||||
|
* - SearchResponseData — grouped result payload returned in the ApiResponse envelope.
|
||||||
|
*
|
||||||
|
* No I/O, no dependencies on other domain modules. The pure search core lives
|
||||||
|
* in `src/search-service.ts`; the route wrapper in `src/web/routes/search-routes.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Federated source kinds. Group/render order is sessions → events → files. */
|
||||||
|
export type SearchSourceType = 'session' | 'event' | 'file';
|
||||||
|
|
||||||
|
/** Where the frontend should jump when a result card is activated. */
|
||||||
|
export interface SearchJumpTarget {
|
||||||
|
/** Kind of navigation target. */
|
||||||
|
kind: 'session' | 'run-summary' | 'file-preview';
|
||||||
|
/** Owning Codeman session id (always present — every result is session-scoped). */
|
||||||
|
sessionId: string;
|
||||||
|
/**
|
||||||
|
* Secondary identifier for the target:
|
||||||
|
* - kind 'run-summary': the run-summary event id
|
||||||
|
* - kind 'file-preview': the attachment history item id
|
||||||
|
* - kind 'session': undefined (the sessionId is sufficient)
|
||||||
|
*/
|
||||||
|
targetId?: string;
|
||||||
|
/**
|
||||||
|
* Workspace-relative path for file-preview targets. Never an absolute path —
|
||||||
|
* server-private external paths are intentionally omitted to avoid leakage.
|
||||||
|
*/
|
||||||
|
relativePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single typed search result card. */
|
||||||
|
export interface SearchResult {
|
||||||
|
/** Which federated source produced this result. */
|
||||||
|
type: SearchSourceType;
|
||||||
|
/** Owning Codeman session id. */
|
||||||
|
sessionId: string;
|
||||||
|
/** Display name of the owning session / case. */
|
||||||
|
sessionName: string;
|
||||||
|
/** Millisecond timestamp used for recency ranking and display. */
|
||||||
|
timestamp: number;
|
||||||
|
/** Short, already-truncated snippet describing the match. */
|
||||||
|
snippet: string;
|
||||||
|
/** True when the query matched the primary name/path exactly (case-insensitive). */
|
||||||
|
exactMatch: boolean;
|
||||||
|
/** Navigation target for the jump-to action. */
|
||||||
|
jumpTo: SearchJumpTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group of results for one source type, in render order. */
|
||||||
|
export interface SearchResultGroup {
|
||||||
|
type: SearchSourceType;
|
||||||
|
results: SearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload returned as `data` inside the standard ApiResponse envelope. */
|
||||||
|
export interface SearchResponseData {
|
||||||
|
/** The normalized query that was executed. */
|
||||||
|
query: string;
|
||||||
|
/** Results grouped by source type, ordered sessions → events → files. */
|
||||||
|
groups: SearchResultGroup[];
|
||||||
|
/** Total number of results across all groups (after caps applied). */
|
||||||
|
totalResults: number;
|
||||||
|
/** True if any group or the total was capped (more matches existed). */
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
+16
-2
@@ -8,10 +8,12 @@
|
|||||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||||
* - SessionOutput — captured stdout/stderr/exitCode
|
* - SessionOutput — captured stdout/stderr/exitCode
|
||||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' (which CLI backend)
|
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
|
||||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
||||||
* - SessionColor — visual differentiation color
|
* - SessionColor — visual differentiation color
|
||||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||||
|
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
||||||
|
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
|
||||||
*
|
*
|
||||||
* Cross-domain relationships:
|
* Cross-domain relationships:
|
||||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||||
@@ -39,7 +41,7 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
|||||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
||||||
|
|
||||||
/** Session mode: which CLI backend a session runs */
|
/** Session mode: which CLI backend a session runs */
|
||||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex';
|
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||||
@@ -85,6 +87,16 @@ export interface CodexConfig {
|
|||||||
renderMode?: CodexRenderMode;
|
renderMode?: CodexRenderMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gemini CLI session configuration */
|
||||||
|
export interface GeminiConfig {
|
||||||
|
/** Model identifier (e.g., "gemini-2.5-pro"). Passed via --model. */
|
||||||
|
model?: string;
|
||||||
|
/** Gemini approval mode for tool calls. */
|
||||||
|
approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan';
|
||||||
|
/** Resume a previous Gemini session ("latest", index, or session id). */
|
||||||
|
resumeSession?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for creating a new session
|
* Configuration for creating a new session
|
||||||
*/
|
*/
|
||||||
@@ -214,6 +226,8 @@ export interface SessionState {
|
|||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
/** Codex-specific configuration (only for mode === 'codex') */
|
/** Codex-specific configuration (only for mode === 'codex') */
|
||||||
codexConfig?: CodexConfig;
|
codexConfig?: CodexConfig;
|
||||||
|
/** Gemini-specific configuration (only for mode === 'gemini') */
|
||||||
|
geminiConfig?: GeminiConfig;
|
||||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Resolve the Gemini CLI binary across common install paths.
|
||||||
|
*
|
||||||
|
* Mirrors codex-cli-resolver.ts and opencode-cli-resolver.ts. Finds the
|
||||||
|
* `gemini` binary and provides an augmented PATH directory for tmux sessions.
|
||||||
|
*
|
||||||
|
* @module utils/gemini-cli-resolver
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
|
||||||
|
/** Common directories where the Gemini CLI binary may be installed */
|
||||||
|
const GEMINI_SEARCH_DIRS = [
|
||||||
|
join(homedir(), '.gemini', 'bin'),
|
||||||
|
join(homedir(), '.local', 'bin'),
|
||||||
|
'/usr/local/bin',
|
||||||
|
join(homedir(), '.bun', 'bin'),
|
||||||
|
join(homedir(), '.npm-global', 'bin'),
|
||||||
|
join(homedir(), 'bin'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Cached directory containing the gemini binary (empty string = searched but not found) */
|
||||||
|
let _geminiDir: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the directory containing the `gemini` binary.
|
||||||
|
* Checks `which gemini` first, then falls back to common install locations.
|
||||||
|
*
|
||||||
|
* @returns Directory path, or null if not found
|
||||||
|
*/
|
||||||
|
export function resolveGeminiDir(): string | null {
|
||||||
|
if (_geminiDir !== null) return _geminiDir || null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = execSync('which gemini', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
}).trim();
|
||||||
|
if (result && existsSync(result)) {
|
||||||
|
_geminiDir = dirname(result);
|
||||||
|
return _geminiDir;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Gemini not in PATH, will check common locations
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of GEMINI_SEARCH_DIRS) {
|
||||||
|
if (existsSync(join(dir, 'gemini'))) {
|
||||||
|
_geminiDir = dir;
|
||||||
|
return _geminiDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_geminiDir = '';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Gemini CLI is available on the system.
|
||||||
|
*/
|
||||||
|
export function isGeminiAvailable(): boolean {
|
||||||
|
return resolveGeminiDir() !== null;
|
||||||
|
}
|
||||||
@@ -29,3 +29,4 @@ export { wrapWithNice } from './nice-wrapper.js';
|
|||||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||||
|
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import type { LifecycleEntry, RunSummary, RunSummaryEvent, TokenUsageEntry } from '../types.js';
|
||||||
|
|
||||||
|
export type AwayDigestRangeName = 'since-last-visit' | '1h' | 'today' | '24h' | 'custom';
|
||||||
|
export type AwayDigestCategory = 'needs_attention' | 'completed' | 'still_running' | 'idle' | 'informational';
|
||||||
|
export type AwayDigestSectionName = 'needsAttention' | 'completed' | 'stillRunning' | 'idle' | 'informational';
|
||||||
|
export type AwayDigestSeverity = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
export type AwayDigestSource = 'lifecycle' | 'run_summary' | 'status' | 'token_stats' | 'subagent';
|
||||||
|
export type AwayDigestTokenWindowPrecision = 'day' | 'none';
|
||||||
|
|
||||||
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
|
const VALID_RANGES = new Set<AwayDigestRangeName>(['since-last-visit', '1h', 'today', '24h', 'custom']);
|
||||||
|
|
||||||
|
export interface AwayDigestRange {
|
||||||
|
range: AwayDigestRangeName;
|
||||||
|
since: number;
|
||||||
|
until: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwayDigestRangeInput {
|
||||||
|
range?: string;
|
||||||
|
since?: number;
|
||||||
|
until?: number;
|
||||||
|
lastViewed?: number;
|
||||||
|
now?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwayDigestSession {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
status?: string;
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalCost?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwayDigestSubagent {
|
||||||
|
id?: string;
|
||||||
|
agentId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
lastUpdated?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
completedAt?: number;
|
||||||
|
modifiedAt?: number;
|
||||||
|
lastActivityAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwayDigestItem {
|
||||||
|
id: string;
|
||||||
|
sessionId?: string;
|
||||||
|
sessionName?: string;
|
||||||
|
timestamp: number;
|
||||||
|
category: AwayDigestCategory;
|
||||||
|
severity: AwayDigestSeverity;
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
source: AwayDigestSource;
|
||||||
|
link?: {
|
||||||
|
type: 'session' | 'run_summary' | 'lifecycle' | 'notification';
|
||||||
|
sessionId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwayDigestTotals {
|
||||||
|
sessionsCreated: number;
|
||||||
|
sessionsExited: number;
|
||||||
|
activeSessions: number;
|
||||||
|
needsAttention: number;
|
||||||
|
completed: number;
|
||||||
|
errors: number;
|
||||||
|
warnings: number;
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
estimatedCost?: number;
|
||||||
|
tokenWindowPrecision: AwayDigestTokenWindowPrecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwayDigestResponse {
|
||||||
|
range: AwayDigestRange;
|
||||||
|
generatedAt: number;
|
||||||
|
dataFreshness: {
|
||||||
|
lifecyclePersisted: true;
|
||||||
|
tokenStatsPersisted: true;
|
||||||
|
runSummariesLiveOnly: true;
|
||||||
|
subagentsLiveOnly: true;
|
||||||
|
};
|
||||||
|
totals: AwayDigestTotals;
|
||||||
|
sections: Record<AwayDigestSectionName, AwayDigestItem[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwayDigestInput {
|
||||||
|
range: AwayDigestRange;
|
||||||
|
lifecycleEntries: LifecycleEntry[];
|
||||||
|
runSummaries: RunSummary[];
|
||||||
|
sessions: AwayDigestSession[];
|
||||||
|
dailyTokenStats: TokenUsageEntry[];
|
||||||
|
subagents: AwayDigestSubagent[];
|
||||||
|
now?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAwayDigestRange(input: AwayDigestRangeInput): AwayDigestRange {
|
||||||
|
const now = input.now ?? Date.now();
|
||||||
|
const range = (input.range ?? 'since-last-visit') as AwayDigestRangeName;
|
||||||
|
if (!VALID_RANGES.has(range)) {
|
||||||
|
throw new Error(`Invalid away digest range: ${input.range}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let since: number;
|
||||||
|
const until = finiteOrDefault(input.until, now);
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case 'since-last-visit':
|
||||||
|
since = finiteOrDefault(input.lastViewed, now - DAY_MS);
|
||||||
|
break;
|
||||||
|
case '1h':
|
||||||
|
since = now - HOUR_MS;
|
||||||
|
break;
|
||||||
|
case 'today': {
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
since = start.getTime();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '24h':
|
||||||
|
since = now - DAY_MS;
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
if (!Number.isFinite(input.since)) {
|
||||||
|
throw new Error('Custom away digest range requires a finite since timestamp');
|
||||||
|
}
|
||||||
|
since = input.since as number;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (until < since) {
|
||||||
|
throw new Error('Away digest until timestamp must be greater than or equal to since');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { range, since, until };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAwayDigest(input: AwayDigestInput): AwayDigestResponse {
|
||||||
|
const now = input.now ?? Date.now();
|
||||||
|
const sections: Record<AwayDigestSectionName, AwayDigestItem[]> = {
|
||||||
|
needsAttention: [],
|
||||||
|
completed: [],
|
||||||
|
stillRunning: [],
|
||||||
|
idle: [],
|
||||||
|
informational: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionsById = new Map(input.sessions.map((session) => [session.id, session]));
|
||||||
|
const lifecycleEntries = input.lifecycleEntries.filter((entry) => isInRange(entry.ts, input.range));
|
||||||
|
|
||||||
|
for (const entry of lifecycleEntries) {
|
||||||
|
addItem(sections, lifecycleEntryToItem(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const summary of input.runSummaries) {
|
||||||
|
for (const event of summary.events) {
|
||||||
|
if (!isInRange(event.timestamp, input.range)) continue;
|
||||||
|
addItem(sections, runSummaryEventToItem(summary, event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of input.sessions) {
|
||||||
|
const item = sessionToItem(session, now);
|
||||||
|
addItem(sections, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const subagent of input.subagents) {
|
||||||
|
const timestamp = subagentTimestamp(subagent, now);
|
||||||
|
if (!isInRange(timestamp, input.range) || subagent.status !== 'completed') continue;
|
||||||
|
addItem(sections, subagentToItem(subagent, sessionsById, timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenTotals = aggregateTokenStats(input.dailyTokenStats, input.range);
|
||||||
|
const totals = calculateTotals(sections, lifecycleEntries, input.sessions, tokenTotals);
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: input.range,
|
||||||
|
generatedAt: now,
|
||||||
|
dataFreshness: {
|
||||||
|
lifecyclePersisted: true,
|
||||||
|
tokenStatsPersisted: true,
|
||||||
|
runSummariesLiveOnly: true,
|
||||||
|
subagentsLiveOnly: true,
|
||||||
|
},
|
||||||
|
totals,
|
||||||
|
sections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteOrDefault(value: number | undefined, fallback: number): number {
|
||||||
|
return Number.isFinite(value) ? (value as number) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInRange(timestamp: number, range: AwayDigestRange): boolean {
|
||||||
|
return timestamp >= range.since && timestamp <= range.until;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem(sections: Record<AwayDigestSectionName, AwayDigestItem[]>, item: AwayDigestItem): void {
|
||||||
|
sections[sectionNameForCategory(item.category)].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionNameForCategory(category: AwayDigestCategory): AwayDigestSectionName {
|
||||||
|
switch (category) {
|
||||||
|
case 'needs_attention':
|
||||||
|
return 'needsAttention';
|
||||||
|
case 'still_running':
|
||||||
|
return 'stillRunning';
|
||||||
|
case 'completed':
|
||||||
|
case 'idle':
|
||||||
|
case 'informational':
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifecycleEntryToItem(entry: LifecycleEntry): AwayDigestItem {
|
||||||
|
const needsAttention = entry.event === 'mux_died' || (entry.event === 'exit' && (entry.exitCode ?? 0) !== 0);
|
||||||
|
return {
|
||||||
|
id: `lifecycle-${entry.ts}-${entry.event}-${entry.sessionId}`,
|
||||||
|
sessionId: entry.sessionId,
|
||||||
|
sessionName: entry.name,
|
||||||
|
timestamp: entry.ts,
|
||||||
|
category: needsAttention ? 'needs_attention' : 'informational',
|
||||||
|
severity: needsAttention ? 'error' : entry.event === 'exit' ? 'info' : 'info',
|
||||||
|
title: lifecycleTitle(entry),
|
||||||
|
detail: lifecycleDetail(entry),
|
||||||
|
source: 'lifecycle',
|
||||||
|
link: { type: 'lifecycle', sessionId: entry.sessionId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifecycleTitle(entry: LifecycleEntry): string {
|
||||||
|
if (entry.event === 'exit') {
|
||||||
|
return (entry.exitCode ?? 0) === 0 ? 'Session exited' : 'Session exited with error';
|
||||||
|
}
|
||||||
|
if (entry.event === 'mux_died') return 'Tmux session died';
|
||||||
|
return `Session ${entry.event.replaceAll('_', ' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifecycleDetail(entry: LifecycleEntry): string | undefined {
|
||||||
|
if (entry.reason) return entry.reason;
|
||||||
|
if (entry.event === 'exit' && entry.exitCode !== undefined && entry.exitCode !== null) {
|
||||||
|
return `Exit code ${entry.exitCode}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSummaryEventToItem(summary: RunSummary, event: RunSummaryEvent): AwayDigestItem {
|
||||||
|
const category = runSummaryCategory(event);
|
||||||
|
return {
|
||||||
|
id: `run-summary-${summary.sessionId}-${event.id}`,
|
||||||
|
sessionId: summary.sessionId,
|
||||||
|
sessionName: summary.sessionName,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
category,
|
||||||
|
severity: runSummarySeverity(event, category),
|
||||||
|
title: event.title,
|
||||||
|
detail: event.details,
|
||||||
|
source: 'run_summary',
|
||||||
|
link: { type: 'run_summary', sessionId: summary.sessionId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSummaryCategory(event: RunSummaryEvent): AwayDigestCategory {
|
||||||
|
if (event.type === 'ralph_completion') return 'completed';
|
||||||
|
if (event.severity === 'error' || event.severity === 'warning' || event.type === 'state_stuck') {
|
||||||
|
return 'needs_attention';
|
||||||
|
}
|
||||||
|
return 'informational';
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSummarySeverity(event: RunSummaryEvent, category: AwayDigestCategory): AwayDigestSeverity {
|
||||||
|
if (category === 'completed') return 'success';
|
||||||
|
return event.severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionToItem(session: AwayDigestSession, now: number): AwayDigestItem {
|
||||||
|
const isIdle = session.status === 'idle';
|
||||||
|
return {
|
||||||
|
id: `status-${session.id}`,
|
||||||
|
sessionId: session.id,
|
||||||
|
sessionName: session.name,
|
||||||
|
timestamp: now,
|
||||||
|
category: isIdle ? 'idle' : 'still_running',
|
||||||
|
severity: isIdle ? 'info' : 'success',
|
||||||
|
title: isIdle ? 'Session idle' : 'Session still running',
|
||||||
|
detail: session.status ? `Status: ${session.status}` : undefined,
|
||||||
|
source: 'status',
|
||||||
|
link: { type: 'session', sessionId: session.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentToItem(
|
||||||
|
subagent: AwayDigestSubagent,
|
||||||
|
sessionsById: Map<string, AwayDigestSession>,
|
||||||
|
timestamp: number
|
||||||
|
): AwayDigestItem {
|
||||||
|
const session = subagent.sessionId ? sessionsById.get(subagent.sessionId) : undefined;
|
||||||
|
const agentId = subagent.id ?? subagent.agentId ?? 'unknown';
|
||||||
|
return {
|
||||||
|
id: `subagent-${agentId}`,
|
||||||
|
sessionId: subagent.sessionId,
|
||||||
|
sessionName: session?.name,
|
||||||
|
timestamp,
|
||||||
|
category: 'informational',
|
||||||
|
severity: 'success',
|
||||||
|
title: 'Subagent completed',
|
||||||
|
detail: subagent.description,
|
||||||
|
source: 'subagent',
|
||||||
|
link: subagent.sessionId ? { type: 'session', sessionId: subagent.sessionId } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentTimestamp(subagent: AwayDigestSubagent, fallback: number): number {
|
||||||
|
return (
|
||||||
|
subagent.completedAt ??
|
||||||
|
subagent.lastUpdated ??
|
||||||
|
subagent.updatedAt ??
|
||||||
|
subagent.modifiedAt ??
|
||||||
|
subagent.lastActivityAt ??
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateTokenStats(
|
||||||
|
dailyTokenStats: TokenUsageEntry[],
|
||||||
|
range: AwayDigestRange
|
||||||
|
): { inputTokens: number; outputTokens: number; estimatedCost: number; precision: AwayDigestTokenWindowPrecision } {
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
let estimatedCost = 0;
|
||||||
|
|
||||||
|
for (const day of dailyTokenStats) {
|
||||||
|
if (!dayOverlapsRange(day.date, range)) continue;
|
||||||
|
inputTokens += day.inputTokens;
|
||||||
|
outputTokens += day.outputTokens;
|
||||||
|
estimatedCost += day.estimatedCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
estimatedCost,
|
||||||
|
precision: inputTokens > 0 || outputTokens > 0 || estimatedCost > 0 ? 'day' : 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayOverlapsRange(date: string, range: AwayDigestRange): boolean {
|
||||||
|
const dayStart = new Date(`${date}T00:00:00`).getTime();
|
||||||
|
const dayEnd = dayStart + DAY_MS - 1;
|
||||||
|
return dayStart <= range.until && dayEnd >= range.since;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTotals(
|
||||||
|
sections: Record<AwayDigestSectionName, AwayDigestItem[]>,
|
||||||
|
lifecycleEntries: LifecycleEntry[],
|
||||||
|
sessions: AwayDigestSession[],
|
||||||
|
tokenTotals: ReturnType<typeof aggregateTokenStats>
|
||||||
|
): AwayDigestTotals {
|
||||||
|
const allItems = Object.values(sections).flat();
|
||||||
|
return {
|
||||||
|
sessionsCreated: lifecycleEntries.filter((entry) => entry.event === 'created').length,
|
||||||
|
sessionsExited: lifecycleEntries.filter((entry) => entry.event === 'exit').length,
|
||||||
|
activeSessions: sessions.length,
|
||||||
|
needsAttention: sections.needsAttention.length,
|
||||||
|
completed: sections.completed.length,
|
||||||
|
errors: allItems.filter((item) => item.severity === 'error').length,
|
||||||
|
warnings: allItems.filter((item) => item.severity === 'warning').length,
|
||||||
|
inputTokens: tokenTotals.inputTokens,
|
||||||
|
outputTokens: tokenTotals.outputTokens,
|
||||||
|
estimatedCost: tokenTotals.estimatedCost,
|
||||||
|
tokenWindowPrecision: tokenTotals.precision,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import type { ClaudeMode, NiceConfig } from '../../types.js';
|
import type { ClaudeMode, NiceConfig } from '../../types.js';
|
||||||
import type { StateStore } from '../../state-store.js';
|
import type { StateStore } from '../../state-store.js';
|
||||||
|
import type { TerminalHistoryConfig } from '../../config/terminal-history.js';
|
||||||
|
|
||||||
export interface ConfigPort {
|
export interface ConfigPort {
|
||||||
readonly store: StateStore;
|
readonly store: StateStore;
|
||||||
@@ -15,6 +16,7 @@ export interface ConfigPort {
|
|||||||
getGlobalNiceConfig(): Promise<NiceConfig | undefined>;
|
getGlobalNiceConfig(): Promise<NiceConfig | undefined>;
|
||||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||||
|
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||||
getLightState(): unknown;
|
getLightState(): unknown;
|
||||||
getLightSessionsState(): unknown[];
|
getLightSessionsState(): unknown[];
|
||||||
|
|||||||
+330
-90
@@ -468,13 +468,28 @@ class CodemanApp {
|
|||||||
this.maxReconnectAttempts = 10;
|
this.maxReconnectAttempts = 10;
|
||||||
this.isOnline = navigator.onLine;
|
this.isOnline = navigator.onLine;
|
||||||
|
|
||||||
// Offline input queue
|
// Reliable, durable input delivery (replaces the old best-effort queue).
|
||||||
this._inputQueue = new Map(); // Map<sessionId, string>
|
// Every input byte is recorded with a stable clientId + a monotonic
|
||||||
this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session
|
// per-session seq, persisted to localStorage, and only dropped once the
|
||||||
|
// server ACKs that exact seq — so a half-open socket silently dropping a
|
||||||
|
// frame, a reconnect, or a page reload can never lose a typed prompt.
|
||||||
|
// Exactly-once: the server applies each (clientId, seq) at most once.
|
||||||
this._connectionStatus = 'connected';
|
this._connectionStatus = 'connected';
|
||||||
|
this._clientId = '';
|
||||||
// Sequential input send chain — ensures keystroke ordering across async fetches
|
this._seqCounters = new Map(); // sessionId -> last issued seq
|
||||||
this._inputSendChain = Promise.resolve();
|
this._pendingDeliveries = new Map(); // sessionId -> [{seq,data,useMux,ts,tries,sentAt}]
|
||||||
|
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||||
|
this._persistReliableTimer = null;
|
||||||
|
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
||||||
|
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
||||||
|
this._loadReliableState();
|
||||||
|
this._reliableSweepTimer = setInterval(() => this._redeliverSweep(), 2000);
|
||||||
|
// Flush the durable queue synchronously when the page is hidden/closed —
|
||||||
|
// debounced persistence may have a pending write we mustn't lose on reload.
|
||||||
|
window.addEventListener('pagehide', () => this._persistReliableNow());
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') this._persistReliableNow();
|
||||||
|
});
|
||||||
|
|
||||||
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
|
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
|
||||||
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
||||||
@@ -1931,8 +1946,10 @@ class CodemanApp {
|
|||||||
setConnectionStatus(status) {
|
setConnectionStatus(status) {
|
||||||
this._connectionStatus = status;
|
this._connectionStatus = status;
|
||||||
this._updateConnectionIndicator();
|
this._updateConnectionIndicator();
|
||||||
if (status === 'connected' && this._inputQueue.size > 0) {
|
if (status === 'connected') {
|
||||||
this._drainInputQueues();
|
// Reconnected (SSE) — push any durably-queued input out immediately
|
||||||
|
// instead of waiting for the next 2s sweep.
|
||||||
|
this._redeliverSweep();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1965,6 +1982,9 @@ class CodemanApp {
|
|||||||
// went over HTTP, which never claims (see ws-routes sizingToken).
|
// went over HTTP, which never claims (see ws-routes sizingToken).
|
||||||
this.sendResize(sessionId)?.catch?.(() => {});
|
this.sendResize(sessionId)?.catch?.(() => {});
|
||||||
this._startMobileResizeRetry(sessionId);
|
this._startMobileResizeRetry(sessionId);
|
||||||
|
// Flush any durably-queued input over the fresh socket (covers frames a
|
||||||
|
// prior half-open socket silently dropped, and input typed while offline).
|
||||||
|
this._onWsReady(sessionId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1979,6 +1999,10 @@ class CodemanApp {
|
|||||||
this._onSessionClearTerminal({ id: sessionId });
|
this._onSessionClearTerminal({ id: sessionId });
|
||||||
} else if (msg.t === 'r') {
|
} else if (msg.t === 'r') {
|
||||||
this._onSessionNeedsRefresh({ id: sessionId });
|
this._onSessionNeedsRefresh({ id: sessionId });
|
||||||
|
} else if (msg.t === 'ia') {
|
||||||
|
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||||
|
// the durable queue so it can never be re-delivered/lost.
|
||||||
|
this._onWsInputAck(msg.seq);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed messages
|
// Ignore malformed messages
|
||||||
@@ -2062,79 +2086,270 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send input to server without blocking the keystroke flush cycle.
|
* Public input entry point — name/signature kept for all call sites.
|
||||||
* Uses a sequential promise chain to preserve character ordering
|
* Records the input durably, then delivers it reliably (exactly-once). Never
|
||||||
* across concurrent async fetches.
|
* blocks the keystroke flush; never silently drops on a half-open socket.
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {string} input
|
||||||
|
* @param {{useMux?: boolean}} [opts] - useMux only affects the POST fallback.
|
||||||
*/
|
*/
|
||||||
_sendInputAsync(sessionId, input) {
|
_sendInputAsync(sessionId, input, opts) {
|
||||||
// Queue immediately if offline
|
if (!sessionId || !input) return;
|
||||||
if (!this.isOnline || this._connectionStatus === 'disconnected') {
|
this._reliableSend(sessionId, input, opts?.useMux === true);
|
||||||
this._enqueueInput(sessionId, input);
|
}
|
||||||
|
|
||||||
|
/** Record one input frame and kick delivery. The record lives until ACKed. */
|
||||||
|
_reliableSend(sessionId, data, useMux) {
|
||||||
|
const seq = this._nextSeq(sessionId);
|
||||||
|
const rec = { seq, data, useMux: !!useMux, ts: Date.now(), tries: 0, sentAt: 0 };
|
||||||
|
let list = this._pendingDeliveries.get(sessionId);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
this._pendingDeliveries.set(sessionId, list);
|
||||||
|
}
|
||||||
|
list.push(rec);
|
||||||
|
this._persistReliableState();
|
||||||
|
this._updateConnectionIndicator();
|
||||||
|
this._drainSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextSeq(sessionId) {
|
||||||
|
const next = (this._seqCounters.get(sessionId) || 0) + 1;
|
||||||
|
this._seqCounters.set(sessionId, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliver all unacked records for a session, in seq order. */
|
||||||
|
_drainSession(sessionId) {
|
||||||
|
const list = this._pendingDeliveries.get(sessionId);
|
||||||
|
if (!list || list.length === 0) return;
|
||||||
|
|
||||||
|
// Fast path: WebSocket open for this session — fire each not-yet-sent record
|
||||||
|
// over the single ordered stream. They stay pending until the server ACKs
|
||||||
|
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
|
||||||
|
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
|
||||||
|
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||||
|
for (const rec of list) {
|
||||||
|
if (rec.sentAt !== 0) continue;
|
||||||
|
try {
|
||||||
|
this._ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
||||||
|
rec.sentAt = Date.now();
|
||||||
|
rec.tries++;
|
||||||
|
} catch {
|
||||||
|
break; // socket died mid-send — reconnect/POST drainer retries
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast path: WebSocket — fire-and-forget, inherently ordered (single TCP stream).
|
// Slow path: no WS — POST records in order, awaiting each (the HTTP 2xx is
|
||||||
if (this._wsReady && this._wsSessionId === sessionId) {
|
// the ACK). Serialized per session so seq order survives async fetches.
|
||||||
|
if (this._postDraining.has(sessionId)) return;
|
||||||
|
this._postDraining.add(sessionId);
|
||||||
|
(async () => {
|
||||||
try {
|
try {
|
||||||
this._ws.send(JSON.stringify({ t: 'i', d: input }));
|
for (;;) {
|
||||||
this.clearPendingHooks(sessionId);
|
const cur = this._pendingDeliveries.get(sessionId);
|
||||||
return;
|
if (!cur || cur.length === 0) break;
|
||||||
} catch {
|
// If the WebSocket came back mid-drain, yield to it (the acked stream)
|
||||||
// WS send failed — fall through to HTTP POST
|
// so we don't redundantly re-POST what onopen is already re-sending.
|
||||||
}
|
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
// Slow path: HTTP POST — chain on dispatch only, don't wait for response.
|
const rec = cur[0];
|
||||||
// The server handles writeViaMux as fire-and-forget anyway.
|
rec.tries++;
|
||||||
this._inputSendChain = this._inputSendChain.then(() => {
|
rec.sentAt = Date.now();
|
||||||
const fetchPromise = fetch(`/api/sessions/${sessionId}/input`, {
|
let resp = null;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const body = { input: rec.data, seq: rec.seq, clientId: this._clientId };
|
||||||
body: JSON.stringify({ input }),
|
if (rec.useMux) body.useMux = true;
|
||||||
keepalive: input.length < 65536,
|
resp = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||||
});
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
// Handle response asynchronously — don't block next keystroke on response
|
body: JSON.stringify(body),
|
||||||
fetchPromise.then(resp => {
|
keepalive: rec.data.length < 65536,
|
||||||
if (!resp.ok) {
|
});
|
||||||
this._enqueueInput(sessionId, input);
|
} catch {
|
||||||
} else {
|
resp = null;
|
||||||
this.clearPendingHooks(sessionId);
|
}
|
||||||
|
if (resp && resp.ok) {
|
||||||
|
this._ackDelivery(sessionId, rec.seq);
|
||||||
|
} else if (resp && (resp.status === 404 || resp.status === 410)) {
|
||||||
|
// Session no longer exists — the input can never land. Drop it
|
||||||
|
// rather than retry forever (not a "lost" prompt: the target is gone).
|
||||||
|
this._ackDelivery(sessionId, rec.seq);
|
||||||
|
} else {
|
||||||
|
break; // offline / 5xx — leave queued; sweep + reconnect retry later
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
} finally {
|
||||||
this._enqueueInput(sessionId, input);
|
this._postDraining.delete(sessionId);
|
||||||
});
|
}
|
||||||
|
})();
|
||||||
// Return immediately after fetch is dispatched (don't await response)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop an ACKed record (by exact seq) and persist. */
|
||||||
_enqueueInput(sessionId, input) {
|
_ackDelivery(sessionId, seq) {
|
||||||
const existing = this._inputQueue.get(sessionId) || '';
|
const list = this._pendingDeliveries.get(sessionId);
|
||||||
let combined = existing + input;
|
if (list) {
|
||||||
// Enforce 64KB cap — keep most recent keystrokes
|
const idx = list.findIndex((r) => r.seq === seq);
|
||||||
if (combined.length > this._inputQueueMaxBytes) {
|
if (idx !== -1) {
|
||||||
combined = combined.slice(combined.length - this._inputQueueMaxBytes);
|
list.splice(idx, 1);
|
||||||
}
|
if (list.length === 0) this._pendingDeliveries.delete(sessionId);
|
||||||
this._inputQueue.set(sessionId, combined);
|
// When nothing is left pending anywhere, flush durable state immediately
|
||||||
this._updateConnectionIndicator();
|
// (not debounced) so a reload in the next 250ms can't redeliver an
|
||||||
}
|
// already-delivered frame — otherwise localStorage briefly still shows it.
|
||||||
|
if (this._pendingDeliveries.size === 0) this._persistReliableNow();
|
||||||
async _drainInputQueues() {
|
else this._persistReliableState();
|
||||||
if (this._inputQueue.size === 0) return;
|
this._updateConnectionIndicator();
|
||||||
// Snapshot and clear
|
|
||||||
const queued = new Map(this._inputQueue);
|
|
||||||
this._inputQueue.clear();
|
|
||||||
this._updateConnectionIndicator();
|
|
||||||
|
|
||||||
for (const [sessionId, input] of queued) {
|
|
||||||
const resp = await this._apiPost(`/api/sessions/${sessionId}/input`, { input });
|
|
||||||
if (!resp?.ok) {
|
|
||||||
this._enqueueInput(sessionId, input);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._updateConnectionIndicator();
|
this.clearPendingHooks?.(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server input-ACK frame ({t:'ia',seq}) over the WebSocket. */
|
||||||
|
_onWsInputAck(seq) {
|
||||||
|
if (this._wsSessionId && Number.isInteger(seq)) this._ackDelivery(this._wsSessionId, seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from ws.onopen — flush everything pending over the fresh socket. */
|
||||||
|
_onWsReady(sessionId) {
|
||||||
|
const list = this._pendingDeliveries.get(sessionId);
|
||||||
|
if (list) for (const r of list) r.sentAt = 0; // fresh socket ⇒ re-send all
|
||||||
|
this._drainSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic retry. For the active WS session, an oldest frame unacked past the
|
||||||
|
* timeout means the socket is (half-)dead — close it to force a fast reconnect
|
||||||
|
* (onclose → reconnect → onopen → _onWsReady re-sends). Other sessions just
|
||||||
|
* (re)drain over POST.
|
||||||
|
*/
|
||||||
|
_redeliverSweep() {
|
||||||
|
if (this._pendingDeliveries.size === 0) return;
|
||||||
|
for (const sessionId of [...this._pendingDeliveries.keys()]) {
|
||||||
|
const list = this._pendingDeliveries.get(sessionId);
|
||||||
|
if (!list || list.length === 0) continue;
|
||||||
|
const isActiveWs =
|
||||||
|
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
|
||||||
|
if (isActiveWs) {
|
||||||
|
const oldest = list[0];
|
||||||
|
if (oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs) {
|
||||||
|
try {
|
||||||
|
this._ws.close(); // half-open: never recovers on its own — force reconnect
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._drainSession(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total bytes/count still awaiting ACK across all sessions (for the indicator). */
|
||||||
|
_pendingBytes() {
|
||||||
|
let bytes = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const list of this._pendingDeliveries.values()) {
|
||||||
|
for (const r of list) {
|
||||||
|
bytes += r.data.length;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bytes, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- durable persistence (localStorage; quota- and disabled-storage-safe) --
|
||||||
|
|
||||||
|
_loadReliableState() {
|
||||||
|
// Stable client identity for server-side dedup across reconnects/reloads.
|
||||||
|
try {
|
||||||
|
this._clientId = localStorage.getItem('codeman:clientId') || '';
|
||||||
|
} catch {
|
||||||
|
this._clientId = '';
|
||||||
|
}
|
||||||
|
if (!this._clientId) {
|
||||||
|
this._clientId = 'c-' + Math.random().toString(36).slice(2) + '-' + Date.now().toString(36);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('codeman:clientId', this._clientId);
|
||||||
|
} catch {
|
||||||
|
/* storage disabled — dedup degrades to per-load, still no loss */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('codeman:pendingInput');
|
||||||
|
if (!raw) return;
|
||||||
|
const saved = JSON.parse(raw);
|
||||||
|
if (saved && saved.seqs) {
|
||||||
|
for (const [s, n] of Object.entries(saved.seqs)) {
|
||||||
|
if (Number.isFinite(n)) this._seqCounters.set(s, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (saved && saved.pending) {
|
||||||
|
for (const [s, recs] of Object.entries(saved.pending)) {
|
||||||
|
if (Array.isArray(recs) && recs.length) {
|
||||||
|
// Reset sentAt so they re-deliver promptly on this fresh load.
|
||||||
|
this._pendingDeliveries.set(
|
||||||
|
s,
|
||||||
|
recs
|
||||||
|
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
|
||||||
|
.map((r) => ({
|
||||||
|
seq: r.seq,
|
||||||
|
data: r.data,
|
||||||
|
useMux: !!r.useMux,
|
||||||
|
ts: r.ts || Date.now(),
|
||||||
|
tries: 0,
|
||||||
|
sentAt: 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* corrupt/parse error — start clean rather than throw */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistReliableState() {
|
||||||
|
// Debounced — typing without local echo calls this per keystroke.
|
||||||
|
if (this._persistReliableTimer) return;
|
||||||
|
this._persistReliableTimer = setTimeout(() => {
|
||||||
|
this._persistReliableTimer = null;
|
||||||
|
this._persistReliableNow();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistReliableNow() {
|
||||||
|
if (this._persistReliableTimer) {
|
||||||
|
clearTimeout(this._persistReliableTimer);
|
||||||
|
this._persistReliableTimer = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const seqs = {};
|
||||||
|
for (const [s, n] of this._seqCounters) seqs[s] = n;
|
||||||
|
const pending = {};
|
||||||
|
let bytes = 0;
|
||||||
|
for (const [s, list] of this._pendingDeliveries) {
|
||||||
|
if (!list.length) continue;
|
||||||
|
pending[s] = list.map((r) => ({
|
||||||
|
seq: r.seq,
|
||||||
|
data: r.data,
|
||||||
|
useMux: r.useMux,
|
||||||
|
ts: r.ts,
|
||||||
|
tries: r.tries,
|
||||||
|
}));
|
||||||
|
for (const r of list) bytes += r.data.length;
|
||||||
|
}
|
||||||
|
// Bound the persisted backlog. On extreme overflow keep the seq counters
|
||||||
|
// (so future input stays monotonic and dedup-safe) but skip the payloads —
|
||||||
|
// the in-memory queue still delivers; only cross-reload durability is lost.
|
||||||
|
const payload =
|
||||||
|
bytes > this._reliableMaxBytes ? { seqs } : { seqs, pending };
|
||||||
|
localStorage.setItem('codeman:pendingInput', JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
/* QuotaExceeded or disabled storage — in-memory delivery is unaffected */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateConnectionIndicator() {
|
_updateConnectionIndicator() {
|
||||||
@@ -2143,28 +2358,27 @@ class CodemanApp {
|
|||||||
const text = this.$('connectionText');
|
const text = this.$('connectionText');
|
||||||
if (!indicator || !dot || !text) return;
|
if (!indicator || !dot || !text) return;
|
||||||
|
|
||||||
let totalBytes = 0;
|
|
||||||
for (const v of this._inputQueue.values()) totalBytes += v.length;
|
|
||||||
|
|
||||||
const status = this._connectionStatus;
|
const status = this._connectionStatus;
|
||||||
const hasQueue = totalBytes > 0;
|
|
||||||
|
|
||||||
// Connected with empty queue — hide
|
// While the connection is healthy, never surface the input queue. With the
|
||||||
if ((status === 'connected' || status === 'connecting') && !hasQueue) {
|
// reliable-delivery layer every keystroke is briefly "pending" until its ACK
|
||||||
|
// lands a few ms later — showing that flashed "Sending 1B…" on every single
|
||||||
|
// character. The indicator is only meaningful for an actual connection
|
||||||
|
// problem (reconnecting / offline), where the queued byte count reassures
|
||||||
|
// the user their typing is safely buffered and will be sent.
|
||||||
|
if (status === 'connected' || status === 'connecting') {
|
||||||
indicator.style.display = 'none';
|
indicator.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { bytes: totalBytes, count } = this._pendingBytes();
|
||||||
|
const hasQueue = count > 0;
|
||||||
indicator.style.display = 'flex';
|
indicator.style.display = 'flex';
|
||||||
dot.className = 'connection-dot';
|
dot.className = 'connection-dot';
|
||||||
|
|
||||||
const formatBytes = (b) => b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`;
|
const formatBytes = (b) => (b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`);
|
||||||
|
|
||||||
if (status === 'connected' && hasQueue) {
|
if (status === 'reconnecting') {
|
||||||
// Draining
|
|
||||||
dot.classList.add('draining');
|
|
||||||
text.textContent = `Sending ${formatBytes(totalBytes)}...`;
|
|
||||||
} else if (status === 'reconnecting') {
|
|
||||||
dot.classList.add('reconnecting');
|
dot.classList.add('reconnecting');
|
||||||
text.textContent = hasQueue ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...';
|
text.textContent = hasQueue ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...';
|
||||||
} else {
|
} else {
|
||||||
@@ -2179,6 +2393,8 @@ class CodemanApp {
|
|||||||
this.isOnline = true;
|
this.isOnline = true;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.connectSSE();
|
this.connectSSE();
|
||||||
|
// Network came back — drain durably-queued input right away.
|
||||||
|
this._redeliverSweep();
|
||||||
});
|
});
|
||||||
window.addEventListener('offline', () => {
|
window.addEventListener('offline', () => {
|
||||||
this.isOnline = false;
|
this.isOnline = false;
|
||||||
@@ -2773,18 +2989,22 @@ class CodemanApp {
|
|||||||
const minimizedCount = minimizedAgents?.size || 0;
|
const minimizedCount = minimizedAgents?.size || 0;
|
||||||
const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : '';
|
const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : '';
|
||||||
|
|
||||||
|
// Ultracode runs + agent transcripts minimized to this tab (ultracode-windows.js
|
||||||
|
// renders one merged ULTRA badge; returns '' when nothing is minimized).
|
||||||
|
const ultracodeBadge = this.renderUltracodeTabBadge ? this.renderUltracodeTabBadge(id) : '';
|
||||||
|
|
||||||
// Show folder name if session has a custom name AND tall tabs setting is enabled
|
// Show folder name if session has a custom name AND tall tabs setting is enabled
|
||||||
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
|
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
|
||||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||||
|
|
||||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, '${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
<span class="tab-info">
|
<span class="tab-info">
|
||||||
<span class="tab-name-row">
|
<span class="tab-name-row">
|
||||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : ''}
|
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : ''}
|
||||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -2792,9 +3012,10 @@ class CodemanApp {
|
|||||||
</span>
|
</span>
|
||||||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
||||||
${subagentBadge}
|
${subagentBadge}
|
||||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
${ultracodeBadge}
|
||||||
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
|
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
|
||||||
|
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||||
</div>`);
|
</div>`);
|
||||||
_tabIdx++;
|
_tabIdx++;
|
||||||
}
|
}
|
||||||
@@ -3649,6 +3870,17 @@ class CodemanApp {
|
|||||||
_crashDiag.log('FOCUS');
|
_crashDiag.log('FOCUS');
|
||||||
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
|
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
|
||||||
this.scrollToLastNonEmptyLine();
|
this.scrollToLastNonEmptyLine();
|
||||||
|
// If we switched INTO this tab while the soft keyboard is already up, no
|
||||||
|
// viewport-resize transition fires (handleViewportResize only runs
|
||||||
|
// onKeyboardShow on a hidden→visible change), so the newly-active
|
||||||
|
// session never gets that heal: fit() + scrollToBottom() + local-echo
|
||||||
|
// overlay rerender() + one-shot SIGWINCH. Without it the overlay renders
|
||||||
|
// against stale, off-bottom state and typed input stays INVISIBLE until
|
||||||
|
// the user manually toggles the keyboard. Replicate the heal here so
|
||||||
|
// local echo paints on the first keystroke after a keyboard-up tab switch.
|
||||||
|
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) {
|
||||||
|
KeyboardHandler.onKeyboardShow();
|
||||||
|
}
|
||||||
this._clearTerminalLoadState(sessionId, selectGen);
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||||
@@ -3681,7 +3913,13 @@ class CodemanApp {
|
|||||||
|
|
||||||
this._flushedOffsets?.delete(sessionId);
|
this._flushedOffsets?.delete(sessionId);
|
||||||
this._flushedTexts?.delete(sessionId);
|
this._flushedTexts?.delete(sessionId);
|
||||||
this._inputQueue.delete(sessionId);
|
// Drop any durably-queued input for a session that's actually gone (deleted/
|
||||||
|
// exited). Not a lost prompt — the target no longer exists. Only reached on
|
||||||
|
// real session removal, never on a tab switch.
|
||||||
|
this._pendingDeliveries?.delete(sessionId);
|
||||||
|
this._seqCounters?.delete(sessionId);
|
||||||
|
this._postDraining?.delete(sessionId);
|
||||||
|
this._persistReliableState();
|
||||||
this.ralphStates.delete(sessionId);
|
this.ralphStates.delete(sessionId);
|
||||||
this.ralphClosedSessions.delete(sessionId);
|
this.ralphClosedSessions.delete(sessionId);
|
||||||
this.projectInsights.delete(sessionId);
|
this.projectInsights.delete(sessionId);
|
||||||
@@ -3761,7 +3999,9 @@ class CodemanApp {
|
|||||||
? 'Kill Tmux & OpenCode'
|
? 'Kill Tmux & OpenCode'
|
||||||
: session.mode === 'codex'
|
: session.mode === 'codex'
|
||||||
? 'Kill Tmux & Codex'
|
? 'Kill Tmux & Codex'
|
||||||
: 'Kill Tmux & Claude Code';
|
: session.mode === 'gemini'
|
||||||
|
? 'Kill Tmux & Gemini'
|
||||||
|
: 'Kill Tmux & Claude Code';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('closeConfirmModal').classList.add('active');
|
document.getElementById('closeConfirmModal').classList.add('active');
|
||||||
|
|||||||
@@ -4449,6 +4449,7 @@ var GestureController = class {
|
|||||||
// packages/gesture-control/src/codeman/entry.ts
|
// packages/gesture-control/src/codeman/entry.ts
|
||||||
var TAB_SELECTOR = ".session-tab";
|
var TAB_SELECTOR = ".session-tab";
|
||||||
var PANEL_SELECTOR = ".cg-float";
|
var PANEL_SELECTOR = ".cg-float";
|
||||||
|
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
|
||||||
var DOCK_SELECTOR = ".session-tabs";
|
var DOCK_SELECTOR = ".session-tabs";
|
||||||
var CLICK_SELECTOR = "#runBtn, .btn-shell";
|
var CLICK_SELECTOR = "#runBtn, .btn-shell";
|
||||||
var Z2 = 2147483e3;
|
var Z2 = 2147483e3;
|
||||||
@@ -4476,6 +4477,8 @@ var GestureBridge = class {
|
|||||||
__publicField(this, "taps", /* @__PURE__ */ new Map());
|
__publicField(this, "taps", /* @__PURE__ */ new Map());
|
||||||
/** Live floating panels, keyed by session id (idempotent per id). */
|
/** Live floating panels, keyed by session id (idempotent per id). */
|
||||||
__publicField(this, "floats", /* @__PURE__ */ new Map());
|
__publicField(this, "floats", /* @__PURE__ */ new Map());
|
||||||
|
/** rAF coalescing for connector-line redraws while dragging an agent window. */
|
||||||
|
__publicField(this, "connectorRedrawScheduled", false);
|
||||||
injectStyles();
|
injectStyles();
|
||||||
this.surface = el("div", "cg-surface");
|
this.surface = el("div", "cg-surface");
|
||||||
this.canvas = el("canvas", "cg-canvas");
|
this.canvas = el("canvas", "cg-canvas");
|
||||||
@@ -4530,7 +4533,7 @@ var GestureBridge = class {
|
|||||||
await this.gc.start();
|
await this.gc.start();
|
||||||
this.running = true;
|
this.running = true;
|
||||||
this.button.classList.add("on");
|
this.button.classList.add("on");
|
||||||
this.status.textContent = "on \u2014 pinch a tab or button";
|
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = describeError(err);
|
const msg = describeError(err);
|
||||||
this.status.textContent = `failed: ${msg}`;
|
this.status.textContent = `failed: ${msg}`;
|
||||||
@@ -4575,6 +4578,16 @@ var GestureBridge = class {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const win = this.hitClosest(x2, y2, WINDOW_SELECTOR);
|
||||||
|
if (win) {
|
||||||
|
const rect = win.getBoundingClientRect();
|
||||||
|
win.style.bottom = "auto";
|
||||||
|
win.classList.add("cg-win-grabbed");
|
||||||
|
this.bringWindowToFront(win);
|
||||||
|
this.grabs.set(hand, { kind: "window", el: win, dx: x2 - rect.left, dy: y2 - rect.top });
|
||||||
|
this.status.textContent = "moving window";
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
|
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
|
||||||
const id = tab?.dataset.id;
|
const id = tab?.dataset.id;
|
||||||
if (tab && id) {
|
if (tab && id) {
|
||||||
@@ -4620,11 +4633,15 @@ var GestureBridge = class {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (grab?.kind === "window") {
|
||||||
|
this.moveWindow(grab.el, x2 - grab.dx, y2 - grab.dy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tap = this.taps.get(hand);
|
const tap = this.taps.get(hand);
|
||||||
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
|
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
|
||||||
tap.el.classList.remove("cg-tap-armed");
|
tap.el.classList.remove("cg-tap-armed");
|
||||||
this.taps.delete(hand);
|
this.taps.delete(hand);
|
||||||
this.status.textContent = "on \u2014 pinch a tab or button";
|
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onDrop(hand, x2, y2) {
|
onDrop(hand, x2, y2) {
|
||||||
@@ -4645,6 +4662,18 @@ var GestureBridge = class {
|
|||||||
else this.flash("placed");
|
else this.flash("placed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (grab?.kind === "window") {
|
||||||
|
this.grabs.delete(hand);
|
||||||
|
grab.el.classList.remove("cg-win-grabbed");
|
||||||
|
this.connectorRedrawScheduled = false;
|
||||||
|
this.redrawWindowConnectors();
|
||||||
|
try {
|
||||||
|
window.app?.saveSubagentWindowStates?.();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
this.flash("placed window");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tap = this.taps.get(hand);
|
const tap = this.taps.get(hand);
|
||||||
if (tap) {
|
if (tap) {
|
||||||
this.taps.delete(hand);
|
this.taps.delete(hand);
|
||||||
@@ -4694,6 +4723,54 @@ var GestureBridge = class {
|
|||||||
float.el.style.left = `${l}px`;
|
float.el.style.left = `${l}px`;
|
||||||
float.el.style.top = `${t2}px`;
|
float.el.style.top = `${t2}px`;
|
||||||
}
|
}
|
||||||
|
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
|
||||||
|
* redraw its connector line. The window self-positions via `style.left/top` and
|
||||||
|
* app.js's connector redraw reads live rects, so this tracks without touching
|
||||||
|
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
|
||||||
|
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
|
||||||
|
* which equals the *spanned* viewport in a multi-monitor window — so the window
|
||||||
|
* can still travel across the physical monitor seam, just not off-screen. */
|
||||||
|
moveWindow(el2, left, top) {
|
||||||
|
if (!el2.isConnected) return;
|
||||||
|
const w2 = el2.offsetWidth || 380;
|
||||||
|
const h2 = el2.offsetHeight || 320;
|
||||||
|
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w2 - 4));
|
||||||
|
const t2 = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h2 - 4));
|
||||||
|
el2.style.left = `${l}px`;
|
||||||
|
el2.style.top = `${t2}px`;
|
||||||
|
this.redrawWindowConnectors();
|
||||||
|
}
|
||||||
|
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
|
||||||
|
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
|
||||||
|
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
|
||||||
|
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
|
||||||
|
redrawWindowConnectors() {
|
||||||
|
if (this.connectorRedrawScheduled) return;
|
||||||
|
this.connectorRedrawScheduled = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.connectorRedrawScheduled = false;
|
||||||
|
try {
|
||||||
|
window.app?.updateConnectionLines?.();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
|
||||||
|
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
|
||||||
|
bringWindowToFront(el2) {
|
||||||
|
const app = window.app;
|
||||||
|
if (!app) return;
|
||||||
|
try {
|
||||||
|
if (el2.classList.contains("ultracode-window")) {
|
||||||
|
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1e3) + 1;
|
||||||
|
el2.style.zIndex = String(app.ultracodeWindowZIndex);
|
||||||
|
} else {
|
||||||
|
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1e3) + 1;
|
||||||
|
el2.style.zIndex = String(app.subagentWindowZIndex);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
positionGhost(ghost, x2, y2) {
|
positionGhost(ghost, x2, y2) {
|
||||||
ghost.style.left = `${x2}px`;
|
ghost.style.left = `${x2}px`;
|
||||||
ghost.style.top = `${y2}px`;
|
ghost.style.top = `${y2}px`;
|
||||||
@@ -4703,15 +4780,17 @@ var GestureBridge = class {
|
|||||||
if (grab.kind === "tab") {
|
if (grab.kind === "tab") {
|
||||||
grab.ghost.remove();
|
grab.ghost.remove();
|
||||||
grab.tab.classList.remove("cg-grabbed");
|
grab.tab.classList.remove("cg-grabbed");
|
||||||
} else {
|
} else if (grab.kind === "panel") {
|
||||||
grab.panel.el.style.pointerEvents = "";
|
grab.panel.el.style.pointerEvents = "";
|
||||||
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
|
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
|
||||||
|
} else {
|
||||||
|
grab.el.classList.remove("cg-win-grabbed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.grabs.clear();
|
this.grabs.clear();
|
||||||
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
|
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
|
||||||
this.taps.clear();
|
this.taps.clear();
|
||||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed"));
|
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
|
||||||
}
|
}
|
||||||
onStatus(fps, hands) {
|
onStatus(fps, hands) {
|
||||||
const { width, height } = this.canvas;
|
const { width, height } = this.canvas;
|
||||||
@@ -4797,6 +4876,10 @@ function injectStyles() {
|
|||||||
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
||||||
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
||||||
|
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
|
||||||
|
outline: 2px solid #4ade80 !important; outline-offset: -2px;
|
||||||
|
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
|
||||||
|
}
|
||||||
.cg-float {
|
.cg-float {
|
||||||
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
||||||
z-index: ${Z2}; display: flex; flex-direction: column; overflow: hidden;
|
z-index: ${Z2}; display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
|||||||
@@ -104,34 +104,78 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.execCommand('paste');
|
document.execCommand('paste');
|
||||||
},
|
},
|
||||||
|
|
||||||
async _uploadAndInsertImages(files) {
|
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||||
|
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||||
|
// rate limit while covering "select a bunch of photos at once".
|
||||||
|
_maxBatchImages: 20,
|
||||||
|
// How many uploads to run concurrently. Small enough that decoding several
|
||||||
|
// large images through <canvas> at once won't OOM a phone, large enough that
|
||||||
|
// 20 photos don't crawl through serially.
|
||||||
|
_uploadConcurrency: 3,
|
||||||
|
|
||||||
|
async _uploadAndInsertImages(fileList) {
|
||||||
const sessionId = this.activeSessionId;
|
const sessionId = this.activeSessionId;
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
|
let files = Array.from(fileList || []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
const paths = [];
|
// Cap the batch and tell the user what got dropped (no silent truncation).
|
||||||
for (const file of files) {
|
let capped = false;
|
||||||
try {
|
if (files.length > this._maxBatchImages) {
|
||||||
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
|
files = files.slice(0, this._maxBatchImages);
|
||||||
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
|
capped = true;
|
||||||
// MIME claim "image/jpeg", which passes the server's extension allowlist
|
}
|
||||||
// but fails its magic-byte check ("bytes do not match declared type").
|
|
||||||
// Decoding through the browser and re-encoding guarantees the bytes
|
const total = files.length;
|
||||||
// match the extension we send.
|
let done = 0;
|
||||||
const normalized = await this._normalizeImageForUpload(file);
|
let failed = 0;
|
||||||
const path = await this._uploadPasteImage(sessionId, normalized);
|
const results = new Array(total); // preserve selection order for insertion
|
||||||
paths.push(path);
|
const progress = () =>
|
||||||
} catch (err) {
|
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||||
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
progress();
|
||||||
|
|
||||||
|
// Bounded-concurrency worker pool over the file list.
|
||||||
|
let next = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
for (;;) {
|
||||||
|
const i = next++;
|
||||||
|
if (i >= total) return;
|
||||||
|
try {
|
||||||
|
// Re-encode to a standard JPEG/PNG (and downscale very large images)
|
||||||
|
// before upload. Galleries on some phones (notably Android/MIUI) hand
|
||||||
|
// back a WebP/HEIF whose filename and MIME claim "image/jpeg", which
|
||||||
|
// passes the server's extension allowlist but fails its magic-byte
|
||||||
|
// check. Decoding through the browser and re-encoding guarantees the
|
||||||
|
// bytes match the extension we send — and shrinks huge photos so they
|
||||||
|
// fit the upload limit and iOS's <canvas> area cap.
|
||||||
|
const normalized = await this._normalizeImageForUpload(files[i]);
|
||||||
|
results[i] = await this._uploadPasteImage(sessionId, normalized);
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
console.warn('Image upload failed:', err);
|
||||||
|
results[i] = null;
|
||||||
|
} finally {
|
||||||
|
done++;
|
||||||
|
if (done < total) progress();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
||||||
|
|
||||||
|
const paths = results.filter(Boolean);
|
||||||
|
if (paths.length > 0) {
|
||||||
|
// Insert all paths in one shot, space-separated, in selection order.
|
||||||
|
await this.sendInput(paths.join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paths.length > 0) {
|
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||||
const pathStr = paths.join(' ');
|
const parts = [];
|
||||||
await this.sendInput(pathStr);
|
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||||
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
|
if (failed > 0) parts.push(`${failed} failed`);
|
||||||
}
|
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||||
|
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||||
|
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||||
},
|
},
|
||||||
|
|
||||||
async _uploadPasteImage(sessionId, file) {
|
async _uploadPasteImage(sessionId, file) {
|
||||||
@@ -176,12 +220,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const height = img.naturalHeight;
|
const height = img.naturalHeight;
|
||||||
if (!width || !height) return file;
|
if (!width || !height) return file;
|
||||||
|
|
||||||
|
// Downscale very large images. Two reasons: (1) iOS Safari refuses to
|
||||||
|
// render a <canvas> larger than ~16.7M px (it returns a blank/null
|
||||||
|
// blob), so a 48MP photo would otherwise fail to re-encode and fall back
|
||||||
|
// to the original — which then trips the server's magic-byte check for
|
||||||
|
// HEIF mislabeled as JPEG. (2) It keeps multi-photo uploads fast and well
|
||||||
|
// under the size limit. Cap the longest edge so area stays safely below
|
||||||
|
// the canvas limit while still uploading a large, high-quality image.
|
||||||
|
const MAX_EDGE = 4096;
|
||||||
|
const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
|
||||||
|
const w = Math.max(1, Math.round(width * scale));
|
||||||
|
const h = Math.max(1, Math.round(height * scale));
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = w;
|
||||||
canvas.height = height;
|
canvas.height = h;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return file;
|
if (!ctx) return file;
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
|
||||||
const mime = toPng ? 'image/png' : 'image/jpeg';
|
const mime = toPng ? 'image/png' : 'image/jpeg';
|
||||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
|
<button class="btn-icon-header btn-away-digest" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
||||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||||
@@ -305,13 +306,56 @@
|
|||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
Run OpenCode
|
Run OpenCode
|
||||||
</button>
|
</button>
|
||||||
|
<button class="welcome-btn welcome-btn-gemini" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
|
Run Gemini
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||||
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
|
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="history-sessions" id="historySessions" style="display:none">
|
<div class="history-sessions" id="historySessions" style="display:none">
|
||||||
<h3 class="history-title">Resume Conversation</h3>
|
<div class="search-panel" id="searchPanel">
|
||||||
|
<div class="search-input-row">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="searchInput"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search sessions, events, files…"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
maxlength="200"
|
||||||
|
aria-label="Search across sessions"
|
||||||
|
/>
|
||||||
|
<button type="button" id="searchClearBtn" class="search-clear-btn" aria-label="Clear search" hidden>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-filters" id="searchFilters">
|
||||||
|
<div class="search-filter-group" role="group" aria-label="Source type filter">
|
||||||
|
<button type="button" class="search-filter-chip active" data-type-filter="session">Sessions</button>
|
||||||
|
<button type="button" class="search-filter-chip active" data-type-filter="event">Events</button>
|
||||||
|
<button type="button" class="search-filter-chip active" data-type-filter="file">Files</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-filter-group search-filter-secondary">
|
||||||
|
<select id="searchCaseFilter" class="search-select" aria-label="Filter by case">
|
||||||
|
<option value="">All cases</option>
|
||||||
|
</select>
|
||||||
|
<select id="searchStatusFilter" class="search-select" aria-label="Filter by session status">
|
||||||
|
<option value="">Any status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="history">History</option>
|
||||||
|
</select>
|
||||||
|
<select id="searchDateFilter" class="search-select" aria-label="Filter by date range">
|
||||||
|
<option value="">Any time</option>
|
||||||
|
<option value="1">Past 24h</option>
|
||||||
|
<option value="7">Past 7 days</option>
|
||||||
|
<option value="30">Past 30 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-results" id="searchResults" hidden></div>
|
||||||
|
</div>
|
||||||
|
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
||||||
<div class="history-list" id="historyList"></div>
|
<div class="history-list" id="historyList"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
|
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
|
||||||
@@ -400,6 +444,9 @@
|
|||||||
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
|
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
|
||||||
<span class="run-mode-dot codex"></span>Codex
|
<span class="run-mode-dot codex"></span>Codex
|
||||||
</button>
|
</button>
|
||||||
|
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
|
||||||
|
<span class="run-mode-dot gemini"></span>Gemini
|
||||||
|
</button>
|
||||||
<div class="run-mode-sep"></div>
|
<div class="run-mode-sep"></div>
|
||||||
<div class="run-mode-header">Recent Sessions</div>
|
<div class="run-mode-header">Recent Sessions</div>
|
||||||
<div class="run-mode-history" id="runModeHistory"></div>
|
<div class="run-mode-history" id="runModeHistory"></div>
|
||||||
@@ -1863,6 +1910,44 @@
|
|||||||
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
|
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Away Digest Modal -->
|
||||||
|
<div class="modal" id="awayDigestModal">
|
||||||
|
<div class="modal-backdrop" onclick="app.closeAwayDigest()"></div>
|
||||||
|
<div class="modal-content away-digest-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Away Digest</h3>
|
||||||
|
<div class="modal-header-actions">
|
||||||
|
<button class="btn-toolbar btn-sm" onclick="app.loadAwayDigest()" title="Refresh away digest">↻ Refresh</button>
|
||||||
|
<button class="modal-close" onclick="app.closeAwayDigest()" aria-label="Close away digest">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="away-digest-ranges" role="group" aria-label="Away digest range">
|
||||||
|
<button class="filter-btn active" data-away-range="since-last-visit" onclick="app.setAwayDigestRange('since-last-visit')">Since last visit</button>
|
||||||
|
<button class="filter-btn" data-away-range="1h" onclick="app.setAwayDigestRange('1h')">Last hour</button>
|
||||||
|
<button class="filter-btn" data-away-range="today" onclick="app.setAwayDigestRange('today')">Today</button>
|
||||||
|
<button class="filter-btn" data-away-range="24h" onclick="app.setAwayDigestRange('24h')">24h</button>
|
||||||
|
<button class="filter-btn" data-away-range="custom" onclick="app.setAwayDigestRange('custom')">Custom</button>
|
||||||
|
</div>
|
||||||
|
<div class="away-digest-custom-range" id="awayDigestCustomRange">
|
||||||
|
<label>
|
||||||
|
Since
|
||||||
|
<input type="datetime-local" id="awayDigestCustomSince">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Until
|
||||||
|
<input type="datetime-local" id="awayDigestCustomUntil">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="away-digest-summary" id="awayDigestSummary"></div>
|
||||||
|
<div class="away-digest-freshness" id="awayDigestFreshness"></div>
|
||||||
|
<div class="away-digest-sections" id="awayDigestSections">
|
||||||
|
<p class="empty-message">Open the digest to load recent activity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Token Stats Modal -->
|
<!-- Token Stats Modal -->
|
||||||
<div class="modal" id="tokenStatsModal">
|
<div class="modal" id="tokenStatsModal">
|
||||||
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ const KeyboardAccessoryBar = {
|
|||||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||||
<button class="accessory-btn" data-action="init" title="/init">/init</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="clear" title="/clear">/clear</button>
|
||||||
|
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
<path d="M19 9l-7 7-7-7"/>
|
<path d="M19 9l-7 7-7-7"/>
|
||||||
|
|||||||
@@ -434,11 +434,14 @@ html.mobile-init .file-browser-panel {
|
|||||||
height: 12px;
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar.
|
/* Hide header settings gear, lifecycle log, and away digest on mobile - settings
|
||||||
|
moved to toolbar; away digest is a secondary informational control that doesn't
|
||||||
|
belong on the cramped phone header.
|
||||||
(The attachments button is opt-in / default-hidden everywhere via its own
|
(The attachments button is opt-in / default-hidden everywhere via its own
|
||||||
--hidden marker, so it needs no mobile-specific rule here.) */
|
--hidden marker, so it needs no mobile-specific rule here.) */
|
||||||
.btn-icon-header.btn-settings,
|
.btn-icon-header.btn-settings,
|
||||||
.btn-icon-header.btn-lifecycle-log {
|
.btn-icon-header.btn-lifecycle-log,
|
||||||
|
.btn-icon-header.btn-away-digest {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,6 +780,20 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-color: rgba(16, 185, 129, 0.5);
|
border-color: rgba(16, 185, 129, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gemini mode colors on mobile */
|
||||||
|
.btn-toolbar.btn-run.mode-gemini,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-gemini {
|
||||||
|
background: #10243f;
|
||||||
|
border-color: rgba(96, 165, 250, 0.3);
|
||||||
|
color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toolbar.btn-run.mode-gemini:active,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-gemini:active {
|
||||||
|
background: #174ea6;
|
||||||
|
border-color: rgba(96, 165, 250, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||||
.run-mode-menu {
|
.run-mode-menu {
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
@@ -1163,6 +1180,44 @@ html.mobile-init .file-browser-panel {
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.away-digest-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-ranges {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-ranges .filter-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-custom-range,
|
||||||
|
.away-digest-custom-range.active {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-action {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-footer,
|
.modal-footer,
|
||||||
.form-actions {
|
.form-actions {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ class NotificationManager {
|
|||||||
const readClass = n.read ? '' : ' unread';
|
const readClass = n.read ? '' : ' unread';
|
||||||
const countLabel = n.count > 1 ? `<span class="notif-item-count">×${n.count}</span>` : '';
|
const countLabel = n.count > 1 ? `<span class="notif-item-count">×${n.count}</span>` : '';
|
||||||
const sessionChip = n.sessionName ? `<span class="notif-item-session">${escapeHtml(n.sessionName)}</span>` : '';
|
const sessionChip = n.sessionName ? `<span class="notif-item-session">${escapeHtml(n.sessionName)}</span>` : '';
|
||||||
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${escapeHtml(n.id)}')">
|
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification(${escapeHtml(JSON.stringify(n.id))})">
|
||||||
<div class="notif-item-header">
|
<div class="notif-item-header">
|
||||||
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
|
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
|
||||||
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
||||||
|
|||||||
@@ -392,10 +392,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
let actions = '';
|
let actions = '';
|
||||||
if (orchState === 'executing' || orchState === 'failed') {
|
if (orchState === 'executing' || orchState === 'failed') {
|
||||||
if (phase.status === 'pending') {
|
if (phase.status === 'pending') {
|
||||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
|
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Skip">skip</button>`;
|
||||||
}
|
}
|
||||||
if (phase.status === 'failed') {
|
if (phase.status === 'failed') {
|
||||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
|
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Retry">retry</button>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+291
-17
@@ -13,6 +13,15 @@
|
|||||||
* @loadorder 11 of 15 — loaded after settings-ui.js, before session-ui.js
|
* @loadorder 11 of 15 — loaded after settings-ui.js, before session-ui.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
|
||||||
|
const AWAY_DIGEST_SECTIONS = [
|
||||||
|
['needsAttention', 'Needs Attention'],
|
||||||
|
['completed', 'Completed'],
|
||||||
|
['stillRunning', 'Still Running'],
|
||||||
|
['idle', 'Idle'],
|
||||||
|
['informational', 'Informational'],
|
||||||
|
];
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
_addActivityEntry(agentId, entry, maxSize = 50) {
|
_addActivityEntry(agentId, entry, maxSize = 50) {
|
||||||
const activity = this.subagentActivity.get(agentId) || [];
|
const activity = this.subagentActivity.get(agentId) || [];
|
||||||
@@ -242,6 +251,271 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Away Digest Modal
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async openAwayDigest(range = 'since-last-visit') {
|
||||||
|
this.awayDigestRange = range;
|
||||||
|
this._awayDigestLoadedSuccessfully = false;
|
||||||
|
this._awayDigestSinceLastVisitGeneratedAt = undefined;
|
||||||
|
const modal = document.getElementById('awayDigestModal');
|
||||||
|
if (modal) modal.classList.add('active');
|
||||||
|
this.updateAwayDigestRangeControls();
|
||||||
|
await this.loadAwayDigest();
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAwayDigest() {
|
||||||
|
const modal = document.getElementById('awayDigestModal');
|
||||||
|
const generatedAt = this._awayDigestSinceLastVisitGeneratedAt;
|
||||||
|
if (Number.isFinite(generatedAt)) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AWAY_DIGEST_LAST_VIEWED_KEY, String(generatedAt));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to save away digest last-viewed marker:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (modal) modal.classList.remove('active');
|
||||||
|
},
|
||||||
|
|
||||||
|
setAwayDigestRange(range) {
|
||||||
|
this.awayDigestRange = range;
|
||||||
|
this._awayDigestLoadedSuccessfully = false;
|
||||||
|
this.updateAwayDigestRangeControls();
|
||||||
|
this.loadAwayDigest();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAwayDigestRangeControls() {
|
||||||
|
const range = this.awayDigestRange || 'since-last-visit';
|
||||||
|
document.querySelectorAll('[data-away-range]').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.awayRange === range);
|
||||||
|
});
|
||||||
|
const customRange = document.getElementById('awayDigestCustomRange');
|
||||||
|
if (customRange) customRange.classList.toggle('active', range === 'custom');
|
||||||
|
if (range === 'custom') this.ensureAwayDigestCustomDefaults();
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureAwayDigestCustomDefaults() {
|
||||||
|
const sinceInput = document.getElementById('awayDigestCustomSince');
|
||||||
|
const untilInput = document.getElementById('awayDigestCustomUntil');
|
||||||
|
if (!sinceInput || !untilInput) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (!untilInput.value) untilInput.value = this.formatAwayDigestDateTimeLocal(now);
|
||||||
|
if (!sinceInput.value) {
|
||||||
|
const since = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
sinceInput.value = this.formatAwayDigestDateTimeLocal(since);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatAwayDigestDateTimeLocal(date) {
|
||||||
|
const pad = value => String(value).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAwayDigest() {
|
||||||
|
const summaryEl = document.getElementById('awayDigestSummary');
|
||||||
|
const freshnessEl = document.getElementById('awayDigestFreshness');
|
||||||
|
const sectionsEl = document.getElementById('awayDigestSections');
|
||||||
|
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-loading">Loading digest...</div>';
|
||||||
|
if (freshnessEl) freshnessEl.textContent = '';
|
||||||
|
if (sectionsEl) sectionsEl.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const range = this.awayDigestRange || 'since-last-visit';
|
||||||
|
const params = new URLSearchParams({ range });
|
||||||
|
|
||||||
|
if (range === 'since-last-visit') {
|
||||||
|
const lastViewed = this.readAwayDigestLastViewed();
|
||||||
|
if (Number.isFinite(lastViewed)) params.set('lastViewed', String(lastViewed));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range === 'custom') {
|
||||||
|
this.ensureAwayDigestCustomDefaults();
|
||||||
|
const since = this.readAwayDigestDateTimeInput('awayDigestCustomSince');
|
||||||
|
const until = this.readAwayDigestDateTimeInput('awayDigestCustomUntil');
|
||||||
|
if (!Number.isFinite(since)) {
|
||||||
|
throw new Error('Choose a custom start time');
|
||||||
|
}
|
||||||
|
params.set('since', String(since));
|
||||||
|
if (Number.isFinite(until)) params.set('until', String(until));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/away-digest?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.error || 'Failed to load away digest');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._awayDigestLoadedSuccessfully = true;
|
||||||
|
this._awayDigestGeneratedAt = data.digest.generatedAt;
|
||||||
|
if (range === 'since-last-visit') {
|
||||||
|
this._awayDigestSinceLastVisitGeneratedAt = data.digest.generatedAt;
|
||||||
|
}
|
||||||
|
this.renderAwayDigest(data.digest);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to load away digest';
|
||||||
|
console.error('Failed to fetch away digest:', err);
|
||||||
|
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-load-error">Failed to load away digest</div>';
|
||||||
|
if (sectionsEl) {
|
||||||
|
sectionsEl.innerHTML = `<div class="empty-message">${escapeHtml(message)}</div>`;
|
||||||
|
}
|
||||||
|
this.showToast(message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
readAwayDigestLastViewed() {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(AWAY_DIGEST_LAST_VIEWED_KEY);
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
readAwayDigestDateTimeInput(id) {
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
if (!input || !input.value) return undefined;
|
||||||
|
const parsed = Date.parse(input.value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAwayDigest(digest) {
|
||||||
|
const summaryEl = document.getElementById('awayDigestSummary');
|
||||||
|
const freshnessEl = document.getElementById('awayDigestFreshness');
|
||||||
|
const sectionsEl = document.getElementById('awayDigestSections');
|
||||||
|
if (!summaryEl || !freshnessEl || !sectionsEl) return;
|
||||||
|
|
||||||
|
const inputTokens = digest.totals.inputTokens || 0;
|
||||||
|
const outputTokens = digest.totals.outputTokens || 0;
|
||||||
|
const estimatedCost = digest.totals.estimatedCost || 0;
|
||||||
|
summaryEl.innerHTML = `
|
||||||
|
<div class="away-digest-card">
|
||||||
|
<span class="away-digest-card-label">Needs Attention</span>
|
||||||
|
<span class="away-digest-card-value">${digest.totals.needsAttention}</span>
|
||||||
|
</div>
|
||||||
|
<div class="away-digest-card">
|
||||||
|
<span class="away-digest-card-label">Completed</span>
|
||||||
|
<span class="away-digest-card-value">${digest.totals.completed}</span>
|
||||||
|
</div>
|
||||||
|
<div class="away-digest-card">
|
||||||
|
<span class="away-digest-card-label">Active Sessions</span>
|
||||||
|
<span class="away-digest-card-value">${digest.totals.activeSessions}</span>
|
||||||
|
</div>
|
||||||
|
<div class="away-digest-card">
|
||||||
|
<span class="away-digest-card-label">Tokens</span>
|
||||||
|
<span class="away-digest-card-value">${this.formatTokens(inputTokens + outputTokens)}</span>
|
||||||
|
<span class="away-digest-card-cost">~$${estimatedCost.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const freshnessNotes = [];
|
||||||
|
if (digest.dataFreshness.runSummariesLiveOnly || digest.dataFreshness.subagentsLiveOnly) {
|
||||||
|
freshnessNotes.push('Run summaries and subagent completions use recent live state; lifecycle and token stats are persisted.');
|
||||||
|
}
|
||||||
|
if (digest.totals.tokenWindowPrecision === 'day') {
|
||||||
|
freshnessNotes.push('Token totals are aggregated at day precision.');
|
||||||
|
}
|
||||||
|
freshnessEl.textContent = freshnessNotes.join(' ');
|
||||||
|
|
||||||
|
sectionsEl.innerHTML = AWAY_DIGEST_SECTIONS
|
||||||
|
.map(([key, title]) => this.renderAwayDigestSection(title, digest.sections[key] || []))
|
||||||
|
.join('');
|
||||||
|
this.attachAwayDigestActions();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAwayDigestSection(title, items) {
|
||||||
|
const count = items.length;
|
||||||
|
const body = count
|
||||||
|
? items.map(item => this.renderAwayDigestItem(item)).join('')
|
||||||
|
: '<div class="away-digest-empty">No items</div>';
|
||||||
|
return `
|
||||||
|
<section class="away-digest-section">
|
||||||
|
<div class="away-digest-section-title">
|
||||||
|
<h4>${escapeHtml(title)}</h4>
|
||||||
|
<span>${count}</span>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAwayDigestItem(item) {
|
||||||
|
const sourceLabel = this.formatAwayDigestSource(item.source);
|
||||||
|
const sessionLabel = item.sessionName || item.sessionId || '';
|
||||||
|
const detail = item.detail ? `<div class="away-digest-item-detail">${escapeHtml(item.detail)}</div>` : '';
|
||||||
|
const action = item.link ? `
|
||||||
|
<button class="away-digest-action"
|
||||||
|
data-away-link-type="${escapeHtml(item.link.type)}"
|
||||||
|
data-away-session-id="${escapeHtml(item.link.sessionId || '')}">
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
` : '';
|
||||||
|
return `
|
||||||
|
<article class="away-digest-item away-digest-${escapeHtml(item.severity)}">
|
||||||
|
<div class="away-digest-item-main">
|
||||||
|
<div class="away-digest-item-meta">
|
||||||
|
<span>${escapeHtml(this.formatAwayDigestTimestamp(item.timestamp))}</span>
|
||||||
|
<span>${escapeHtml(sourceLabel)}</span>
|
||||||
|
${sessionLabel ? `<span>${escapeHtml(sessionLabel)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="away-digest-item-title">${escapeHtml(item.title)}</div>
|
||||||
|
${detail}
|
||||||
|
</div>
|
||||||
|
${action}
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
attachAwayDigestActions() {
|
||||||
|
const sectionsEl = document.getElementById('awayDigestSections');
|
||||||
|
if (!sectionsEl) return;
|
||||||
|
sectionsEl.querySelectorAll('[data-away-link-type]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
this.openAwayDigestItem(button.dataset.awayLinkType, button.dataset.awaySessionId || undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async openAwayDigestItem(type, sessionId) {
|
||||||
|
if (type === 'session' && sessionId) {
|
||||||
|
await this.selectSession(sessionId);
|
||||||
|
this.closeAwayDigest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'run_summary' && sessionId) {
|
||||||
|
await this.openRunSummary(sessionId);
|
||||||
|
this.closeAwayDigest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'lifecycle') {
|
||||||
|
this.openLifecycleLog();
|
||||||
|
this.closeAwayDigest();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatAwayDigestTimestamp(timestamp) {
|
||||||
|
if (!Number.isFinite(timestamp)) return '';
|
||||||
|
return new Date(timestamp).toLocaleString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
formatAwayDigestSource(source) {
|
||||||
|
const labels = {
|
||||||
|
lifecycle: 'Lifecycle',
|
||||||
|
run_summary: 'Run Summary',
|
||||||
|
status: 'Status',
|
||||||
|
token_stats: 'Token Stats',
|
||||||
|
subagent: 'Subagent',
|
||||||
|
};
|
||||||
|
return labels[source] || source;
|
||||||
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Token Statistics Modal
|
// Token Statistics Modal
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -753,8 +1027,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
|
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
|
||||||
html.push(`
|
html.push(`
|
||||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
|
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
|
||||||
onclick="app.selectSubagent('${escapeHtml(agent.agentId)}')"
|
onclick="app.selectSubagent(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||||
ondblclick="app.openSubagentWindow('${escapeHtml(agent.agentId)}')"
|
ondblclick="app.openSubagentWindow(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||||
title="Double-click to open tracking window">
|
title="Double-click to open tracking window">
|
||||||
<div class="subagent-header">
|
<div class="subagent-header">
|
||||||
${agentIcon}
|
${agentIcon}
|
||||||
@@ -762,8 +1036,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
${teammateBadge}
|
${teammateBadge}
|
||||||
${modelBadge}
|
${modelBadge}
|
||||||
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
||||||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">✕</button>` : ''}
|
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">✕</button>` : ''}
|
||||||
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${escapeHtml(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}(${escapeHtml(JSON.stringify(agent.agentId))})" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
||||||
${hasWindow ? '✕' : '⧉'}
|
${hasWindow ? '✕' : '⧉'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -810,7 +1084,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
||||||
<span class="name">${escapeHtml(a.tool)}</span>
|
<span class="name">${escapeHtml(a.tool)}</span>
|
||||||
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
|
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
|
||||||
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
|
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams(${escapeHtml(JSON.stringify(a.toolUseId))})">▶</button>` : ''}
|
||||||
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
|
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (a.type === 'tool_result') {
|
} else if (a.type === 'tool_result') {
|
||||||
@@ -859,7 +1133,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
|
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
|
||||||
${modelBadge}
|
${modelBadge}
|
||||||
<span class="subagent-status ${agent.status}">${agent.status}</span>
|
<span class="subagent-status ${agent.status}">${agent.status}</span>
|
||||||
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${escapeHtml(agent.agentId)}')">
|
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript(${escapeHtml(JSON.stringify(agent.agentId))})">
|
||||||
View Full Transcript
|
View Full Transcript
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1195,7 +1469,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
parentDiv.dataset.parentSession = parentSessionId;
|
parentDiv.dataset.parentSession = parentSessionId;
|
||||||
parentDiv.innerHTML = `
|
parentDiv.innerHTML = `
|
||||||
<span class="parent-label">from</span>
|
<span class="parent-label">from</span>
|
||||||
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentName)}</span>
|
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentName)}</span>
|
||||||
`;
|
`;
|
||||||
header.insertAdjacentElement('afterend', parentDiv);
|
header.insertAdjacentElement('afterend', parentDiv);
|
||||||
}
|
}
|
||||||
@@ -1687,7 +1961,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="status running">terminal</span>
|
<span class="status running">terminal</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="subagent-window-actions">
|
<div class="subagent-window-actions">
|
||||||
<button onclick="app.closeSubagentWindow('${escapeHtml(windowId)}')" title="Minimize to tab">─</button>
|
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(windowId))})" title="Minimize to tab">─</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
|
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
|
||||||
@@ -2200,7 +2474,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const fileName = path.split('/').pop();
|
const fileName = path.split('/').pop();
|
||||||
html.push(`
|
html.push(`
|
||||||
<span class="project-insight-filepath"
|
<span class="project-insight-filepath"
|
||||||
onclick="app.openLogViewerWindow('${escapeHtml(path)}', '${escapeHtml(tool.sessionId)}')"
|
onclick="app.openLogViewerWindow(${escapeHtml(JSON.stringify(path))}, ${escapeHtml(JSON.stringify(tool.sessionId))})"
|
||||||
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
|
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -3099,7 +3373,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="status streaming">streaming</span>
|
<span class="status streaming">streaming</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-viewer-window-actions">
|
<div class="log-viewer-window-actions">
|
||||||
<button onclick="app.closeLogViewerWindow('${escapeHtml(windowId)}')" title="Close">×</button>
|
<button onclick="app.closeLogViewerWindow(${escapeHtml(JSON.stringify(windowId))})" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
|
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
|
||||||
@@ -3275,14 +3549,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="size-badge">${sizeKB} KB</span>
|
<span class="size-badge">${sizeKB} KB</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-popup-actions">
|
<div class="image-popup-actions">
|
||||||
<button onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
|
<button onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" title="Open in new tab">↗</button>
|
||||||
<button onclick="app.closeImagePopup('${escapeHtml(imageId)}')" title="Close">×</button>
|
<button onclick="app.closeImagePopup(${escapeHtml(JSON.stringify(imageId))})" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-popup-body">
|
<div class="image-popup-body">
|
||||||
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
|
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
|
||||||
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
|
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
|
||||||
onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" />
|
onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" />
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -3505,9 +3779,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
|
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sid = escapeHtml(muxSession.sessionId);
|
const sid = escapeHtml(JSON.stringify(muxSession.sessionId));
|
||||||
html += `
|
html += `
|
||||||
<div class="process-item process-item-clickable" onclick="app.selectSession('${sid}')" title="Switch to session">
|
<div class="process-item process-item-clickable" onclick="app.selectSession(${sid})" title="Switch to session">
|
||||||
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
|
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
|
||||||
<div class="process-info">
|
<div class="process-info">
|
||||||
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
||||||
@@ -3520,7 +3794,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="process-actions">
|
<div class="process-actions">
|
||||||
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession('${sid}')" title="Kill session">Kill</button>
|
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession(${sid})" title="Kill session">Kill</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -3563,7 +3837,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="process-actions">
|
<div class="process-actions">
|
||||||
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">Kill</button>` : ''}
|
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">Kill</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode),
|
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini),
|
||||||
* session options modal (per-session settings, color picker, rename),
|
* session options modal (per-session settings, color picker, rename),
|
||||||
* session options tabs (Ralph config tab), case settings (CRUD, links),
|
* session options tabs (Ralph config tab), case settings (CRUD, links),
|
||||||
* create case modal, and mobile case picker.
|
* create case modal, and mobile case picker.
|
||||||
@@ -151,7 +151,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return this.run();
|
return this.run();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Run using the selected mode (Claude Code, OpenCode, or Codex) */
|
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
|
||||||
async run() {
|
async run() {
|
||||||
const mode = this._runMode || 'claude';
|
const mode = this._runMode || 'claude';
|
||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
@@ -160,6 +160,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (mode === 'codex') {
|
if (mode === 'codex') {
|
||||||
return this.runCodex();
|
return this.runCodex();
|
||||||
}
|
}
|
||||||
|
if (mode === 'gemini') {
|
||||||
|
return this.runGemini();
|
||||||
|
}
|
||||||
return this.runClaude();
|
return this.runClaude();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -257,7 +260,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||||
}
|
}
|
||||||
if (label) {
|
if (label) {
|
||||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : 'Run';
|
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : 'Run';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -640,6 +643,47 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async runGemini() {
|
||||||
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
|
|
||||||
|
this.terminal.clear();
|
||||||
|
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
|
||||||
|
this.terminal.writeln('');
|
||||||
|
this.terminal.focus();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusRes = await fetch('/api/gemini/status');
|
||||||
|
const status = (await statusRes.json()).data;
|
||||||
|
if (!status.available) {
|
||||||
|
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
||||||
|
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||||
|
const res = await fetch('/api/quick-start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
caseName,
|
||||||
|
mode: 'gemini',
|
||||||
|
geminiConfig: { approvalMode: 'yolo' },
|
||||||
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || 'Failed to start Gemini');
|
||||||
|
|
||||||
|
if (data.data.sessionId) {
|
||||||
|
await this.selectSession(data.data.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.terminal.focus();
|
||||||
|
} catch (err) {
|
||||||
|
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Session Options Modal
|
// Session Options Modal
|
||||||
@@ -651,8 +695,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.editingSessionId = sessionId;
|
this.editingSessionId = sessionId;
|
||||||
|
|
||||||
// Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only)
|
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||||
this.switchOptionsTab(session.mode === 'opencode' || session.mode === 'codex' ? 'summary' : 'respawn');
|
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
|
||||||
|
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||||
|
|
||||||
// Update respawn status display and buttons
|
// Update respawn status display and buttons
|
||||||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||||||
@@ -680,10 +725,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
respawnSection.style.display = 'none';
|
respawnSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Claude-specific options for OpenCode sessions
|
// Hide Claude-specific options for external CLI sessions
|
||||||
const isOpenCode = session.mode === 'opencode' || session.mode === 'codex';
|
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
|
||||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||||
claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
|
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||||
|
|
||||||
// Reset duration presets to default (unlimited)
|
// Reset duration presets to default (unlimited)
|
||||||
this.selectDurationPreset('');
|
this.selectDurationPreset('');
|
||||||
@@ -731,26 +776,28 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('respawnPresetSelect').value = '';
|
document.getElementById('respawnPresetSelect').value = '';
|
||||||
document.getElementById('presetDescriptionHint').textContent = '';
|
document.getElementById('presetDescriptionHint').textContent = '';
|
||||||
|
|
||||||
// Hide Ralph/Todo tab and Respawn tab for opencode sessions (not supported)
|
// Hide Ralph/Todo tab and Respawn tab for external CLI sessions (not supported)
|
||||||
const ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]');
|
const ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]');
|
||||||
const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]');
|
const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]');
|
||||||
if (isOpenCode) {
|
if (isExternalCli) {
|
||||||
if (ralphTabBtn) ralphTabBtn.style.display = 'none';
|
if (ralphTabBtn) ralphTabBtn.style.display = 'none';
|
||||||
if (respawnTabBtn) respawnTabBtn.style.display = 'none';
|
if (respawnTabBtn) respawnTabBtn.style.display = 'none';
|
||||||
// Default to Context tab for opencode sessions since Respawn is hidden
|
// Default to Context tab for external CLI sessions since Respawn is hidden
|
||||||
this.switchOptionsTab('context');
|
this.switchOptionsTab('context');
|
||||||
} else {
|
} else {
|
||||||
if (ralphTabBtn) ralphTabBtn.style.display = '';
|
if (ralphTabBtn) ralphTabBtn.style.display = '';
|
||||||
if (respawnTabBtn) respawnTabBtn.style.display = '';
|
if (respawnTabBtn) respawnTabBtn.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate Ralph Wiggum form with current session values (skip for opencode)
|
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
|
||||||
if (!isOpenCode) {
|
if (!isExternalCli) {
|
||||||
const ralphState = this.ralphStates.get(sessionId);
|
const ralphState = this.ralphStates.get(sessionId);
|
||||||
this.populateRalphForm({
|
this.populateRalphForm({
|
||||||
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
|
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
|
||||||
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
|
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
|
||||||
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
|
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
|
||||||
|
maxTodos: ralphState?.loop?.maxTodos || session.ralphLoop?.maxTodos,
|
||||||
|
todoExpirationMinutes: ralphState?.loop?.todoExpirationMinutes || session.ralphLoop?.todoExpirationMinutes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1385,11 +1432,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="case-manage-actions">
|
<div class="case-manage-actions">
|
||||||
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
|
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||||
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
|
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
||||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
|
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="Delete case">✕</button>
|
title="Delete case">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1484,14 +1531,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const isSelected = c.name === currentCase;
|
const isSelected = c.name === currentCase;
|
||||||
html += `
|
html += `
|
||||||
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
|
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
|
||||||
onclick="app.selectMobileCase('${escapeHtml(c.name)}')">
|
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
|
||||||
<span class="mobile-case-item-icon">
|
<span class="mobile-case-item-icon">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
|
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
|
||||||
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile('${escapeHtml(c.name)}')" title="Delete">
|
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -1579,6 +1626,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
|||||||
return this._runMode || 'claude';
|
return this._runMode || 'claude';
|
||||||
},
|
},
|
||||||
set(mode) {
|
set(mode) {
|
||||||
this._runMode = mode === 'opencode' || mode === 'codex' || mode === 'claude' ? mode : 'claude';
|
this._runMode =
|
||||||
|
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'claude' ? mode : 'claude';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+592
-7
@@ -1178,6 +1178,11 @@ body.solo-mode .btn-lifecycle-log {
|
|||||||
color: #a855f7;
|
color: #a855f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-tab .tab-mode.gemini {
|
||||||
|
background: rgba(138, 180, 248, 0.2);
|
||||||
|
color: #8ab4f8;
|
||||||
|
}
|
||||||
|
|
||||||
/* Timer Banner - Compact */
|
/* Timer Banner - Compact */
|
||||||
.timer-banner {
|
.timer-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2362,6 +2367,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.welcome-btn-gemini {
|
||||||
|
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
|
||||||
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
|
color: #dbeafe;
|
||||||
|
box-shadow: 0 2px 8px rgba(96, 165, 250, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-btn-gemini:hover {
|
||||||
|
background: linear-gradient(135deg, #17345f 0%, #2563eb 55%, #6366f1 100%);
|
||||||
|
box-shadow: 0 4px 20px rgba(96, 165, 250, 0.3), 0 0 40px rgba(99, 102, 241, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(147, 197, 253, 0.5);
|
||||||
|
color: #eff6ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-btn-tunnel {
|
.welcome-btn-tunnel {
|
||||||
background: linear-gradient(135deg, #221538 0%, #3b1a7a 50%, #6d28d9 100%);
|
background: linear-gradient(135deg, #221538 0%, #3b1a7a 50%, #6d28d9 100%);
|
||||||
border-color: rgba(124, 58, 237, 0.4);
|
border-color: rgba(124, 58, 237, 0.4);
|
||||||
@@ -2466,6 +2486,264 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── COD-9 cross-session search (folded into the welcome history panel) ── */
|
||||||
|
.search-panel {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.55rem 2rem 0.55rem 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-smooth), background var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.4rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-secondary {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-chip {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.28rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-chip:hover {
|
||||||
|
border-color: rgba(59, 130, 246, 0.3);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-chip.active {
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
|
color: #cdddff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select {
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.28rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group-header:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group-count {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-card:hover,
|
||||||
|
.search-result-card:focus-visible {
|
||||||
|
border-color: rgba(59, 130, 246, 0.35);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-badge {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-badge-session {
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
color: #9dc0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-badge-event {
|
||||||
|
background: rgba(168, 85, 247, 0.18);
|
||||||
|
color: #d4b3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-badge-file {
|
||||||
|
background: rgba(34, 197, 94, 0.16);
|
||||||
|
color: #95e6b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-time {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-snippet {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty,
|
||||||
|
.search-truncated {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0.6rem 0.2rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-truncated {
|
||||||
|
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.search-filter-secondary {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.search-select {
|
||||||
|
max-width: 7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.history-title {
|
.history-title {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -2910,6 +3188,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
color: #e9d5ff;
|
color: #e9d5ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gemini mode colors */
|
||||||
|
.btn-toolbar.btn-run.mode-gemini,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-gemini {
|
||||||
|
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
|
||||||
|
border-color: rgba(96, 165, 250, 0.5);
|
||||||
|
color: #dbeafe;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-run.mode-gemini:hover,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-gemini:hover {
|
||||||
|
background: linear-gradient(135deg, #17345f 0%, #2563eb 55%, #6366f1 100%);
|
||||||
|
box-shadow: 0 0 12px rgba(96, 165, 250, 0.35), 0 2px 8px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(147, 197, 253, 0.6);
|
||||||
|
color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dropdown menu */
|
/* Dropdown menu */
|
||||||
.run-mode-menu {
|
.run-mode-menu {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -2963,6 +3257,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
.run-mode-dot.claude { background: #3b82f6; }
|
.run-mode-dot.claude { background: #3b82f6; }
|
||||||
.run-mode-dot.opencode { background: #10b981; }
|
.run-mode-dot.opencode { background: #10b981; }
|
||||||
.run-mode-dot.codex { background: #a855f7; }
|
.run-mode-dot.codex { background: #a855f7; }
|
||||||
|
.run-mode-dot.gemini { background: #8ab4f8; }
|
||||||
|
|
||||||
.run-mode-sep {
|
.run-mode-sep {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
@@ -5108,6 +5403,238 @@ kbd {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Away Digest Modal */
|
||||||
|
.away-digest-modal {
|
||||||
|
max-width: 860px;
|
||||||
|
width: min(92vw, 860px);
|
||||||
|
max-height: 86vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-modal .modal-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-ranges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-custom-range {
|
||||||
|
display: none;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-custom-range.active {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-custom-range label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-custom-range input {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-card,
|
||||||
|
.away-digest-loading,
|
||||||
|
.away-digest-load-error {
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-loading,
|
||||||
|
.away-digest-load-error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-load-error {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-card-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-card-value {
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-card-cost {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
color: var(--accent-hover);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-freshness {
|
||||||
|
min-height: 18px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-section {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-section-title h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-section-title span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-left: 3px solid var(--border-light);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-success {
|
||||||
|
border-left-color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-warning {
|
||||||
|
border-left-color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-error {
|
||||||
|
border-left-color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-info {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-item-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-item-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.55rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-item-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-item-detail {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-action {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--accent-hover);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-action:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: rgba(96, 165, 250, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.away-digest-empty {
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Token Stats Modal */
|
/* Token Stats Modal */
|
||||||
.token-stats-modal {
|
.token-stats-modal {
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
@@ -5573,6 +6100,45 @@ kbd {
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ultracode run minimized-to-tab badge — same chip shape as the subagent badge but a
|
||||||
|
distinct purple (matches the ultracode window accent), and a SEPARATE class so the
|
||||||
|
incremental tab-update path (which keys on .tab-subagent-badge) never touches it.
|
||||||
|
Reuses the shared .subagent-dropdown for its restore/dismiss list. */
|
||||||
|
.session-tab .tab-ultracode-badge {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #a855f7;
|
||||||
|
color: white;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: transform 0.1s, background 0.15s;
|
||||||
|
}
|
||||||
|
@media (hover: hover) {
|
||||||
|
.session-tab .tab-ultracode-badge:hover {
|
||||||
|
background: #9333ea;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.session-tab .tab-ultracode-badge .subagent-label {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
/* Per-item type glyph in the ULTRA dropdown (🧬 run vs 📄 transcript). */
|
||||||
|
.subagent-dropdown-item .ultracode-dd-icon {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Subagent Dropdown - compact, hover-triggered */
|
/* Subagent Dropdown - compact, hover-triggered */
|
||||||
.subagent-dropdown {
|
.subagent-dropdown {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -8647,13 +9213,6 @@ kbd {
|
|||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
.ultracode-window.collapsed {
|
|
||||||
height: auto !important;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
.ultracode-window.collapsed .ultracode-window-body {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.ultracode-window-header {
|
.ultracode-window-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -8744,6 +9303,32 @@ kbd {
|
|||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent-transcript window: spawned from an agent card (in a run window or the dock
|
||||||
|
panel), tied to its parent run window by its own connector line. Reuses the
|
||||||
|
.ultracode-window chrome; wider with a monospace transcript body. */
|
||||||
|
.ultracode-agent-window {
|
||||||
|
width: 470px;
|
||||||
|
height: 420px;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(56, 189, 248, 0.18);
|
||||||
|
}
|
||||||
|
.ultracode-agent-window .ultracode-window-body .uw-transcript {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
/* Distinguish the parent→agent line from the tab→run line: solid cyan, no dashes.
|
||||||
|
Must follow .ultracode-connection (equal specificity → source order wins). */
|
||||||
|
.connection-line.ultracode-agent-connection {
|
||||||
|
stroke: #38bdf8;
|
||||||
|
stroke-dasharray: none;
|
||||||
|
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8)) drop-shadow(0 0 5px rgba(56, 189, 248, 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
||||||
default via the marker class below; the server strips it at render when the
|
default via the marker class below; the server strips it at render when the
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
|
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
|
||||||
const statusClass = agent?.status || 'idle';
|
const statusClass = agent?.status || 'idle';
|
||||||
agentItems.push(`
|
agentItems.push(`
|
||||||
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Click to restore">
|
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore">
|
||||||
<span class="subagent-dropdown-status ${statusClass}"></span>
|
<span class="subagent-dropdown-status ${statusClass}"></span>
|
||||||
<span class="subagent-dropdown-name">${escapeHtml(truncatedName)}</span>
|
<span class="subagent-dropdown-name">${escapeHtml(truncatedName)}</span>
|
||||||
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Dismiss">×</span>
|
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -461,6 +461,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (typeof this._appendUltracodeConnectionLines === 'function') {
|
if (typeof this._appendUltracodeConnectionLines === 'function') {
|
||||||
this._appendUltracodeConnectionLines(svg, rects);
|
this._appendUltracodeConnectionLines(svg, rects);
|
||||||
}
|
}
|
||||||
|
// Agent-transcript windows → their run window / tab (ultracode-windows.js).
|
||||||
|
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
|
||||||
|
this._appendUltracodeAgentConnectionLines(svg, rects);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -695,7 +699,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
parentSessionId && parentSessionName
|
parentSessionId && parentSessionName
|
||||||
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
|
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
|
||||||
<span class="parent-label">from</span>
|
<span class="parent-label">from</span>
|
||||||
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentSessionName)}</span>
|
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentSessionName)}</span>
|
||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -716,7 +720,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="status ${agent.status}">${agent.status}</span>
|
<span class="status ${agent.status}">${agent.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="subagent-window-actions">
|
<div class="subagent-window-actions">
|
||||||
<button onclick="app.closeSubagentWindow('${escapeHtml(agentId)}')" title="Minimize to tab">─</button>
|
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(agentId))})" title="Minimize to tab">─</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${parentHeader}
|
${parentHeader}
|
||||||
|
|||||||
@@ -983,6 +983,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
overlay.classList.add('visible');
|
overlay.classList.add('visible');
|
||||||
this.loadTunnelStatus();
|
this.loadTunnelStatus();
|
||||||
this.loadHistorySessions();
|
this.loadHistorySessions();
|
||||||
|
this.initSearchPanel();
|
||||||
}
|
}
|
||||||
// Home screen has no input target — hide the CJK textarea (activeSessionId
|
// 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.
|
// is null by the time we get here). Guarded: defined on the app object.
|
||||||
@@ -2195,12 +2196,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async sendInput(input) {
|
async sendInput(input) {
|
||||||
if (!this.activeSessionId) return;
|
if (!this.activeSessionId || !input) return;
|
||||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
// Route through the durable, exactly-once delivery layer (useMux for the
|
||||||
method: 'POST',
|
// POST fallback) so voice / keyboard-accessory / paste input also survives a
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// dropped link instead of being lost in a single best-effort fetch.
|
||||||
body: JSON.stringify({ input, useMux: true }),
|
this._sendInputAsync(this.activeSessionId, input, { useMux: true });
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -2254,3 +2254,338 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// COD-9 — Cross-session search (folded into the welcome history panel)
|
||||||
|
// Consumes GET /api/search; renders grouped result cards with jump-to actions.
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
(function (global) {
|
||||||
|
const SEARCH_DEBOUNCE_MS = 250;
|
||||||
|
const SEARCH_LIMIT = 60;
|
||||||
|
const SOURCE_LABELS = { session: 'Sessions', event: 'Events', file: 'Files' };
|
||||||
|
|
||||||
|
/** Human-friendly relative-ish timestamp matching the history panel's style. */
|
||||||
|
function formatSearchTime(ts) {
|
||||||
|
if (!Number.isFinite(ts)) return '';
|
||||||
|
const d = new Date(ts);
|
||||||
|
return (
|
||||||
|
d.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||||
|
' ' +
|
||||||
|
d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.CodemanSearch = { SEARCH_DEBOUNCE_MS, SEARCH_LIMIT, SOURCE_LABELS, formatSearchTime };
|
||||||
|
})(window);
|
||||||
|
|
||||||
|
Object.assign(CodemanApp.prototype, {
|
||||||
|
/**
|
||||||
|
* Wire up the search box, filter chips, and selects inside the welcome
|
||||||
|
* history panel. Idempotent — safe to call every time the overlay opens.
|
||||||
|
*/
|
||||||
|
initSearchPanel() {
|
||||||
|
const input = document.getElementById('searchInput');
|
||||||
|
if (!input || this._searchPanelWired) {
|
||||||
|
// Even when already wired, refresh the case dropdown (cases may have loaded since).
|
||||||
|
if (this._searchPanelWired) this._populateSearchCaseFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._searchPanelWired = true;
|
||||||
|
|
||||||
|
// Active source-type filter set (mirrors the chip .active state → types= param).
|
||||||
|
this._searchTypes = new Set(['session', 'event', 'file']);
|
||||||
|
this._searchSecondary = { caseLabel: '', status: '', days: '' };
|
||||||
|
this._searchDebounceTimer = null;
|
||||||
|
this._searchSeq = 0;
|
||||||
|
this._searchLastData = null;
|
||||||
|
|
||||||
|
const clearBtn = document.getElementById('searchClearBtn');
|
||||||
|
const results = document.getElementById('searchResults');
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
if (clearBtn) clearBtn.hidden = input.value.length === 0;
|
||||||
|
this._scheduleSearch();
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Escape' && input.value) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this._clearSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', () => this._clearSearch());
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#searchFilters .search-filter-chip').forEach((chip) => {
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
const t = chip.dataset.typeFilter;
|
||||||
|
// Keep at least one type selected.
|
||||||
|
if (this._searchTypes.has(t) && this._searchTypes.size === 1) return;
|
||||||
|
if (this._searchTypes.has(t)) {
|
||||||
|
this._searchTypes.delete(t);
|
||||||
|
chip.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
this._searchTypes.add(t);
|
||||||
|
chip.classList.add('active');
|
||||||
|
}
|
||||||
|
this._runSearch();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const caseSel = document.getElementById('searchCaseFilter');
|
||||||
|
const statusSel = document.getElementById('searchStatusFilter');
|
||||||
|
const dateSel = document.getElementById('searchDateFilter');
|
||||||
|
if (caseSel) {
|
||||||
|
caseSel.addEventListener('change', () => {
|
||||||
|
this._searchSecondary.caseLabel = caseSel.value;
|
||||||
|
this._renderSearch(this._searchLastData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (statusSel) {
|
||||||
|
statusSel.addEventListener('change', () => {
|
||||||
|
this._searchSecondary.status = statusSel.value;
|
||||||
|
this._renderSearch(this._searchLastData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dateSel) {
|
||||||
|
dateSel.addEventListener('change', () => {
|
||||||
|
this._searchSecondary.days = dateSel.value;
|
||||||
|
this._renderSearch(this._searchLastData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._populateSearchCaseFilter();
|
||||||
|
if (results) results.hidden = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Fill the case <select> from loaded cases (#caseName values). */
|
||||||
|
_populateSearchCaseFilter() {
|
||||||
|
const sel = document.getElementById('searchCaseFilter');
|
||||||
|
if (!sel) return;
|
||||||
|
const cases = Array.isArray(this.cases) ? this.cases : [];
|
||||||
|
const names = Array.from(new Set(cases.map((c) => c && c.name).filter(Boolean))).sort();
|
||||||
|
const current = sel.value;
|
||||||
|
// Rebuild options (keep the "All cases" placeholder).
|
||||||
|
sel.innerHTML = '';
|
||||||
|
const all = document.createElement('option');
|
||||||
|
all.value = '';
|
||||||
|
all.textContent = 'All cases';
|
||||||
|
sel.appendChild(all);
|
||||||
|
for (const name of names) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = '#' + name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (current && names.includes(current)) sel.value = current;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Debounced trigger from the input event. */
|
||||||
|
_scheduleSearch() {
|
||||||
|
clearTimeout(this._searchDebounceTimer);
|
||||||
|
this._searchDebounceTimer = setTimeout(() => this._runSearch(), window.CodemanSearch.SEARCH_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearSearch() {
|
||||||
|
const input = document.getElementById('searchInput');
|
||||||
|
const clearBtn = document.getElementById('searchClearBtn');
|
||||||
|
if (input) input.value = '';
|
||||||
|
if (clearBtn) clearBtn.hidden = true;
|
||||||
|
this._searchLastData = null;
|
||||||
|
this._renderSearch(null);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Execute the federated search request and render the result. */
|
||||||
|
async _runSearch() {
|
||||||
|
const input = document.getElementById('searchInput');
|
||||||
|
if (!input) return;
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length === 0) {
|
||||||
|
this._searchLastData = null;
|
||||||
|
this._renderSearch(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = Array.from(this._searchTypes);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('q', q.slice(0, 200));
|
||||||
|
if (types.length > 0 && types.length < 3) params.set('types', types.join(','));
|
||||||
|
params.set('limit', String(window.CodemanSearch.SEARCH_LIMIT));
|
||||||
|
|
||||||
|
const seq = ++this._searchSeq;
|
||||||
|
const data = await this._apiJson('/api/search?' + params.toString());
|
||||||
|
// Drop stale responses (a newer query already fired).
|
||||||
|
if (seq !== this._searchSeq) return;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
// null = request error or 400 (bad input). Show an empty/error state.
|
||||||
|
this._searchLastData = { query: q, groups: [], totalResults: 0, truncated: false, _error: true };
|
||||||
|
} else {
|
||||||
|
this._searchLastData = data;
|
||||||
|
}
|
||||||
|
this._renderSearch(this._searchLastData);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply client-side secondary filters (case / status / date) to a group's
|
||||||
|
* results. Type filtering already happened server-side via types=.
|
||||||
|
*/
|
||||||
|
_applySecondaryFilters(results) {
|
||||||
|
const { caseLabel, status, days } = this._searchSecondary;
|
||||||
|
let out = results;
|
||||||
|
if (caseLabel) {
|
||||||
|
const want = '#' + caseLabel;
|
||||||
|
out = out.filter((r) => (r.sessionName || '').includes(want) || r.sessionName === caseLabel);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
const activeIds = new Set((this.sessionOrder || []).concat(Object.keys(this.sessions || {})));
|
||||||
|
out = out.filter((r) => {
|
||||||
|
const isActive = activeIds.has(r.sessionId);
|
||||||
|
return status === 'active' ? isActive : !isActive;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (days) {
|
||||||
|
const cutoff = Date.now() - Number(days) * 24 * 60 * 60 * 1000;
|
||||||
|
out = out.filter((r) => Number.isFinite(r.timestamp) && r.timestamp >= cutoff);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Render the grouped result cards (or empty/loading states). */
|
||||||
|
_renderSearch(data) {
|
||||||
|
const results = document.getElementById('searchResults');
|
||||||
|
const historyTitle = document.getElementById('historyTitle');
|
||||||
|
const historyList = document.getElementById('historyList');
|
||||||
|
if (!results) return;
|
||||||
|
|
||||||
|
const searching = !!data;
|
||||||
|
// Hide the plain "Resume Conversation" history list while a search is active.
|
||||||
|
if (historyTitle) historyTitle.style.display = searching ? 'none' : '';
|
||||||
|
if (historyList) historyList.style.display = searching ? 'none' : '';
|
||||||
|
|
||||||
|
results.innerHTML = '';
|
||||||
|
if (!data) {
|
||||||
|
results.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.hidden = false;
|
||||||
|
|
||||||
|
if (data._error) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'search-empty';
|
||||||
|
empty.textContent = 'Search unavailable — check the query and try again.';
|
||||||
|
results.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply secondary (client-side) filters and recompute shown total.
|
||||||
|
const groups = (data.groups || [])
|
||||||
|
.map((g) => ({ type: g.type, results: this._applySecondaryFilters(g.results || []) }))
|
||||||
|
.filter((g) => g.results.length > 0);
|
||||||
|
|
||||||
|
const shownTotal = groups.reduce((n, g) => n + g.results.length, 0);
|
||||||
|
|
||||||
|
if (shownTotal === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'search-empty';
|
||||||
|
empty.textContent = 'No results for "' + (data.query || '') + '"';
|
||||||
|
results.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'search-group-header';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'search-group-label';
|
||||||
|
label.textContent = window.CodemanSearch.SOURCE_LABELS[group.type] || group.type;
|
||||||
|
const count = document.createElement('span');
|
||||||
|
count.className = 'search-group-count';
|
||||||
|
count.textContent = String(group.results.length);
|
||||||
|
header.append(label, count);
|
||||||
|
results.appendChild(header);
|
||||||
|
|
||||||
|
for (const r of group.results) {
|
||||||
|
results.appendChild(this._buildSearchResultCard(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.truncated) {
|
||||||
|
const trunc = document.createElement('div');
|
||||||
|
trunc.className = 'search-truncated';
|
||||||
|
trunc.textContent = 'Showing the top matches — refine your search to narrow results.';
|
||||||
|
results.appendChild(trunc);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Build a single result card DOM node wired to its jump-to action. */
|
||||||
|
_buildSearchResultCard(r) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'search-result-card';
|
||||||
|
card.dataset.type = r.type;
|
||||||
|
card.tabIndex = 0;
|
||||||
|
card.setAttribute('role', 'button');
|
||||||
|
|
||||||
|
const topRow = document.createElement('div');
|
||||||
|
topRow.className = 'search-result-top';
|
||||||
|
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'search-result-badge search-badge-' + r.type;
|
||||||
|
badge.textContent = (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
|
||||||
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'search-result-name';
|
||||||
|
name.textContent = r.sessionName || r.sessionId || '(session)';
|
||||||
|
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.className = 'search-result-time';
|
||||||
|
time.textContent = window.CodemanSearch.formatSearchTime(r.timestamp);
|
||||||
|
|
||||||
|
topRow.append(badge, name, time);
|
||||||
|
|
||||||
|
const snippet = document.createElement('div');
|
||||||
|
snippet.className = 'search-result-snippet';
|
||||||
|
snippet.textContent = r.snippet || '';
|
||||||
|
|
||||||
|
card.append(topRow, snippet);
|
||||||
|
|
||||||
|
const jump = () => this._jumpToSearchResult(r);
|
||||||
|
card.addEventListener('click', jump);
|
||||||
|
card.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||||
|
ev.preventDefault();
|
||||||
|
jump();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a search result by jumpTo.kind, reusing the existing app methods:
|
||||||
|
* session → selectSession(sessionId) (open/switch to the session)
|
||||||
|
* run-summary → openRunSummary(sessionId) (session options → summary tab)
|
||||||
|
* file-preview→ openFilePreview(path, sessionId, attachmentId)
|
||||||
|
*/
|
||||||
|
_jumpToSearchResult(r) {
|
||||||
|
const jt = r && r.jumpTo;
|
||||||
|
if (!jt) return;
|
||||||
|
// Leaving the welcome overlay so the target surface is visible.
|
||||||
|
if (typeof this.hideWelcome === 'function') this.hideWelcome();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (jt.kind === 'run-summary') {
|
||||||
|
this.openRunSummary(jt.sessionId);
|
||||||
|
} else if (jt.kind === 'file-preview') {
|
||||||
|
this.openFilePreview(jt.relativePath || '', jt.sessionId, jt.targetId || null);
|
||||||
|
} else {
|
||||||
|
// 'session' (default)
|
||||||
|
this.selectSession(jt.sessionId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[search] jump failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -131,12 +131,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Phase 4: open an agent's live transcript by agentId. The workflow agent's
|
// Phase 4: fetch an agent's live transcript by agentId. The workflow agent's
|
||||||
// agentId is byte-identical to the agent-<id>.jsonl stem already tracked by
|
// agentId is byte-identical to the agent-<id>.jsonl stem already tracked by
|
||||||
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
|
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
|
||||||
// Graceful when the agent isn't tracked yet / aged out / tracking disabled.
|
// Returns { formatted: string[], entryCount } or null when nothing is available
|
||||||
async openWorkflowAgentTranscript(agentId) {
|
// (queued / aged out of tracking / tracking disabled). Rendering into a connected
|
||||||
if (!agentId) return;
|
// in-page floating window lives in ultracode-windows.js (openUltracodeAgentWindow) —
|
||||||
|
// we no longer spawn a detached browser popup.
|
||||||
|
async _fetchWorkflowAgentTranscript(agentId) {
|
||||||
|
if (!agentId) return null;
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
|
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
|
||||||
@@ -147,21 +150,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const ok = data && data.success && data.data;
|
const ok = data && data.success && data.data;
|
||||||
const formatted = ok ? data.data.formatted : null;
|
const formatted = ok ? data.data.formatted : null;
|
||||||
const entryCount = ok ? data.data.entryCount || 0 : 0;
|
const entryCount = ok ? data.data.entryCount || 0 : 0;
|
||||||
if (!formatted || !entryCount) {
|
if (!formatted || !entryCount) return null;
|
||||||
alert(
|
return { formatted, entryCount };
|
||||||
'No transcript available for this agent yet — it may be queued, aged out of tracking, or subagent tracking is disabled.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const win = window.open('', '_blank', 'width=860,height=640');
|
|
||||||
if (!win) return; // popup blocked
|
|
||||||
win.document.write(
|
|
||||||
`<html><head><title>Workflow agent ${escapeHtml(agentId)} transcript</title>` +
|
|
||||||
`<style>body{background:#1a1a2e;color:#eee;font-family:monospace;padding:20px}pre{white-space:pre-wrap;word-wrap:break-word}</style>` +
|
|
||||||
`</head><body><h2>Workflow agent ${escapeHtml(agentId)} (${entryCount} entries)</h2>` +
|
|
||||||
`<pre>${escapeHtml(formatted.join('\n'))}</pre></body></html>`
|
|
||||||
);
|
|
||||||
win.document.close();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ----- Render (debounced) -----
|
// ----- Render (debounced) -----
|
||||||
@@ -214,7 +204,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
|
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun('${escapeHtml(r.runId)}')">` +
|
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun(${escapeHtml(JSON.stringify(r.runId))})">` +
|
||||||
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
|
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
|
||||||
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
|
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
|
||||||
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
|
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
|
||||||
@@ -262,13 +252,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const header =
|
const header =
|
||||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
||||||
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
|
return header + group.map((a) => this._workflowAgentCardHtml(a, runId)).join('');
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
detail.innerHTML = html;
|
detail.innerHTML = html;
|
||||||
},
|
},
|
||||||
|
|
||||||
_workflowAgentCardHtml(a) {
|
_workflowAgentCardHtml(a, runId) {
|
||||||
const state = String(a.state || 'start');
|
const state = String(a.state || 'start');
|
||||||
const stateCls = this._workflowAgentStateClass(state);
|
const stateCls = this._workflowAgentStateClass(state);
|
||||||
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
|
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
|
||||||
@@ -289,7 +279,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const cardStateCls = state === 'done' ? ' uw-state-done' : state === 'progress' ? ' uw-state-working' : '';
|
const cardStateCls = state === 'done' ? ' uw-state-done' : state === 'progress' ? ' uw-state-working' : '';
|
||||||
const cardAttrs = clickable
|
const cardAttrs = clickable
|
||||||
? ` class="ultracode-agent-card ultracode-agent-card--clickable${cardStateCls}" role="button" tabindex="0"` +
|
? ` class="ultracode-agent-card ultracode-agent-card--clickable${cardStateCls}" role="button" tabindex="0"` +
|
||||||
` title="View transcript" onclick="app.openWorkflowAgentTranscript('${escapeHtml(a.agentId)}')"`
|
` title="View transcript" onclick="app.openUltracodeAgentWindow(${escapeHtml(JSON.stringify(a.agentId))},${escapeHtml(JSON.stringify(runId || ''))})"`
|
||||||
: ` class="ultracode-agent-card${cardStateCls}"`;
|
: ` class="ultracode-agent-card${cardStateCls}"`;
|
||||||
return (
|
return (
|
||||||
`<div${cardAttrs}>` +
|
`<div${cardAttrs}>` +
|
||||||
|
|||||||
@@ -33,9 +33,12 @@
|
|||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
/** Lazily seed the floating-window state maps (constructor also seeds them). */
|
/** Lazily seed the floating-window state maps (constructor also seeds them). */
|
||||||
_ensureUltracodeWindowState() {
|
_ensureUltracodeWindowState() {
|
||||||
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed }
|
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners }
|
||||||
if (!this.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed
|
if (!this.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed
|
||||||
if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id
|
if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id
|
||||||
|
if (!this.ultracodeAgentWindows) this.ultracodeAgentWindows = new Map(); // agentId -> { element, runId, dragListeners }
|
||||||
|
if (!this.minimizedUltracodeRuns) this.minimizedUltracodeRuns = new Map(); // sessionId -> Set<runId> minimized to a tab
|
||||||
|
if (!this.minimizedUltracodeAgents) this.minimizedUltracodeAgents = new Map(); // sessionId -> Map<agentId,{runId,label}>
|
||||||
if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000;
|
if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -86,6 +89,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const active = this._isWorkflowRunActive(run);
|
const active = this._isWorkflowRunActive(run);
|
||||||
|
|
||||||
|
// Minimized to a tab — keep it there (don't re-pop a window). Clear the tab badge a
|
||||||
|
// short while after the run finishes, mirroring the floating window's finish grace.
|
||||||
|
if (this._isUltracodeRunMinimized(runId)) {
|
||||||
|
if (!active && !this.ultracodeWindowCloseTimers.has(runId)) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.ultracodeWindowCloseTimers.delete(runId);
|
||||||
|
this._removeMinimizedUltracodeRun(runId);
|
||||||
|
this.renderSessionTabs();
|
||||||
|
this.updateConnectionLines();
|
||||||
|
}, 8000);
|
||||||
|
this.ultracodeWindowCloseTimers.set(runId, timer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
// Run is alive — cancel any pending auto-close.
|
// Run is alive — cancel any pending auto-close.
|
||||||
const pending = this.ultracodeWindowCloseTimers.get(runId);
|
const pending = this.ultracodeWindowCloseTimers.get(runId);
|
||||||
@@ -134,11 +152,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const run = this.workflowRuns && this.workflowRuns.get(runId);
|
const run = this.workflowRuns && this.workflowRuns.get(runId);
|
||||||
if (!run) return;
|
if (!run) return;
|
||||||
this.ultracodeWindowsClosed.delete(runId); // an explicit open overrides a past dismissal
|
this.ultracodeWindowsClosed.delete(runId); // an explicit open overrides a past dismissal
|
||||||
|
this._removeMinimizedUltracodeRun(runId); // …and a past minimize-to-tab
|
||||||
const existing = this.ultracodeWindows.get(runId);
|
const existing = this.ultracodeWindows.get(runId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Already open — bring to front, expand if collapsed, refresh.
|
// Already open — bring to front and refresh.
|
||||||
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
|
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||||
if (existing.collapsed) this.toggleUltracodeWindowCollapse(runId);
|
|
||||||
this.renderUltracodeWindowContent(runId);
|
this.renderUltracodeWindowContent(runId);
|
||||||
this._fetchWorkflowRunDetail(runId);
|
this._fetchWorkflowRunDetail(runId);
|
||||||
this.updateConnectionLines();
|
this.updateConnectionLines();
|
||||||
@@ -167,7 +185,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="uw-status"></span>
|
<span class="uw-status"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ultracode-window-actions">
|
<div class="ultracode-window-actions">
|
||||||
<button class="uw-min" type="button" title="Collapse">─</button>
|
<button class="uw-min" type="button" title="Minimize to tab">─</button>
|
||||||
<button class="uw-close" type="button" title="Close">×</button>
|
<button class="uw-close" type="button" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,7 +216,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.toggleUltracodeWindowCollapse(runId);
|
this.minimizeUltracodeWindowToTab(runId);
|
||||||
});
|
});
|
||||||
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -211,19 +229,261 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
|
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners, collapsed: false });
|
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners });
|
||||||
|
|
||||||
this.renderUltracodeWindowContent(runId);
|
this.renderUltracodeWindowContent(runId);
|
||||||
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
|
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
|
||||||
this.updateConnectionLines();
|
this.updateConnectionLines();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Collapse/expand the window to header-only (line stays connected). */
|
// ── Minimize a run window into its originating tab (same idiom as subagent windows) ──
|
||||||
toggleUltracodeWindowCollapse(runId) {
|
|
||||||
|
/** Is this run currently minimized to a tab (so auto-pop should leave it alone)? */
|
||||||
|
_isUltracodeRunMinimized(runId) {
|
||||||
|
if (!this.minimizedUltracodeRuns) return false;
|
||||||
|
for (const set of this.minimizedUltracodeRuns.values()) {
|
||||||
|
if (set.has(runId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Drop a run from the minimized-to-tab tracking (all sessions, or a specific one). */
|
||||||
|
_removeMinimizedUltracodeRun(runId, sessionId) {
|
||||||
|
if (!this.minimizedUltracodeRuns) return;
|
||||||
|
if (sessionId) {
|
||||||
|
const set = this.minimizedUltracodeRuns.get(sessionId);
|
||||||
|
if (set) {
|
||||||
|
set.delete(runId);
|
||||||
|
if (!set.size) this.minimizedUltracodeRuns.delete(sessionId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [sid, set] of this.minimizedUltracodeRuns) {
|
||||||
|
if (set.delete(runId) && !set.size) this.minimizedUltracodeRuns.delete(sid);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimize the floating run window into its originating session tab: record it as
|
||||||
|
* minimized (so a badge renders on the tab), genie-animate the window toward that
|
||||||
|
* tab, then remove the floating element. Restorable from the tab badge dropdown.
|
||||||
|
*/
|
||||||
|
minimizeUltracodeWindowToTab(runId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
const data = this.ultracodeWindows.get(runId);
|
const data = this.ultracodeWindows.get(runId);
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
data.collapsed = !data.collapsed;
|
|
||||||
data.element.classList.toggle('collapsed', data.collapsed);
|
let parentSessionId = data.parentSessionId;
|
||||||
|
if (!parentSessionId) {
|
||||||
|
const summary = this.workflowRuns && this.workflowRuns.get(runId);
|
||||||
|
parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||||
|
}
|
||||||
|
// No tab to fly into → fall back to a plain close so the window isn't orphaned.
|
||||||
|
if (!parentSessionId) {
|
||||||
|
this.closeUltracodeWindow(runId, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending finish auto-close — the badge owns the run's lifecycle now.
|
||||||
|
const pending = this.ultracodeWindowCloseTimers.get(runId);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending);
|
||||||
|
this.ultracodeWindowCloseTimers.delete(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.minimizedUltracodeRuns.has(parentSessionId)) this.minimizedUltracodeRuns.set(parentSessionId, new Set());
|
||||||
|
this.minimizedUltracodeRuns.get(parentSessionId).add(runId);
|
||||||
|
|
||||||
|
const element = data.element;
|
||||||
|
const dragListeners = data.dragListeners;
|
||||||
|
this._animateUltracodeWindowToTab(element, parentSessionId, () => {
|
||||||
|
this._teardownUltracodeDrag(dragListeners);
|
||||||
|
if (element) element.remove();
|
||||||
|
this.ultracodeWindows.delete(runId);
|
||||||
|
// Full rebuild so the tab badge renders (the incremental path only knows subagent badges).
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
this.updateConnectionLines();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
|
||||||
|
_animateUltracodeWindowToTab(element, sessionId, done) {
|
||||||
|
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
|
||||||
|
if (!tab || !element) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = element.getBoundingClientRect();
|
||||||
|
const t = tab.getBoundingClientRect();
|
||||||
|
const dx = t.left + t.width / 2 - (w.left + w.width / 2);
|
||||||
|
const dy = t.top + t.height / 2 - (w.top + w.height / 2);
|
||||||
|
element.style.transformOrigin = 'center center';
|
||||||
|
element.style.transition = 'transform 0.26s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.26s ease';
|
||||||
|
element.style.pointerEvents = 'none';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
element.style.transform = `translate(${dx}px, ${dy}px) scale(0.06)`;
|
||||||
|
element.style.opacity = '0';
|
||||||
|
});
|
||||||
|
let finished = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
done();
|
||||||
|
};
|
||||||
|
element.addEventListener('transitionend', finish, { once: true });
|
||||||
|
setTimeout(finish, 320); // fallback in case transitionend doesn't fire
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Tab badge (with restore/dismiss dropdown) for runs minimized to this session's tab. */
|
||||||
|
renderUltracodeTabBadge(sessionId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
const runSet = this.minimizedUltracodeRuns.get(sessionId);
|
||||||
|
const agentMap = this.minimizedUltracodeAgents.get(sessionId);
|
||||||
|
const total = (runSet ? runSet.size : 0) + (agentMap ? agentMap.size : 0);
|
||||||
|
if (total === 0) return '';
|
||||||
|
|
||||||
|
const trunc = (s) => (s.length > 25 ? s.slice(0, 25) + '…' : s);
|
||||||
|
const items = [];
|
||||||
|
// Minimized run windows (🧬) first…
|
||||||
|
if (runSet) {
|
||||||
|
for (const runId of runSet) {
|
||||||
|
const run = this.workflowRuns && this.workflowRuns.get(runId);
|
||||||
|
const name = run ? run.workflowName || run.summary || runId : runId;
|
||||||
|
const statusCls = this._workflowStatusClass(run ? String(run.status || '') : '');
|
||||||
|
items.push(
|
||||||
|
`<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreUltracodeRunFromTab(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore run">` +
|
||||||
|
`<span class="subagent-dropdown-status ${statusCls}"></span>` +
|
||||||
|
`<span class="ultracode-dd-icon">🧬</span>` +
|
||||||
|
`<span class="subagent-dropdown-name">${escapeHtml(trunc(name))}</span>` +
|
||||||
|
`<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.dismissMinimizedUltracodeRun(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>` +
|
||||||
|
`</div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// …then minimized agent transcripts (📄).
|
||||||
|
if (agentMap) {
|
||||||
|
for (const [agentId, entry] of agentMap) {
|
||||||
|
const name = (entry && entry.label) || agentId;
|
||||||
|
items.push(
|
||||||
|
`<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreUltracodeAgentFromTab(${escapeHtml(JSON.stringify(agentId))},${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore transcript">` +
|
||||||
|
`<span class="subagent-dropdown-status"></span>` +
|
||||||
|
`<span class="ultracode-dd-icon">📄</span>` +
|
||||||
|
`<span class="subagent-dropdown-name">${escapeHtml(trunc(name))}</span>` +
|
||||||
|
`<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.dismissMinimizedUltracodeAgent(${escapeHtml(JSON.stringify(agentId))},${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>` +
|
||||||
|
`</div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const label = total === 1 ? 'ULTRA' : `ULTRA (${total})`;
|
||||||
|
return (
|
||||||
|
`<span class="tab-ultracode-badge" onmouseenter="app.showSubagentDropdown(this)" onmouseleave="app.scheduleHideSubagentDropdown(this)" onclick="event.stopPropagation(); app.pinSubagentDropdown(this);">` +
|
||||||
|
`<span class="subagent-label">${label}</span>` +
|
||||||
|
`<div class="subagent-dropdown" onmouseenter="app.cancelHideSubagentDropdown()" onmouseleave="app.scheduleHideSubagentDropdown(this.parentElement)">${items.join('')}</div>` +
|
||||||
|
`</span>`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Restore a minimized run from its tab badge: re-open the floating window. */
|
||||||
|
restoreUltracodeRunFromTab(runId, sessionId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
this._removeMinimizedUltracodeRun(runId, sessionId);
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
this.openUltracodeWindowForRun(runId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Dismiss a minimized run from its tab badge (don't re-pop it). */
|
||||||
|
dismissMinimizedUltracodeRun(runId, sessionId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
this._removeMinimizedUltracodeRun(runId, sessionId);
|
||||||
|
this.ultracodeWindowsClosed.add(runId);
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
this.updateConnectionLines();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Minimize an agent transcript window into its tab (same idiom as run windows) ──
|
||||||
|
|
||||||
|
/** Is this agent transcript currently minimized to a tab? */
|
||||||
|
_isUltracodeAgentMinimized(agentId) {
|
||||||
|
if (!this.minimizedUltracodeAgents) return false;
|
||||||
|
for (const map of this.minimizedUltracodeAgents.values()) {
|
||||||
|
if (map.has(agentId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Look up a minimized agent's {runId,label} entry (across sessions). */
|
||||||
|
_getMinimizedUltracodeAgent(agentId) {
|
||||||
|
if (!this.minimizedUltracodeAgents) return null;
|
||||||
|
for (const map of this.minimizedUltracodeAgents.values()) {
|
||||||
|
if (map.has(agentId)) return map.get(agentId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Drop an agent from minimized tracking (all sessions, or a specific one). */
|
||||||
|
_removeMinimizedUltracodeAgent(agentId, sessionId) {
|
||||||
|
if (!this.minimizedUltracodeAgents) return;
|
||||||
|
if (sessionId) {
|
||||||
|
const map = this.minimizedUltracodeAgents.get(sessionId);
|
||||||
|
if (map) {
|
||||||
|
map.delete(agentId);
|
||||||
|
if (!map.size) this.minimizedUltracodeAgents.delete(sessionId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [sid, map] of this.minimizedUltracodeAgents) {
|
||||||
|
if (map.delete(agentId) && !map.size) this.minimizedUltracodeAgents.delete(sid);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Minimize an agent transcript window into the run's originating session tab. */
|
||||||
|
minimizeUltracodeAgentWindowToTab(agentId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
const info = this.ultracodeAgentWindows.get(agentId);
|
||||||
|
if (!info) return;
|
||||||
|
const runId = info.runId;
|
||||||
|
const summary = runId && this.workflowRuns ? this.workflowRuns.get(runId) : null;
|
||||||
|
let parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||||
|
if (!parentSessionId && this.activeSessionId && this.sessions && this.sessions.has(this.activeSessionId)) {
|
||||||
|
parentSessionId = this.activeSessionId;
|
||||||
|
}
|
||||||
|
// No tab to fly into → plain close rather than orphan it.
|
||||||
|
if (!parentSessionId) {
|
||||||
|
this.closeUltracodeAgentWindow(agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const labelEl = info.element.querySelector('.uw-name');
|
||||||
|
const label = labelEl ? labelEl.textContent : agentId;
|
||||||
|
if (!this.minimizedUltracodeAgents.has(parentSessionId))
|
||||||
|
this.minimizedUltracodeAgents.set(parentSessionId, new Map());
|
||||||
|
this.minimizedUltracodeAgents.get(parentSessionId).set(agentId, { runId, label });
|
||||||
|
|
||||||
|
const element = info.element;
|
||||||
|
const dragListeners = info.dragListeners;
|
||||||
|
this._animateUltracodeWindowToTab(element, parentSessionId, () => {
|
||||||
|
this._teardownUltracodeDrag(dragListeners);
|
||||||
|
if (element) element.remove();
|
||||||
|
this.ultracodeAgentWindows.delete(agentId);
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
this.updateConnectionLines();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Restore a minimized agent transcript from its tab badge: re-open its window. */
|
||||||
|
restoreUltracodeAgentFromTab(agentId, sessionId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
const entry = this._getMinimizedUltracodeAgent(agentId);
|
||||||
|
const runId = entry ? entry.runId : null;
|
||||||
|
this._removeMinimizedUltracodeAgent(agentId, sessionId);
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
this.openUltracodeAgentWindow(agentId, runId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Dismiss a minimized agent transcript from its tab badge. */
|
||||||
|
dismissMinimizedUltracodeAgent(agentId, sessionId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
this._removeMinimizedUltracodeAgent(agentId, sessionId);
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
this.updateConnectionLines();
|
this.updateConnectionLines();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -260,19 +520,149 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Agent-transcript windows ────────────────────────────────────────────────
|
||||||
|
// Clicking an agent card (in a run window OR the dock panel) opens the agent's
|
||||||
|
// live transcript as its OWN in-page floating window, line-tied to its parent run
|
||||||
|
// window (or the run's session tab when that window is closed). Replaces the old
|
||||||
|
// detached `window.open` browser popup so the transcript stays inside the same
|
||||||
|
// draggable, connector-line floating-window system as the run windows.
|
||||||
|
|
||||||
|
/** Open (or focus) the floating transcript window for a workflow agent. */
|
||||||
|
async openUltracodeAgentWindow(agentId, runId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
if (!agentId) return;
|
||||||
|
this._removeMinimizedUltracodeAgent(agentId); // an explicit open overrides a past minimize
|
||||||
|
const existing = this.ultracodeAgentWindows.get(agentId);
|
||||||
|
if (existing && existing.element) {
|
||||||
|
// Already open — bring to front and refresh transcript.
|
||||||
|
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||||
|
this.updateConnectionLines();
|
||||||
|
} else if (!this.createUltracodeAgentWindow(agentId, runId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Body shows a loading state until the fetch lands (re-fetch on focus too, so a
|
||||||
|
// still-running agent's transcript grows as you re-click).
|
||||||
|
const data = this._fetchWorkflowAgentTranscript ? await this._fetchWorkflowAgentTranscript(agentId) : null;
|
||||||
|
this.renderUltracodeAgentWindowContent(agentId, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Build and mount the floating agent-transcript window shell near its parent. */
|
||||||
|
createUltracodeAgentWindow(agentId, runId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
if (this.ultracodeAgentWindows.has(agentId)) return this.ultracodeAgentWindows.get(agentId).element;
|
||||||
|
|
||||||
|
const label = this._ultracodeAgentLabel(agentId, runId) || agentId;
|
||||||
|
const win = document.createElement('div');
|
||||||
|
win.className = 'ultracode-window ultracode-agent-window spawning';
|
||||||
|
win.id = `ultracode-agent-window-${agentId}`;
|
||||||
|
win.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||||
|
win.innerHTML = `
|
||||||
|
<div class="ultracode-window-header">
|
||||||
|
<div class="ultracode-window-title" title="${escapeHtml(label)} — transcript">
|
||||||
|
<span class="icon">📄</span>
|
||||||
|
<span class="uw-name">${escapeHtml(label)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ultracode-window-actions">
|
||||||
|
<button class="uw-min" type="button" title="Minimize to tab">─</button>
|
||||||
|
<button class="uw-close" type="button" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ultracode-window-body">
|
||||||
|
<div class="subagent-empty">Loading transcript…</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Position: offset from the parent run window if it's open, else cascade.
|
||||||
|
const parentWin = runId ? this.ultracodeWindows.get(runId) : null;
|
||||||
|
if (parentWin && parentWin.element) {
|
||||||
|
const r = parentWin.element.getBoundingClientRect();
|
||||||
|
win.style.left = `${Math.max(8, Math.min(r.left + 40, window.innerWidth - 472))}px`;
|
||||||
|
win.style.top = `${Math.max(8, Math.min(r.top + 40, window.innerHeight - 160))}px`;
|
||||||
|
} else {
|
||||||
|
const n = this.ultracodeAgentWindows.size;
|
||||||
|
win.style.left = `${Math.min(140 + n * 28, Math.max(8, window.innerWidth - 472))}px`;
|
||||||
|
win.style.top = `${110 + n * 28}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(win);
|
||||||
|
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||||
|
|
||||||
|
const header = win.querySelector('.ultracode-window-header');
|
||||||
|
const dragListeners = this.makeWindowDraggable(win, header);
|
||||||
|
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.minimizeUltracodeAgentWindowToTab(agentId);
|
||||||
|
});
|
||||||
|
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.closeUltracodeAgentWindow(agentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ultracodeAgentWindows.set(agentId, { element: win, runId, dragListeners });
|
||||||
|
this.updateConnectionLines();
|
||||||
|
return win;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Resolve a human label for an agent from the run's fetched detail.agents[]. */
|
||||||
|
_ultracodeAgentLabel(agentId, runId) {
|
||||||
|
const detail = this.workflowRunDetails && runId ? this.workflowRunDetails.get(runId) : null;
|
||||||
|
const agents = detail && Array.isArray(detail.agents) ? detail.agents : null;
|
||||||
|
if (agents) {
|
||||||
|
const found = agents.find((a) => a.agentId === agentId);
|
||||||
|
if (found && found.label) return found.label;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Fill an agent window's body with the fetched transcript (or a friendly empty state). */
|
||||||
|
renderUltracodeAgentWindowContent(agentId, data) {
|
||||||
|
const info = this.ultracodeAgentWindows.get(agentId);
|
||||||
|
if (!info || !info.element) return;
|
||||||
|
const body = info.element.querySelector('.ultracode-window-body');
|
||||||
|
if (!body) return;
|
||||||
|
if (!data || !data.formatted || !data.entryCount) {
|
||||||
|
body.innerHTML =
|
||||||
|
'<div class="subagent-empty">No transcript available yet — the agent may be queued, aged out of tracking, or subagent tracking is disabled.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = escapeHtml(data.formatted.join('\n'));
|
||||||
|
body.innerHTML = `<div class="uw-summary">${data.entryCount} entries</div><pre class="uw-transcript">${text}</pre>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Close one floating agent-transcript window. */
|
||||||
|
closeUltracodeAgentWindow(agentId) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
const info = this.ultracodeAgentWindows.get(agentId);
|
||||||
|
if (!info) return;
|
||||||
|
this._teardownUltracodeDrag(info.dragListeners);
|
||||||
|
if (info.element) info.element.remove();
|
||||||
|
this.ultracodeAgentWindows.delete(agentId);
|
||||||
|
this.updateConnectionLines();
|
||||||
|
},
|
||||||
|
|
||||||
/** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */
|
/** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */
|
||||||
removeAllUltracodeWindows() {
|
removeAllUltracodeWindows() {
|
||||||
this._ensureUltracodeWindowState();
|
this._ensureUltracodeWindowState();
|
||||||
const had = this.ultracodeWindows.size > 0;
|
const hadMinimized = this.minimizedUltracodeRuns.size > 0 || this.minimizedUltracodeAgents.size > 0;
|
||||||
|
const had = this.ultracodeWindows.size > 0 || this.ultracodeAgentWindows.size > 0;
|
||||||
for (const [, data] of this.ultracodeWindows) {
|
for (const [, data] of this.ultracodeWindows) {
|
||||||
this._teardownUltracodeDrag(data.dragListeners);
|
this._teardownUltracodeDrag(data.dragListeners);
|
||||||
if (data.element) data.element.remove();
|
if (data.element) data.element.remove();
|
||||||
}
|
}
|
||||||
this.ultracodeWindows.clear();
|
this.ultracodeWindows.clear();
|
||||||
|
for (const [, info] of this.ultracodeAgentWindows) {
|
||||||
|
this._teardownUltracodeDrag(info.dragListeners);
|
||||||
|
if (info.element) info.element.remove();
|
||||||
|
}
|
||||||
|
this.ultracodeAgentWindows.clear();
|
||||||
for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t);
|
for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t);
|
||||||
this.ultracodeWindowCloseTimers.clear();
|
this.ultracodeWindowCloseTimers.clear();
|
||||||
|
this.minimizedUltracodeRuns.clear();
|
||||||
|
this.minimizedUltracodeAgents.clear();
|
||||||
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
|
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
|
||||||
if (had) this.updateConnectionLines();
|
if (had) this.updateConnectionLines();
|
||||||
|
// Drop any now-stale tab badges.
|
||||||
|
if (hadMinimized) this.renderSessionTabs();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** When the feature is toggled on, pop windows for any currently-active runs. */
|
/** When the feature is toggled on, pop windows for any currently-active runs. */
|
||||||
@@ -355,7 +745,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const header =
|
const header =
|
||||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
||||||
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
|
return header + group.map((a) => this._workflowAgentCardHtml(a, run.runId)).join('');
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
return head + grid;
|
return head + grid;
|
||||||
@@ -408,4 +798,51 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
svg.appendChild(line);
|
svg.appendChild(line);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append agent-window → parent connector lines into the shared SVG. Parent is the
|
||||||
|
* agent's run floating window when open, else the run's session tab. Called right
|
||||||
|
* after `_appendUltracodeConnectionLines` so it shares the same batched pass + the
|
||||||
|
* tab-rect cache.
|
||||||
|
*/
|
||||||
|
_appendUltracodeAgentConnectionLines(svg, rects) {
|
||||||
|
this._ensureUltracodeWindowState();
|
||||||
|
if (!svg || !this.ultracodeAgentWindows.size) return;
|
||||||
|
if (!rects) rects = new Map();
|
||||||
|
|
||||||
|
for (const [agentId, info] of this.ultracodeAgentWindows) {
|
||||||
|
if (!info.element) continue;
|
||||||
|
const winRect = info.element.getBoundingClientRect();
|
||||||
|
// Anchor: parent run window bottom-center if open, else the run's tab.
|
||||||
|
let px, py;
|
||||||
|
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
|
||||||
|
if (runWin && runWin.element) {
|
||||||
|
const pr = runWin.element.getBoundingClientRect();
|
||||||
|
px = pr.left + pr.width / 2;
|
||||||
|
py = pr.bottom;
|
||||||
|
} else {
|
||||||
|
const summary = info.runId && this.workflowRuns ? this.workflowRuns.get(info.runId) : null;
|
||||||
|
const parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||||
|
if (!parentSessionId) continue;
|
||||||
|
const tabKey = 'tab:' + parentSessionId;
|
||||||
|
if (!rects.has(tabKey)) {
|
||||||
|
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
||||||
|
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
const tabRect = rects.get(tabKey);
|
||||||
|
if (!tabRect) continue;
|
||||||
|
px = tabRect.left + tabRect.width / 2;
|
||||||
|
py = tabRect.bottom;
|
||||||
|
}
|
||||||
|
const x2 = winRect.left + winRect.width / 2;
|
||||||
|
const y2 = winRect.top;
|
||||||
|
const midY = (py + y2) / 2;
|
||||||
|
const path = `M ${px} ${py} C ${px} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||||
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
line.setAttribute('d', path);
|
||||||
|
line.setAttribute('class', 'connection-line ultracode-connection ultracode-agent-connection');
|
||||||
|
line.setAttribute('data-agent-id', agentId);
|
||||||
|
svg.appendChild(line);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -17,4 +17,5 @@ export { registerRalphRoutes } from './ralph-routes.js';
|
|||||||
export { registerPlanRoutes } from './plan-routes.js';
|
export { registerPlanRoutes } from './plan-routes.js';
|
||||||
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||||
export { registerClipboardRoutes } from './clipboard-routes.js';
|
export { registerClipboardRoutes } from './clipboard-routes.js';
|
||||||
|
export { registerSearchRoutes } from './search-routes.js';
|
||||||
export { registerWsRoutes } from './ws-routes.js';
|
export { registerWsRoutes } from './ws-routes.js';
|
||||||
@@ -32,17 +32,16 @@ export function registerRalphRoutes(
|
|||||||
// Configure Ralph tracker for a session
|
// Configure Ralph tracker for a session
|
||||||
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = parseBody(
|
const { enabled, completionPhrase, maxIterations, maxTodos, todoExpirationMinutes, reset, disableAutoEnable } =
|
||||||
RalphConfigSchema,
|
parseBody(RalphConfigSchema, req.body, 'Invalid request body') as {
|
||||||
req.body,
|
enabled?: boolean;
|
||||||
'Invalid request body'
|
completionPhrase?: string;
|
||||||
) as {
|
maxIterations?: number;
|
||||||
enabled?: boolean;
|
maxTodos?: number;
|
||||||
completionPhrase?: string;
|
todoExpirationMinutes?: number;
|
||||||
maxIterations?: number;
|
reset?: boolean | 'full';
|
||||||
reset?: boolean | 'full';
|
disableAutoEnable?: boolean;
|
||||||
disableAutoEnable?: boolean;
|
};
|
||||||
};
|
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
||||||
@@ -98,6 +97,14 @@ export function registerRalphRoutes(
|
|||||||
session.ralphTracker.setMaxIterations(maxIterations || null);
|
session.ralphTracker.setMaxIterations(maxIterations || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (maxTodos !== undefined) {
|
||||||
|
session.ralphTracker.setMaxTodos(maxTodos);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todoExpirationMinutes !== undefined) {
|
||||||
|
session.ralphTracker.setTodoExpirationMinutes(todoExpirationMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
// Persist and broadcast the update
|
// Persist and broadcast the update
|
||||||
ctx.persistSessionState(session);
|
ctx.persistSessionState(session);
|
||||||
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Cross-session federated search route (COD-9).
|
||||||
|
*
|
||||||
|
* Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search
|
||||||
|
* across three v1 sources, returned in the standard ApiResponse envelope:
|
||||||
|
* 1. sessions/cases — name, working directory, session id
|
||||||
|
* 2. run-summary events — event title/details (from the live run-summary trackers)
|
||||||
|
* 3. file paths — per-session attachment history (workspace-relative paths only)
|
||||||
|
*
|
||||||
|
* This route is a THIN wrapper: it harvests the source arrays from the live
|
||||||
|
* server stores (held on the route context) in a bounded way, then delegates
|
||||||
|
* grouping/ranking/capping to the pure `searchSources()` core in
|
||||||
|
* `src/search-service.ts`. Terminal-buffer scanning and any persisted index are
|
||||||
|
* out of scope for v1.
|
||||||
|
*
|
||||||
|
* Safety: query input is Zod-validated (length-bounded `q`, allowlisted `types`,
|
||||||
|
* numeric `limit`); only workspace-relative file paths are ever exposed (the
|
||||||
|
* server-private `externalPath` on attachment history is never read here); and
|
||||||
|
* the pure core enforces a per-group and total result cap so a broad query
|
||||||
|
* cannot return an unbounded payload. No terminal output is read.
|
||||||
|
*
|
||||||
|
* Endpoints: GET /api/search
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { parseBody } from '../route-helpers.js';
|
||||||
|
import { SearchQuerySchema } from '../schemas.js';
|
||||||
|
import {
|
||||||
|
searchSources,
|
||||||
|
type SearchSources,
|
||||||
|
type SessionSearchInput,
|
||||||
|
type EventSearchInput,
|
||||||
|
type FileSearchInput,
|
||||||
|
} from '../../search-service.js';
|
||||||
|
import type { SearchSourceType } from '../../types/search.js';
|
||||||
|
import type { SessionPort, InfraPort } from '../ports/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-source harvest caps. These bound how much in-memory data we hand to the
|
||||||
|
* pure core BEFORE it applies its own result caps — they keep the harvest itself
|
||||||
|
* cheap on large deployments (e.g. 50 sessions × many events). They are
|
||||||
|
* deliberately well above the result caps so ranking still sees enough candidates.
|
||||||
|
*/
|
||||||
|
const MAX_EVENTS_PER_SESSION = 500;
|
||||||
|
|
||||||
|
interface SessionLike {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workingDir: string;
|
||||||
|
lastActivityAt?: number;
|
||||||
|
createdAt?: number;
|
||||||
|
attachmentHistory?: Array<{
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
relativePath?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
mtimeMs?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harvest the three source arrays from the live in-memory stores. Reads only
|
||||||
|
* bounded, already-loaded data — no disk I/O, no terminal buffers.
|
||||||
|
*/
|
||||||
|
function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
|
||||||
|
const sessions: SessionSearchInput[] = [];
|
||||||
|
const events: EventSearchInput[] = [];
|
||||||
|
const files: FileSearchInput[] = [];
|
||||||
|
|
||||||
|
for (const raw of ctx.sessions.values()) {
|
||||||
|
const s = raw as unknown as SessionLike;
|
||||||
|
const sessionName = s.name ?? '';
|
||||||
|
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
sessionId: s.id,
|
||||||
|
sessionName,
|
||||||
|
workingDir: s.workingDir ?? '',
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Files: per-session attachment history. Only the workspace-relative path is
|
||||||
|
// surfaced; the server-private externalPath is intentionally never read.
|
||||||
|
const history = s.attachmentHistory ?? [];
|
||||||
|
for (const item of history) {
|
||||||
|
files.push({
|
||||||
|
sessionId: s.id,
|
||||||
|
sessionName,
|
||||||
|
fileName: item.fileName,
|
||||||
|
relativePath: item.relativePath,
|
||||||
|
timestamp: item.timestamp ?? item.mtimeMs ?? timestamp,
|
||||||
|
itemId: item.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events: from the live run-summary trackers, keyed by session id.
|
||||||
|
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
|
||||||
|
const session = ctx.sessions.get(sessionId) as unknown as SessionLike | undefined;
|
||||||
|
const sessionName = session?.name ?? '';
|
||||||
|
const summary = tracker.getSummary();
|
||||||
|
// Newest events are most relevant; cap the per-session harvest.
|
||||||
|
const evts = summary.events.slice(-MAX_EVENTS_PER_SESSION);
|
||||||
|
for (const e of evts) {
|
||||||
|
events.push({
|
||||||
|
sessionId,
|
||||||
|
sessionName,
|
||||||
|
eventId: e.id,
|
||||||
|
title: e.title,
|
||||||
|
details: e.details ?? '',
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessions, events, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void {
|
||||||
|
app.get('/api/search', async (req) => {
|
||||||
|
// Zod-validate the query. parseBody throws a structured 400 on failure.
|
||||||
|
const { q, types, limit } = parseBody(SearchQuerySchema, req.query);
|
||||||
|
|
||||||
|
const allowed: Set<SearchSourceType> | null = types
|
||||||
|
? new Set(
|
||||||
|
types
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean) as SearchSourceType[]
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sources = harvestSources(ctx);
|
||||||
|
|
||||||
|
// Apply the optional source-type filter before searching so excluded
|
||||||
|
// sources never contribute to (or consume budget in) the result set.
|
||||||
|
const filtered: SearchSources = {
|
||||||
|
sessions: !allowed || allowed.has('session') ? sources.sessions : [],
|
||||||
|
events: !allowed || allowed.has('event') ? sources.events : [],
|
||||||
|
files: !allowed || allowed.has('file') ? sources.files : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = searchSources(q, filtered);
|
||||||
|
|
||||||
|
// Optional caller-supplied total cap (always on top of the core's hard caps).
|
||||||
|
if (limit !== undefined && result.totalResults > limit) {
|
||||||
|
let remaining = limit;
|
||||||
|
const cappedGroups = [];
|
||||||
|
for (const group of result.groups) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
const slice = group.results.slice(0, remaining);
|
||||||
|
remaining -= slice.length;
|
||||||
|
cappedGroups.push({ type: group.type, results: slice });
|
||||||
|
}
|
||||||
|
result.groups = cappedGroups;
|
||||||
|
result.totalResults = limit;
|
||||||
|
result.truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
|||||||
import { RunSummaryTracker } from '../../run-summary.js';
|
import { RunSummaryTracker } from '../../run-summary.js';
|
||||||
|
|
||||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||||
|
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||||
import { dataPath } from '../../config/instance.js';
|
import { dataPath } from '../../config/instance.js';
|
||||||
|
|
||||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||||
@@ -286,12 +287,14 @@ export function registerSessionRoutes(
|
|||||||
//
|
//
|
||||||
// For keys the caller is actively setting, strip any stale disk entry a prior
|
// For keys the caller is actively setting, strip any stale disk entry a prior
|
||||||
// Codeman version may have written. Scope limited to:
|
// Codeman version may have written. Scope limited to:
|
||||||
// - Claude mode (OpenCode doesn't read .claude/settings.local.json)
|
// - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
|
||||||
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
|
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
|
||||||
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
|
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
|
||||||
// can target, because those may have hand-authored values).
|
// can target, because those may have hand-authored values).
|
||||||
const canStripDisk =
|
const canStripDisk =
|
||||||
body.mode !== 'opencode' &&
|
body.mode !== 'opencode' &&
|
||||||
|
body.mode !== 'codex' &&
|
||||||
|
body.mode !== 'gemini' &&
|
||||||
body.envOverrides &&
|
body.envOverrides &&
|
||||||
Object.keys(body.envOverrides).length > 0 &&
|
Object.keys(body.envOverrides).length > 0 &&
|
||||||
workingDir.startsWith(CASES_DIR + '/');
|
workingDir.startsWith(CASES_DIR + '/');
|
||||||
@@ -348,6 +351,17 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check Gemini availability if requested
|
||||||
|
if (body.mode === 'gemini') {
|
||||||
|
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||||
|
if (!isGeminiAvailable()) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||||
// "No conversation found" errors from Claude CLI.
|
// "No conversation found" errors from Claude CLI.
|
||||||
@@ -386,10 +400,13 @@ export function registerSessionRoutes(
|
|||||||
? body.openCodeConfig?.model
|
? body.openCodeConfig?.model
|
||||||
: mode === 'codex'
|
: mode === 'codex'
|
||||||
? body.codexConfig?.model
|
? body.codexConfig?.model
|
||||||
: mode !== 'shell'
|
: mode === 'gemini'
|
||||||
? modelConfig?.defaultModel || undefined
|
? body.geminiConfig?.model
|
||||||
: undefined;
|
: mode !== 'shell'
|
||||||
|
? modelConfig?.defaultModel || undefined
|
||||||
|
: undefined;
|
||||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
|
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir,
|
workingDir,
|
||||||
mode,
|
mode,
|
||||||
@@ -402,9 +419,11 @@ export function registerSessionRoutes(
|
|||||||
allowedTools: claudeModeConfig.allowedTools,
|
allowedTools: claudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||||
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
|
||||||
|
geminiConfig: mode === 'gemini' ? body.geminiConfig : undefined,
|
||||||
resumeSessionId: validatedResumeId,
|
resumeSessionId: validatedResumeId,
|
||||||
envOverrides: body.envOverrides,
|
envOverrides: body.envOverrides,
|
||||||
effort: body.effort,
|
effort: body.effort,
|
||||||
|
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.addSession(session);
|
ctx.addSession(session);
|
||||||
@@ -600,9 +619,11 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
||||||
// Ralph tracker is not supported for opencode sessions
|
// Ralph tracker is not supported for opencode / codex / gemini sessions
|
||||||
if (
|
if (
|
||||||
session.mode !== 'opencode' &&
|
session.mode !== 'opencode' &&
|
||||||
|
session.mode !== 'codex' &&
|
||||||
|
session.mode !== 'gemini' &&
|
||||||
ctx.store.getConfig().ralphEnabled &&
|
ctx.store.getConfig().ralphEnabled &&
|
||||||
!session.ralphTracker.autoEnableDisabled
|
!session.ralphTracker.autoEnableDisabled
|
||||||
) {
|
) {
|
||||||
@@ -662,7 +683,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.post('/api/sessions/:id/input', async (req) => {
|
app.post('/api/sessions/:id/input', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
|
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
const inputStr = String(input);
|
const inputStr = String(input);
|
||||||
@@ -673,6 +694,13 @@ export function registerSessionRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
|
||||||
|
// client's ACK, so a tagged duplicate redelivery must still return 200 but
|
||||||
|
// skip the write. Untagged requests (curl/legacy) always apply.
|
||||||
|
if (typeof clientId === 'string' && typeof seq === 'number' && !session.shouldApplyInput(clientId, seq)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||||||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||||||
if (useMux) {
|
if (useMux) {
|
||||||
@@ -1227,6 +1255,7 @@ export function registerSessionRoutes(
|
|||||||
mode = 'claude',
|
mode = 'claude',
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
} = parseBody(QuickStartSchema, req.body);
|
} = parseBody(QuickStartSchema, req.body);
|
||||||
@@ -1253,6 +1282,17 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check Gemini availability if requested
|
||||||
|
if (mode === 'gemini') {
|
||||||
|
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||||
|
if (!isGeminiAvailable()) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||||
// external project directories are honoured by quick-start just like regular case routes.
|
// external project directories are honoured by quick-start just like regular case routes.
|
||||||
@@ -1281,8 +1321,8 @@ export function registerSessionRoutes(
|
|||||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||||
|
|
||||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||||
// (Claude-specific — OpenCode uses its own plugin system)
|
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
|
||||||
if (mode !== 'opencode') {
|
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||||
await writeHooksConfig(casePath);
|
await writeHooksConfig(casePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1299,7 +1339,13 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||||
// see POST /api/sessions for full rationale).
|
// see POST /api/sessions for full rationale).
|
||||||
if (mode !== 'opencode' && envOverrides && Object.keys(envOverrides).length > 0) {
|
if (
|
||||||
|
mode !== 'opencode' &&
|
||||||
|
mode !== 'codex' &&
|
||||||
|
mode !== 'gemini' &&
|
||||||
|
envOverrides &&
|
||||||
|
Object.keys(envOverrides).length > 0
|
||||||
|
) {
|
||||||
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
|
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1312,10 +1358,13 @@ export function registerSessionRoutes(
|
|||||||
? openCodeConfig?.model
|
? openCodeConfig?.model
|
||||||
: mode === 'codex'
|
: mode === 'codex'
|
||||||
? codexConfig?.model
|
? codexConfig?.model
|
||||||
: mode !== 'shell'
|
: mode === 'gemini'
|
||||||
? qsModelConfig?.defaultModel || undefined
|
? geminiConfig?.model
|
||||||
: undefined;
|
: mode !== 'shell'
|
||||||
|
? qsModelConfig?.defaultModel || undefined
|
||||||
|
: undefined;
|
||||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: casePath,
|
workingDir: casePath,
|
||||||
mux: ctx.mux,
|
mux: ctx.mux,
|
||||||
@@ -1327,8 +1376,10 @@ export function registerSessionRoutes(
|
|||||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||||
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
codexConfig: mode === 'codex' ? codexConfig : undefined,
|
||||||
|
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
|
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||||
@@ -1365,7 +1416,7 @@ export function registerSessionRoutes(
|
|||||||
});
|
});
|
||||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
|
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
|
||||||
} else {
|
} else {
|
||||||
// Both 'claude' and 'opencode' modes use startInteractive()
|
// 'claude', 'opencode', 'codex', and 'gemini' modes use startInteractive()
|
||||||
await session.startInteractive();
|
await session.startInteractive();
|
||||||
getLifecycleLog().log({
|
getLifecycleLog().log({
|
||||||
event: 'started',
|
event: 'started',
|
||||||
@@ -1703,7 +1754,7 @@ export function registerSessionRoutes(
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
|
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
|
||||||
// The 10MB size cap is enforced by @fastify/multipart (registered in server.ts).
|
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
||||||
|
|
||||||
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||||||
// CSRF defense: state-changing routes must come from same origin.
|
// CSRF defense: state-changing routes must come from same origin.
|
||||||
@@ -1740,7 +1791,7 @@ export function registerSessionRoutes(
|
|||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|
||||||
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
|
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
|
||||||
// — even an authenticated attacker can otherwise loop 10MB POSTs.
|
// — even an authenticated attacker can otherwise loop large image POSTs.
|
||||||
if (!consumePasteToken(`${req.ip}:${id}`)) {
|
if (!consumePasteToken(`${req.ip}:${id}`)) {
|
||||||
reply.code(429);
|
reply.code(429);
|
||||||
reply.header('Retry-After', '60');
|
reply.header('Retry-After', '60');
|
||||||
@@ -1754,8 +1805,9 @@ export function registerSessionRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the single file part. @fastify/multipart enforces the 10MB size cap
|
// Read the single file part. @fastify/multipart enforces the per-file size
|
||||||
// and the 1-file/4-field count limits (server.ts), replacing a hand-rolled
|
// cap (MAX_PASTE_IMAGE_BYTES) and the 1-file/4-field count limits (server.ts),
|
||||||
|
// replacing a hand-rolled
|
||||||
// boundary scanner with several bugs: literal boundary matches anywhere in
|
// boundary scanner with several bugs: literal boundary matches anywhere in
|
||||||
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
|
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
|
||||||
// offsets), no part-count cap.
|
// offsets), no part-count cap.
|
||||||
@@ -1779,7 +1831,8 @@ export function registerSessionRoutes(
|
|||||||
imageBytes = await part.toBuffer();
|
imageBytes = await part.toBuffer();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
reply.code(413);
|
reply.code(413);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'File too large (max 10MB)');
|
const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024));
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || `File too large (max ${maxMb}MB)`);
|
||||||
}
|
}
|
||||||
if (imageBytes.length === 0) {
|
if (imageBytes.length === 0) {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
@@ -1843,9 +1896,22 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||||
// Non-recursive mkdir: errors on EEXIST and does not follow symlinks for
|
// Non-recursive mkdir: does not follow symlinks for the leaf.
|
||||||
// the leaf. session.workingDir is guaranteed to exist (live session).
|
// session.workingDir is guaranteed to exist (live session).
|
||||||
await fs.mkdir(imageDir);
|
try {
|
||||||
|
await fs.mkdir(imageDir);
|
||||||
|
} catch (mkErr: unknown) {
|
||||||
|
// Concurrent uploads (a batch of photos) race to create .claude-images —
|
||||||
|
// the losers get EEXIST. Treat an already-present REAL directory as
|
||||||
|
// success, but re-verify it isn't a symlink a racing actor planted
|
||||||
|
// (preserve the symlink-safety guarantee above).
|
||||||
|
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
|
||||||
|
const raceStat = await fs.lstat(imageDir);
|
||||||
|
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
|
||||||
|
reply.code(403);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
||||||
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ import { imageWatcher } from '../../image-watcher.js';
|
|||||||
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
|
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
|
||||||
import { applyStatusLineConfig } from '../../hooks-config.js';
|
import { applyStatusLineConfig } from '../../hooks-config.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
|
import {
|
||||||
|
buildAwayDigest,
|
||||||
|
resolveAwayDigestRange,
|
||||||
|
type AwayDigestSession,
|
||||||
|
type AwayDigestSubagent,
|
||||||
|
} from '../away-digest.js';
|
||||||
import {
|
import {
|
||||||
findSessionOrFail,
|
findSessionOrFail,
|
||||||
formatUptime,
|
formatUptime,
|
||||||
@@ -43,6 +49,7 @@ import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '..
|
|||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||||
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
||||||
|
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
|
||||||
|
|
||||||
// Maximum screenshot upload size (10MB)
|
// Maximum screenshot upload size (10MB)
|
||||||
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
||||||
@@ -52,6 +59,12 @@ const SCREENSHOTS_DIR = dataPath('screenshots');
|
|||||||
/** Cached CPU count — doesn't change at runtime */
|
/** Cached CPU count — doesn't change at runtime */
|
||||||
const CPU_COUNT = cpus().length;
|
const CPU_COUNT = cpus().length;
|
||||||
|
|
||||||
|
function parseOptionalNumber(value: string | undefined): number | undefined {
|
||||||
|
if (value === undefined || value.trim() === '') return undefined;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get system CPU and memory usage */
|
/** Get system CPU and memory usage */
|
||||||
function getSystemStats(): {
|
function getSystemStats(): {
|
||||||
cpu: number;
|
cpu: number;
|
||||||
@@ -331,7 +344,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// CLI Integrations (OpenCode)
|
// CLI Integrations (OpenCode, Codex, Gemini)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ========== OpenCode ==========
|
// ========== OpenCode ==========
|
||||||
@@ -352,6 +365,16 @@ export function registerSystemRoutes(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== Gemini ==========
|
||||||
|
|
||||||
|
app.get('/api/gemini/status', async () => {
|
||||||
|
const { isGeminiAvailable, resolveGeminiDir } = await import('../../utils/gemini-cli-resolver.js');
|
||||||
|
return {
|
||||||
|
available: isGeminiAvailable(),
|
||||||
|
path: resolveGeminiDir(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -415,6 +438,56 @@ export function registerSystemRoutes(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/away-digest', async (req, reply) => {
|
||||||
|
const query = req.query as {
|
||||||
|
range?: string;
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
lastViewed?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let range;
|
||||||
|
try {
|
||||||
|
range = resolveAwayDigestRange({
|
||||||
|
range: query.range,
|
||||||
|
since: parseOptionalNumber(query.since),
|
||||||
|
until: parseOptionalNumber(query.until),
|
||||||
|
lastViewed: parseOptionalNumber(query.lastViewed),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(400);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycleLog = getLifecycleLog();
|
||||||
|
const lifecycleEntries = await lifecycleLog.query({
|
||||||
|
since: range.since,
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
name: session.name,
|
||||||
|
status: session.status,
|
||||||
|
inputTokens: session.inputTokens,
|
||||||
|
outputTokens: session.outputTokens,
|
||||||
|
totalCost: session.totalCost,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
|
||||||
|
const digest = buildAwayDigest({
|
||||||
|
range,
|
||||||
|
lifecycleEntries,
|
||||||
|
runSummaries,
|
||||||
|
sessions,
|
||||||
|
dailyTokenStats: ctx.store.getDailyStats(30),
|
||||||
|
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
|
||||||
|
now: range.until,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, digest };
|
||||||
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Configuration & Settings (config, settings, model config, CPU priority)
|
// Configuration & Settings (config, settings, model config, CPU priority)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -552,6 +625,11 @@ export function registerSystemRoutes(
|
|||||||
const merged = { ...existing, ...settingsToStore };
|
const merged = { ...existing, ...settingsToStore };
|
||||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||||
|
|
||||||
|
// Apply a changed tmux history-limit to all live sessions immediately.
|
||||||
|
if (settings.tmuxHistoryLimit !== undefined) {
|
||||||
|
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle subagent tracking toggle dynamically
|
// Handle subagent tracking toggle dynamically
|
||||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,11 @@
|
|||||||
* {"t":"o","d":"..."} — terminal output
|
* {"t":"o","d":"..."} — terminal output
|
||||||
* {"t":"c"} — clear terminal
|
* {"t":"c"} — clear terminal
|
||||||
* {"t":"r"} — needs refresh (reload buffer)
|
* {"t":"r"} — needs refresh (reload buffer)
|
||||||
|
* {"t":"ia","seq":N} — input ACK (echoes the seq of an applied/deduped input frame)
|
||||||
* Client -> Server:
|
* Client -> Server:
|
||||||
* {"t":"i","d":"..."} — input (keystroke or paste)
|
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
|
||||||
|
* optional reliable-delivery tags: the server applies each
|
||||||
|
* (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}.
|
||||||
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
|
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -123,10 +126,22 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
|||||||
const msg = JSON.parse(String(raw));
|
const msg = JSON.parse(String(raw));
|
||||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
// Reliable delivery: when the frame carries a clientId + seq, apply it
|
||||||
// and re-asserts the desktop layout after a mobile override.
|
// exactly once (skip a duplicate redelivery) but ACK it regardless so
|
||||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
// the client can drop it from its durable queue. Frames without seq
|
||||||
session.write(msg.d);
|
// (legacy/other tools) are applied as-is — no behavior change.
|
||||||
|
const cid = typeof msg.cid === 'string' ? msg.cid : null;
|
||||||
|
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
|
||||||
|
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
|
||||||
|
if (apply) {
|
||||||
|
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||||
|
// and re-asserts the desktop layout after a mobile override.
|
||||||
|
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||||
|
session.write(msg.d);
|
||||||
|
}
|
||||||
|
if (seq !== null && socket.readyState === 1) {
|
||||||
|
socket.send(`{"t":"ia","seq":${seq}}`);
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
msg.t === 'z' &&
|
msg.t === 'z' &&
|
||||||
Number.isInteger(msg.c) &&
|
Number.isInteger(msg.c) &&
|
||||||
|
|||||||
+96
-5
@@ -9,6 +9,12 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||||
|
import {
|
||||||
|
MAX_TERMINAL_BUFFER_BYTES,
|
||||||
|
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MIN_TERMINAL_BUFFER_BYTES,
|
||||||
|
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
} from '../config/terminal-history.js';
|
||||||
|
|
||||||
// ========== Path Validation ==========
|
// ========== Path Validation ==========
|
||||||
|
|
||||||
@@ -46,7 +52,7 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
|||||||
// ========== Env Var Allowlist ==========
|
// ========== Env Var Allowlist ==========
|
||||||
|
|
||||||
/** Allowlisted env var key prefixes */
|
/** Allowlisted env var key prefixes */
|
||||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_'];
|
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_'];
|
||||||
|
|
||||||
/** Env var keys that are always blocked (security-sensitive) */
|
/** Env var keys that are always blocked (security-sensitive) */
|
||||||
const BLOCKED_ENV_KEYS = new Set([
|
const BLOCKED_ENV_KEYS = new Set([
|
||||||
@@ -76,7 +82,7 @@ const safeEnvOverridesSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, and CODEX_* keys are allowed.',
|
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, and GOOGLE_* keys are allowed.',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -149,9 +155,26 @@ const CodexConfigSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
/** Schema for Gemini CLI-specific configuration */
|
||||||
|
const GeminiConfigSchema = z
|
||||||
|
.object({
|
||||||
|
model: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||||
|
.optional(),
|
||||||
|
approvalMode: z.enum(['default', 'auto_edit', 'yolo', 'plan']).optional(),
|
||||||
|
resumeSession: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9._-]+$/)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const CreateSessionSchema = z.object({
|
export const CreateSessionSchema = z.object({
|
||||||
workingDir: safePathSchema.optional(),
|
workingDir: safePathSchema.optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||||
name: z.string().max(100).optional(),
|
name: z.string().max(100).optional(),
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
@@ -162,6 +185,7 @@ export const CreateSessionSchema = z.object({
|
|||||||
statusLineTelemetry: z.boolean().optional(),
|
statusLineTelemetry: z.boolean().optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
codexConfig: CodexConfigSchema,
|
codexConfig: CodexConfigSchema,
|
||||||
|
geminiConfig: GeminiConfigSchema,
|
||||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||||
resumeSessionId: z
|
resumeSessionId: z
|
||||||
.string()
|
.string()
|
||||||
@@ -258,9 +282,10 @@ export const QuickStartSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||||
.optional(),
|
.optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
codexConfig: CodexConfigSchema,
|
codexConfig: CodexConfigSchema,
|
||||||
|
geminiConfig: GeminiConfigSchema,
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort: effortLevelSchema,
|
effort: effortLevelSchema,
|
||||||
@@ -393,6 +418,16 @@ export const SettingsUpdateSchema = z
|
|||||||
allowedTools: z.string().max(2000).optional(),
|
allowedTools: z.string().max(2000).optional(),
|
||||||
// Codex CLI settings
|
// Codex CLI settings
|
||||||
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
||||||
|
// Terminal history and retention
|
||||||
|
terminalScrollbackLines: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(MIN_TERMINAL_SCROLLBACK_LINES)
|
||||||
|
.max(MAX_TERMINAL_SCROLLBACK_LINES)
|
||||||
|
.optional(),
|
||||||
|
tmuxHistoryLimit: z.number().int().min(MIN_TERMINAL_SCROLLBACK_LINES).max(MAX_TERMINAL_SCROLLBACK_LINES).optional(),
|
||||||
|
terminalBufferMaxBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(),
|
||||||
|
terminalBufferTrimBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(),
|
||||||
// CPU priority
|
// CPU priority
|
||||||
nice: z
|
nice: z
|
||||||
.object({
|
.object({
|
||||||
@@ -461,7 +496,20 @@ export const SettingsUpdateSchema = z
|
|||||||
.max(20)
|
.max(20)
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict()
|
||||||
|
.superRefine((settings, ctx) => {
|
||||||
|
if (
|
||||||
|
settings.terminalBufferMaxBytes !== undefined &&
|
||||||
|
settings.terminalBufferTrimBytes !== undefined &&
|
||||||
|
settings.terminalBufferTrimBytes > settings.terminalBufferMaxBytes
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['terminalBufferTrimBytes'],
|
||||||
|
message: 'terminalBufferTrimBytes must be less than or equal to terminalBufferMaxBytes',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for POST /api/sessions/:id/input with length limit
|
* Schema for POST /api/sessions/:id/input with length limit
|
||||||
@@ -469,6 +517,15 @@ export const SettingsUpdateSchema = z
|
|||||||
export const SessionInputWithLimitSchema = z.object({
|
export const SessionInputWithLimitSchema = z.object({
|
||||||
input: z.string().max(100000), // 100KB max input
|
input: z.string().max(100000), // 100KB max input
|
||||||
useMux: z.boolean().optional(),
|
useMux: z.boolean().optional(),
|
||||||
|
// Reliable-delivery dedup (optional; absent for curl/legacy clients). The web
|
||||||
|
// client tags each input with a stable clientId + a monotonic per-session seq
|
||||||
|
// and redelivers anything it hasn't seen ACKed (e.g. a frame silently dropped
|
||||||
|
// by a half-open WebSocket on a flaky link). The server applies each (clientId,
|
||||||
|
// seq) at-most-once via Session.shouldApplyInput so a redelivery can't type the
|
||||||
|
// prompt twice. `.optional()` (not `.nullish()`) — the client omits them when
|
||||||
|
// unset rather than sending null. See docs/reliable-input-delivery.md.
|
||||||
|
seq: z.number().int().nonnegative().optional(),
|
||||||
|
clientId: z.string().max(128).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== Session Mutation Routes ==========
|
// ========== Session Mutation Routes ==========
|
||||||
@@ -488,6 +545,8 @@ export const RalphConfigSchema = z.object({
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
completionPhrase: z.string().max(500).optional(),
|
completionPhrase: z.string().max(500).optional(),
|
||||||
maxIterations: z.number().int().min(0).max(10000).optional(),
|
maxIterations: z.number().int().min(0).max(10000).optional(),
|
||||||
|
maxTodos: z.number().int().positive().max(10000).optional(),
|
||||||
|
todoExpirationMinutes: z.number().int().positive().max(525600).optional(),
|
||||||
reset: z.union([z.boolean(), z.literal('full')]).optional(),
|
reset: z.union([z.boolean(), z.literal('full')]).optional(),
|
||||||
disableAutoEnable: z.boolean().optional(),
|
disableAutoEnable: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
@@ -699,3 +758,35 @@ export const OrchestratorStartSchema = z.object({
|
|||||||
export const OrchestratorRejectSchema = z.object({
|
export const OrchestratorRejectSchema = z.object({
|
||||||
feedback: z.string().min(1).max(10000),
|
feedback: z.string().min(1).max(10000),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== Cross-Session Search (COD-9) ==========
|
||||||
|
|
||||||
|
/** Valid federated source kinds for `GET /api/search?types=`. */
|
||||||
|
export const SEARCH_SOURCE_TYPES = ['session', 'event', 'file'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/search query validation.
|
||||||
|
*
|
||||||
|
* Query params arrive as strings: `q` is bounded (1..200 chars), `types` is an
|
||||||
|
* optional comma-separated allowlisted CSV, and `limit` is an optional coerced
|
||||||
|
* integer clamped to 1..60. Validation is the first line of defense — a missing
|
||||||
|
* or oversized `q`, an unknown type, or a non-numeric limit is rejected with 400.
|
||||||
|
*/
|
||||||
|
export const SearchQuerySchema = z.object({
|
||||||
|
q: z.string().trim().min(1, 'Query is required').max(200, 'Query too long (max 200 chars)'),
|
||||||
|
types: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(v) =>
|
||||||
|
v === undefined ||
|
||||||
|
v
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.every((t) => (SEARCH_SOURCE_TYPES as readonly string[]).includes(t)),
|
||||||
|
{ message: 'Invalid types value' }
|
||||||
|
),
|
||||||
|
limit: z.coerce.number().int().min(1).max(60).optional(),
|
||||||
|
});
|
||||||
+16
-2
@@ -128,6 +128,8 @@ import {
|
|||||||
} from '../utils/index.js';
|
} from '../utils/index.js';
|
||||||
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
||||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||||
|
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||||||
|
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
||||||
import { SseEvent } from './sse-events.js';
|
import { SseEvent } from './sse-events.js';
|
||||||
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
||||||
import type { ScheduledRun } from './ports/index.js';
|
import type { ScheduledRun } from './ports/index.js';
|
||||||
@@ -149,6 +151,7 @@ import {
|
|||||||
registerRalphRoutes,
|
registerRalphRoutes,
|
||||||
registerPlanRoutes,
|
registerPlanRoutes,
|
||||||
registerClipboardRoutes,
|
registerClipboardRoutes,
|
||||||
|
registerSearchRoutes,
|
||||||
registerOrchestratorRoutes,
|
registerOrchestratorRoutes,
|
||||||
registerWsRoutes,
|
registerWsRoutes,
|
||||||
} from './routes/index.js';
|
} from './routes/index.js';
|
||||||
@@ -585,6 +588,7 @@ export class WebServer extends EventEmitter {
|
|||||||
getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this),
|
getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this),
|
||||||
getModelConfig: this.getModelConfig.bind(this),
|
getModelConfig: this.getModelConfig.bind(this),
|
||||||
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
||||||
|
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
|
||||||
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
||||||
getLightState: this.getLightState.bind(this),
|
getLightState: this.getLightState.bind(this),
|
||||||
getLightSessionsState: this.getLightSessionsState.bind(this),
|
getLightSessionsState: this.getLightSessionsState.bind(this),
|
||||||
@@ -679,8 +683,8 @@ export class WebServer extends EventEmitter {
|
|||||||
// last byte (hard-coded \r\n offsets), and there was no part-count cap.
|
// last byte (hard-coded \r\n offsets), and there was no part-count cap.
|
||||||
await this.app.register(fastifyMultipart, {
|
await this.app.register(fastifyMultipart, {
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB per file
|
fileSize: MAX_PASTE_IMAGE_BYTES, // per file (default 50MB) — large phone photos / screenshots
|
||||||
files: 1, // paste-image only ever sends one file
|
files: 1, // paste-image sends one file per request (clients batch up to 20 requests)
|
||||||
fields: 4, // small headroom for accompanying form fields
|
fields: 4, // small headroom for accompanying form fields
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -869,6 +873,7 @@ export class WebServer extends EventEmitter {
|
|||||||
registerRalphRoutes(this.app, ctx);
|
registerRalphRoutes(this.app, ctx);
|
||||||
registerPlanRoutes(this.app, ctx);
|
registerPlanRoutes(this.app, ctx);
|
||||||
registerClipboardRoutes(this.app, ctx);
|
registerClipboardRoutes(this.app, ctx);
|
||||||
|
registerSearchRoutes(this.app, ctx);
|
||||||
registerOrchestratorRoutes(this.app, ctx);
|
registerOrchestratorRoutes(this.app, ctx);
|
||||||
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
|
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
|
||||||
}
|
}
|
||||||
@@ -1460,6 +1465,12 @@ export class WebServer extends EventEmitter {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the bounds-clamped terminal-history config from settings.json.
|
||||||
|
private async getTerminalHistoryConfig() {
|
||||||
|
const settings = await this.readSettings();
|
||||||
|
return resolveTerminalHistoryConfig(settings);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get model configuration from settings
|
// Helper to get model configuration from settings
|
||||||
private async getModelConfig(): Promise<{
|
private async getModelConfig(): Promise<{
|
||||||
defaultModel?: string;
|
defaultModel?: string;
|
||||||
@@ -2123,6 +2134,9 @@ export class WebServer extends EventEmitter {
|
|||||||
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
||||||
claudeMode: recoveryClaudeMode.claudeMode,
|
claudeMode: recoveryClaudeMode.claudeMode,
|
||||||
allowedTools: recoveryClaudeMode.allowedTools,
|
allowedTools: recoveryClaudeMode.allowedTools,
|
||||||
|
openCodeConfig: muxSession.mode === 'opencode' ? savedState?.openCodeConfig : undefined,
|
||||||
|
codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined,
|
||||||
|
geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
|
||||||
envOverrides: savedEnvOverrides,
|
envOverrides: savedEnvOverrides,
|
||||||
effort: savedState?.effort,
|
effort: savedState?.effort,
|
||||||
attachmentHistory: savedAttachmentHistory,
|
attachmentHistory: savedAttachmentHistory,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const indexHtml = readFileSync(join(process.cwd(), 'src/web/public/index.html'), 'utf-8');
|
||||||
|
const panelsJs = readFileSync(join(process.cwd(), 'src/web/public/panels-ui.js'), 'utf-8');
|
||||||
|
const stylesCss = readFileSync(join(process.cwd(), 'src/web/public/styles.css'), 'utf-8');
|
||||||
|
const mobileCss = readFileSync(join(process.cwd(), 'src/web/public/mobile.css'), 'utf-8');
|
||||||
|
|
||||||
|
describe('away digest UI', () => {
|
||||||
|
it('exposes a header entry point and modal shell', () => {
|
||||||
|
expect(indexHtml).toContain('onclick="app.openAwayDigest()"');
|
||||||
|
expect(indexHtml).toContain('aria-label="Open away digest"');
|
||||||
|
expect(indexHtml).toContain('id="awayDigestModal"');
|
||||||
|
expect(indexHtml).toContain('id="awayDigestSummary"');
|
||||||
|
expect(indexHtml).toContain('id="awayDigestSections"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers supported range controls', () => {
|
||||||
|
expect(indexHtml).toContain('data-away-range="since-last-visit"');
|
||||||
|
expect(indexHtml).toContain('data-away-range="1h"');
|
||||||
|
expect(indexHtml).toContain('data-away-range="today"');
|
||||||
|
expect(indexHtml).toContain('data-away-range="24h"');
|
||||||
|
expect(indexHtml).toContain('id="awayDigestCustomSince"');
|
||||||
|
expect(indexHtml).toContain('id="awayDigestCustomUntil"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the aggregate endpoint and preserves since-last-visit until successful close', () => {
|
||||||
|
expect(panelsJs).toContain('/api/away-digest');
|
||||||
|
expect(panelsJs).toContain('codeman-away-digest-last-viewed');
|
||||||
|
expect(panelsJs).toContain('openAwayDigest');
|
||||||
|
expect(panelsJs).toContain('closeAwayDigest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has desktop and mobile layout styles', () => {
|
||||||
|
expect(stylesCss).toContain('.away-digest-modal');
|
||||||
|
expect(stylesCss).toContain('.away-digest-summary');
|
||||||
|
expect(stylesCss).toContain('.away-digest-item');
|
||||||
|
expect(mobileCss).toContain('.away-digest-modal');
|
||||||
|
expect(mobileCss).toContain('.away-digest-ranges');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildAwayDigest, resolveAwayDigestRange } from '../src/web/away-digest.js';
|
||||||
|
import type { LifecycleEntry, RunSummary } from '../src/types.js';
|
||||||
|
|
||||||
|
const NOW = Date.UTC(2026, 5, 7, 12, 0, 0);
|
||||||
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function summary(sessionId: string, events: RunSummary['events']): RunSummary {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
sessionName: `${sessionId} name`,
|
||||||
|
startedAt: NOW - 4 * HOUR,
|
||||||
|
lastUpdatedAt: NOW - HOUR,
|
||||||
|
events,
|
||||||
|
stats: {
|
||||||
|
totalRespawnCycles: 0,
|
||||||
|
totalTokensUsed: 0,
|
||||||
|
peakTokens: 0,
|
||||||
|
totalTimeActiveMs: 0,
|
||||||
|
totalTimeIdleMs: 0,
|
||||||
|
errorCount: events.filter((event) => event.severity === 'error').length,
|
||||||
|
warningCount: events.filter((event) => event.severity === 'warning').length,
|
||||||
|
aiCheckCount: 0,
|
||||||
|
lastIdleAt: null,
|
||||||
|
lastWorkingAt: null,
|
||||||
|
stateTransitions: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('away digest', () => {
|
||||||
|
it('resolves since-last-visit after the stored marker and defaults to 24h', () => {
|
||||||
|
expect(resolveAwayDigestRange({ range: 'since-last-visit', lastViewed: NOW - HOUR, now: NOW })).toMatchObject({
|
||||||
|
range: 'since-last-visit',
|
||||||
|
since: NOW - HOUR,
|
||||||
|
until: NOW,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveAwayDigestRange({ range: 'since-last-visit', now: NOW })).toMatchObject({
|
||||||
|
since: NOW - 24 * HOUR,
|
||||||
|
until: NOW,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates action-required, completed, live, idle, and informational items', () => {
|
||||||
|
const lifecycleEntries: LifecycleEntry[] = [
|
||||||
|
{ ts: NOW - 10_000, event: 'exit', sessionId: 'failed', name: 'Failed Session', exitCode: 2 },
|
||||||
|
{ ts: NOW - 9_000, event: 'exit', sessionId: 'clean', name: 'Clean Exit', exitCode: 0 },
|
||||||
|
{ ts: NOW - 8_000, event: 'mux_died', sessionId: 'mux', name: 'Mux Session' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const digest = buildAwayDigest({
|
||||||
|
range: resolveAwayDigestRange({ range: '1h', now: NOW }),
|
||||||
|
lifecycleEntries,
|
||||||
|
runSummaries: [
|
||||||
|
summary('ralph', [
|
||||||
|
{
|
||||||
|
id: 'complete-1',
|
||||||
|
timestamp: NOW - 7_000,
|
||||||
|
type: 'ralph_completion',
|
||||||
|
severity: 'success',
|
||||||
|
title: 'Ralph completion detected',
|
||||||
|
details: 'Phrase: COMPLETE',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
summary('error-session', [
|
||||||
|
{
|
||||||
|
id: 'error-1',
|
||||||
|
timestamp: NOW - 6_000,
|
||||||
|
type: 'error',
|
||||||
|
severity: 'error',
|
||||||
|
title: 'Session error',
|
||||||
|
details: 'Tool failed',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
sessions: [
|
||||||
|
{ id: 'active', name: 'Active Session', status: 'working' },
|
||||||
|
{ id: 'idle', name: 'Idle Session', status: 'idle' },
|
||||||
|
],
|
||||||
|
dailyTokenStats: [
|
||||||
|
{
|
||||||
|
date: '2026-06-07',
|
||||||
|
inputTokens: 1_000,
|
||||||
|
outputTokens: 2_000,
|
||||||
|
estimatedCost: 0.18,
|
||||||
|
sessions: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subagents: [
|
||||||
|
{
|
||||||
|
id: 'agent-1',
|
||||||
|
sessionId: 'active',
|
||||||
|
description: 'Research complete',
|
||||||
|
status: 'completed',
|
||||||
|
lastUpdated: NOW - 5_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(digest.sections.needsAttention.map((item) => item.sessionId)).toEqual(['failed', 'mux', 'error-session']);
|
||||||
|
expect(digest.sections.completed.map((item) => item.sessionId)).toEqual(['ralph']);
|
||||||
|
expect(digest.sections.stillRunning.map((item) => item.sessionId)).toEqual(['active']);
|
||||||
|
expect(digest.sections.idle.map((item) => item.sessionId)).toEqual(['idle']);
|
||||||
|
expect(digest.sections.informational.map((item) => item.sessionId)).toContain('clean');
|
||||||
|
expect(digest.sections.informational.some((item) => item.source === 'subagent')).toBe(true);
|
||||||
|
expect(digest.totals).toMatchObject({
|
||||||
|
needsAttention: 3,
|
||||||
|
completed: 1,
|
||||||
|
activeSessions: 2,
|
||||||
|
inputTokens: 1_000,
|
||||||
|
outputTokens: 2_000,
|
||||||
|
estimatedCost: 0.18,
|
||||||
|
tokenWindowPrecision: 'day',
|
||||||
|
});
|
||||||
|
expect(digest.dataFreshness).toMatchObject({
|
||||||
|
lifecyclePersisted: true,
|
||||||
|
tokenStatsPersisted: true,
|
||||||
|
runSummariesLiveOnly: true,
|
||||||
|
subagentsLiveOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes entries outside the selected range', () => {
|
||||||
|
const digest = buildAwayDigest({
|
||||||
|
range: resolveAwayDigestRange({ range: '1h', now: NOW }),
|
||||||
|
lifecycleEntries: [
|
||||||
|
{ ts: NOW - 2 * HOUR, event: 'mux_died', sessionId: 'old' },
|
||||||
|
{ ts: NOW - 1000, event: 'mux_died', sessionId: 'recent' },
|
||||||
|
],
|
||||||
|
runSummaries: [],
|
||||||
|
sessions: [],
|
||||||
|
dailyTokenStats: [],
|
||||||
|
subagents: [],
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(digest.sections.needsAttention.map((item) => item.sessionId)).toEqual(['recent']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
|
||||||
|
|
||||||
|
describe('Gemini mode schemas', () => {
|
||||||
|
it('accepts Gemini session creation config', () => {
|
||||||
|
const parsed = CreateSessionSchema.parse({
|
||||||
|
workingDir: '/tmp',
|
||||||
|
mode: 'gemini',
|
||||||
|
geminiConfig: {
|
||||||
|
model: 'gemini-2.5-pro',
|
||||||
|
approvalMode: 'yolo',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.mode).toBe('gemini');
|
||||||
|
expect(parsed.geminiConfig).toEqual({
|
||||||
|
model: 'gemini-2.5-pro',
|
||||||
|
approvalMode: 'yolo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts Gemini quick-start config', () => {
|
||||||
|
const parsed = QuickStartSchema.parse({
|
||||||
|
caseName: 'gemini-case',
|
||||||
|
mode: 'gemini',
|
||||||
|
geminiConfig: {
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
approvalMode: 'auto_edit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.mode).toBe('gemini');
|
||||||
|
expect(parsed.geminiConfig?.model).toBe('gemini-2.5-flash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe Gemini model strings', () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateSessionSchema.parse({
|
||||||
|
workingDir: '/tmp',
|
||||||
|
mode: 'gemini',
|
||||||
|
geminiConfig: { model: 'gemini; rm -rf /' },
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { MockSession, createMockSession } from './mock-session.js';
|
import { MockSession, createMockSession } from './mock-session.js';
|
||||||
|
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a mock context that satisfies all port interfaces.
|
* Creates a mock context that satisfies all port interfaces.
|
||||||
@@ -76,6 +77,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
|||||||
getGlobalNiceConfig: vi.fn(async () => undefined),
|
getGlobalNiceConfig: vi.fn(async () => undefined),
|
||||||
getModelConfig: vi.fn(async () => null),
|
getModelConfig: vi.fn(async () => null),
|
||||||
getClaudeModeConfig: vi.fn(async () => ({})),
|
getClaudeModeConfig: vi.fn(async () => ({})),
|
||||||
|
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
|
||||||
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
||||||
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
||||||
getLightSessionsState: vi.fn(() => {
|
getLightSessionsState: vi.fn(() => {
|
||||||
@@ -99,6 +101,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
|||||||
getSession: vi.fn(() => null),
|
getSession: vi.fn(() => null),
|
||||||
clearRespawnConfig: vi.fn(),
|
clearRespawnConfig: vi.fn(),
|
||||||
updateRespawnConfig: vi.fn(),
|
updateRespawnConfig: vi.fn(),
|
||||||
|
setHistoryLimit: vi.fn(async () => {}),
|
||||||
},
|
},
|
||||||
runSummaryTrackers: new Map(),
|
runSummaryTrackers: new Map(),
|
||||||
activePlanOrchestrators: new Map(),
|
activePlanOrchestrators: new Map(),
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ export class MockSession extends EventEmitter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
|
||||||
|
* exercising the reliable-delivery path behave like production. */
|
||||||
|
private _appliedInputSeq = new Map<string, number>();
|
||||||
|
shouldApplyInput(clientId: string, seq: number): boolean {
|
||||||
|
const last = this._appliedInputSeq.get(clientId);
|
||||||
|
if (last !== undefined && seq <= last) return false;
|
||||||
|
this._appliedInputSeq.set(clientId, seq);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the last written data */
|
/** Get the last written data */
|
||||||
get lastWrite(): string | undefined {
|
get lastWrite(): string | undefined {
|
||||||
return this.writeBuffer[this.writeBuffer.length - 1];
|
return this.writeBuffer[this.writeBuffer.length - 1];
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Exactly-once input delivery — Session.shouldApplyInput dedup.
|
||||||
|
*
|
||||||
|
* Guards the server half of the reliable-input-delivery feature: the web client
|
||||||
|
* tags each input frame with a stable clientId + a monotonic per-session seq and
|
||||||
|
* redelivers anything it hasn't seen ACKed (a half-open socket silently drops
|
||||||
|
* frames on a flaky link). shouldApplyInput must apply each (clientId, seq)
|
||||||
|
* exactly once so a redelivery can never type the prompt twice — while still
|
||||||
|
* applying untagged input (curl/legacy) unconditionally at the call sites.
|
||||||
|
*
|
||||||
|
* See docs/reliable-input-delivery.md.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Session } from '../src/session.js';
|
||||||
|
|
||||||
|
function makeSession(): Session {
|
||||||
|
// workingDir is the only required field; no PTY is spawned until start(),
|
||||||
|
// and TmuxManager no-ops under VITEST — so this is a cheap, side-effect-free
|
||||||
|
// instance for exercising the pure dedup bookkeeping.
|
||||||
|
return new Session({ workingDir: '/tmp' });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Session.shouldApplyInput (exactly-once input dedup)', () => {
|
||||||
|
it('applies a fresh (clientId, seq) exactly once', () => {
|
||||||
|
const s = makeSession();
|
||||||
|
expect(s.shouldApplyInput('clientA', 1)).toBe(true);
|
||||||
|
// Same seq redelivered (lost ACK) — must NOT apply again.
|
||||||
|
expect(s.shouldApplyInput('clientA', 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies strictly increasing seqs and rejects stale ones', () => {
|
||||||
|
const s = makeSession();
|
||||||
|
expect(s.shouldApplyInput('c', 1)).toBe(true);
|
||||||
|
expect(s.shouldApplyInput('c', 2)).toBe(true);
|
||||||
|
expect(s.shouldApplyInput('c', 3)).toBe(true);
|
||||||
|
// Out-of-order / replayed lower seqs are duplicates.
|
||||||
|
expect(s.shouldApplyInput('c', 2)).toBe(false);
|
||||||
|
expect(s.shouldApplyInput('c', 1)).toBe(false);
|
||||||
|
// The next genuinely-new seq still applies.
|
||||||
|
expect(s.shouldApplyInput('c', 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks each client independently', () => {
|
||||||
|
const s = makeSession();
|
||||||
|
expect(s.shouldApplyInput('a', 5)).toBe(true);
|
||||||
|
// A different client at seq 1 is not shadowed by client a's higher seq.
|
||||||
|
expect(s.shouldApplyInput('b', 1)).toBe(true);
|
||||||
|
expect(s.shouldApplyInput('b', 1)).toBe(false);
|
||||||
|
expect(s.shouldApplyInput('a', 6)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a seq gap (skips never collapse a new seq to a duplicate)', () => {
|
||||||
|
const s = makeSession();
|
||||||
|
expect(s.shouldApplyInput('c', 1)).toBe(true);
|
||||||
|
// Client jumped seq (e.g. resumed after a reload that kept the counter).
|
||||||
|
expect(s.shouldApplyInput('c', 100)).toBe(true);
|
||||||
|
expect(s.shouldApplyInput('c', 100)).toBe(false);
|
||||||
|
expect(s.shouldApplyInput('c', 50)).toBe(false);
|
||||||
|
expect(s.shouldApplyInput('c', 101)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps recent clients dedup-correct past the eviction bound', () => {
|
||||||
|
const s = makeSession();
|
||||||
|
// Far exceed MAX_INPUT_DEDUP_CLIENTS (256) with one-shot clients, then prove
|
||||||
|
// a freshly-active client is still deduped correctly (MRU eviction).
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
expect(s.shouldApplyInput(`oneshot-${i}`, 1)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(s.shouldApplyInput('recent', 1)).toBe(true);
|
||||||
|
expect(s.shouldApplyInput('recent', 1)).toBe(false);
|
||||||
|
expect(s.shouldApplyInput('recent', 2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,8 @@ function createMockRalphTracker() {
|
|||||||
disableAutoEnable: vi.fn(),
|
disableAutoEnable: vi.fn(),
|
||||||
startLoop: vi.fn(),
|
startLoop: vi.fn(),
|
||||||
setMaxIterations: vi.fn(),
|
setMaxIterations: vi.fn(),
|
||||||
|
setMaxTodos: vi.fn(),
|
||||||
|
setTodoExpirationMinutes: vi.fn(),
|
||||||
resetCircuitBreaker: vi.fn(),
|
resetCircuitBreaker: vi.fn(),
|
||||||
generateFixPlanMarkdown: vi.fn(() => '# Fix Plan\n\n- [ ] Task 1\n'),
|
generateFixPlanMarkdown: vi.fn(() => '# Fix Plan\n\n- [ ] Task 1\n'),
|
||||||
importFixPlanMarkdown: vi.fn(() => 3),
|
importFixPlanMarkdown: vi.fn(() => 3),
|
||||||
@@ -158,6 +160,25 @@ describe('ralph-routes', () => {
|
|||||||
expect(tracker.setMaxIterations).toHaveBeenCalledWith(50);
|
expect(tracker.setMaxIterations).toHaveBeenCalledWith(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies maxTodos and todoExpirationMinutes to the tracker (COD-52)', async () => {
|
||||||
|
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
|
||||||
|
typeof createMockRalphTracker
|
||||||
|
>;
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||||
|
payload: { maxTodos: 25, todoExpirationMinutes: 90 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
// The ralph-config success path returns a bare {} envelope (matching its
|
||||||
|
// sibling config routes); an error would surface as { success: false }.
|
||||||
|
expect(body.success).not.toBe(false);
|
||||||
|
expect(tracker.setMaxTodos).toHaveBeenCalledWith(25);
|
||||||
|
expect(tracker.setTodoExpirationMinutes).toHaveBeenCalledWith(90);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns error for unknown session', async () => {
|
it('returns error for unknown session', async () => {
|
||||||
const res = await harness.app.inject({
|
const res = await harness.app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the cross-session search route (COD-9).
|
||||||
|
*
|
||||||
|
* Uses app.inject() — no real HTTP ports needed.
|
||||||
|
* Port: N/A (app.inject doesn't open ports)
|
||||||
|
*
|
||||||
|
* Covers query validation (400s), result shaping (grouped cards), caps,
|
||||||
|
* exact-before-recency ranking, and that at least two distinct sources
|
||||||
|
* (sessions + events) return results. Source data is injected via the mock
|
||||||
|
* route context (sessions map, runSummaryTrackers map, attachment history).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { registerSearchRoutes } from '../../src/web/routes/search-routes.js';
|
||||||
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
|
import { createMockRouteContext } from '../mocks/index.js';
|
||||||
|
import { RunSummaryTracker } from '../../src/run-summary.js';
|
||||||
|
|
||||||
|
type Ctx = ReturnType<typeof createMockRouteContext>;
|
||||||
|
|
||||||
|
async function harness(configure?: (ctx: Ctx) => void): Promise<{ app: FastifyInstance; ctx: Ctx }> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
// Start from an empty session map so tests fully control the source data.
|
||||||
|
const ctx = createMockRouteContext();
|
||||||
|
ctx.sessions.clear();
|
||||||
|
ctx.runSummaryTrackers.clear();
|
||||||
|
configure?.(ctx);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
registerSearchRoutes(app, ctx as any);
|
||||||
|
installRouteErrorHandler(app);
|
||||||
|
await app.ready();
|
||||||
|
return { app, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal session-like object compatible with the route's reads. */
|
||||||
|
function fakeSession(opts: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workingDir: string;
|
||||||
|
lastActivityAt?: number;
|
||||||
|
attachmentHistory?: unknown[];
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: opts.id,
|
||||||
|
name: opts.name,
|
||||||
|
workingDir: opts.workingDir,
|
||||||
|
lastActivityAt: opts.lastActivityAt ?? 0,
|
||||||
|
createdAt: 0,
|
||||||
|
attachmentHistory: opts.attachmentHistory ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/search — validation', () => {
|
||||||
|
it('400 on missing q', async () => {
|
||||||
|
const { app } = await harness();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/search' });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res.body).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 on empty q', async () => {
|
||||||
|
const { app } = await harness();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/search?q=' });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 on oversized q (>200 chars)', async () => {
|
||||||
|
const { app } = await harness();
|
||||||
|
const q = 'x'.repeat(201);
|
||||||
|
const res = await app.inject({ method: 'GET', url: `/api/search?q=${q}` });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 on bad types value', async () => {
|
||||||
|
const { app } = await harness();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/search?q=foo&types=session,bogus' });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 on non-numeric limit', async () => {
|
||||||
|
const { app } = await harness();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/search?q=foo&limit=abc' });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/search — shaping & multi-source', () => {
|
||||||
|
beforeEach(() => {});
|
||||||
|
|
||||||
|
it('returns grouped results from sessions and events (two distinct sources)', async () => {
|
||||||
|
const { app } = await harness((ctx) => {
|
||||||
|
ctx.sessions.set(
|
||||||
|
's1',
|
||||||
|
fakeSession({ id: 's1', name: 'needle session', workingDir: '/home/u/proj', lastActivityAt: 100 }) as never
|
||||||
|
);
|
||||||
|
const tracker = new RunSummaryTracker('s2', 'Other session');
|
||||||
|
tracker.addEvent('warning', 'info', 'found a needle', 'in the logs');
|
||||||
|
ctx.runSummaryTrackers.set('s2', tracker);
|
||||||
|
ctx.sessions.set(
|
||||||
|
's2',
|
||||||
|
fakeSession({ id: 's2', name: 'Other session', workingDir: '/x', lastActivityAt: 50 }) as never
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
const types = body.data.groups.map((g: { type: string }) => g.type);
|
||||||
|
expect(types).toContain('session');
|
||||||
|
expect(types).toContain('event');
|
||||||
|
// Group order: sessions before events.
|
||||||
|
expect(types.indexOf('session')).toBeLessThan(types.indexOf('event'));
|
||||||
|
expect(body.data.totalResults).toBe(2);
|
||||||
|
|
||||||
|
const sessionResult = body.data.groups.find((g: { type: string }) => g.type === 'session').results[0];
|
||||||
|
expect(sessionResult.sessionId).toBe('s1');
|
||||||
|
expect(sessionResult.sessionName).toBe('needle session');
|
||||||
|
expect(typeof sessionResult.timestamp).toBe('number');
|
||||||
|
expect(typeof sessionResult.snippet).toBe('string');
|
||||||
|
expect(sessionResult.jumpTo).toEqual({ kind: 'session', sessionId: 's1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes file results via relativePath and never leaks an absolute path', async () => {
|
||||||
|
const { app } = await harness((ctx) => {
|
||||||
|
ctx.sessions.set(
|
||||||
|
's1',
|
||||||
|
fakeSession({
|
||||||
|
id: 's1',
|
||||||
|
name: 'Alpha',
|
||||||
|
workingDir: '/home/u/proj',
|
||||||
|
lastActivityAt: 1,
|
||||||
|
attachmentHistory: [
|
||||||
|
{
|
||||||
|
id: 'item-1',
|
||||||
|
sessionId: 's1',
|
||||||
|
fileName: 'needle.txt',
|
||||||
|
extension: 'txt',
|
||||||
|
attachmentType: 'text',
|
||||||
|
size: 10,
|
||||||
|
mtimeMs: 0,
|
||||||
|
timestamp: 5,
|
||||||
|
source: 'detected',
|
||||||
|
relativePath: 'docs/needle.txt',
|
||||||
|
externalPath: '/home/u/secret/needle.txt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle' });
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
const fileGroup = body.data.groups.find((g: { type: string }) => g.type === 'file');
|
||||||
|
expect(fileGroup).toBeTruthy();
|
||||||
|
expect(fileGroup.results[0].jumpTo.relativePath).toBe('docs/needle.txt');
|
||||||
|
// The server-private absolute/external path must never appear in the payload.
|
||||||
|
expect(res.body).not.toContain('/home/u/secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks exact session-name matches before more-recent partial matches', async () => {
|
||||||
|
const { app } = await harness((ctx) => {
|
||||||
|
ctx.sessions.set(
|
||||||
|
'exact-old',
|
||||||
|
fakeSession({ id: 'exact-old', name: 'needle', workingDir: '/x', lastActivityAt: 1 }) as never
|
||||||
|
);
|
||||||
|
ctx.sessions.set(
|
||||||
|
'partial-new',
|
||||||
|
fakeSession({ id: 'partial-new', name: 'needle-haystack', workingDir: '/x', lastActivityAt: 9999 }) as never
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle' });
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
const ids = body.data.groups[0].results.map((r: { sessionId: string }) => r.sessionId);
|
||||||
|
expect(ids).toEqual(['exact-old', 'partial-new']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/search — caps & filters', () => {
|
||||||
|
it('respects the limit query param as a total cap', async () => {
|
||||||
|
const { app } = await harness((ctx) => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
ctx.sessions.set(
|
||||||
|
`s${i}`,
|
||||||
|
fakeSession({ id: `s${i}`, name: `needle ${i}`, workingDir: '/x', lastActivityAt: i }) as never
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle&limit=5' });
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.totalResults).toBe(5);
|
||||||
|
expect(body.data.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by types when provided', async () => {
|
||||||
|
const { app } = await harness((ctx) => {
|
||||||
|
ctx.sessions.set(
|
||||||
|
's1',
|
||||||
|
fakeSession({ id: 's1', name: 'needle session', workingDir: '/x', lastActivityAt: 1 }) as never
|
||||||
|
);
|
||||||
|
const tracker = new RunSummaryTracker('s1', 'needle session');
|
||||||
|
tracker.addEvent('warning', 'info', 'needle event', '');
|
||||||
|
ctx.runSummaryTrackers.set('s1', tracker);
|
||||||
|
});
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/search?q=needle&types=event' });
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
const types = body.data.groups.map((g: { type: string }) => g.type);
|
||||||
|
expect(types).toEqual(['event']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -355,6 +355,43 @@ describe('session-routes', () => {
|
|||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies a tagged (clientId, seq) input exactly once on redelivery', async () => {
|
||||||
|
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
|
||||||
|
session.writeBuffer.length = 0;
|
||||||
|
|
||||||
|
const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload });
|
||||||
|
|
||||||
|
// First delivery of seq 1 — applied (200, written once).
|
||||||
|
const first = await post({ input: 'prompt', seq: 1, clientId: 'cid-1' });
|
||||||
|
expect(first.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Redelivery of the SAME seq (client never saw the ACK) — still 200, but
|
||||||
|
// must NOT write again.
|
||||||
|
const dup = await post({ input: 'prompt', seq: 1, clientId: 'cid-1' });
|
||||||
|
expect(dup.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// A genuinely new seq — applied.
|
||||||
|
const next = await post({ input: '\r', seq: 2, clientId: 'cid-1' });
|
||||||
|
expect(next.statusCode).toBe(200);
|
||||||
|
|
||||||
|
expect(session.writeBuffer).toEqual(['prompt', '\r']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always applies untagged input (curl/legacy, no dedup)', async () => {
|
||||||
|
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
|
||||||
|
session.writeBuffer.length = 0;
|
||||||
|
|
||||||
|
const post = () => harness.app.inject({ method: 'POST', url, payload: { input: 'x' } });
|
||||||
|
await post();
|
||||||
|
await post();
|
||||||
|
// No seq/clientId ⇒ no dedup ⇒ both writes land.
|
||||||
|
expect(session.writeBuffer).toEqual(['x', 'x']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== POST /api/sessions/:id/resize ==========
|
// ========== POST /api/sessions/:id/resize ==========
|
||||||
|
|||||||
@@ -76,11 +76,17 @@ vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({
|
|||||||
resolveOpenCodeDir: vi.fn(() => null),
|
resolveOpenCodeDir: vi.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/utils/gemini-cli-resolver.js', () => ({
|
||||||
|
isGeminiAvailable: vi.fn(() => false),
|
||||||
|
resolveGeminiDir: vi.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { existsSync, readdirSync } from 'node:fs';
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
import { subagentWatcher } from '../../src/subagent-watcher.js';
|
import { subagentWatcher } from '../../src/subagent-watcher.js';
|
||||||
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
|
||||||
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js';
|
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js';
|
||||||
|
import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js';
|
||||||
|
|
||||||
const mockedReadFile = vi.mocked(fs.readFile);
|
const mockedReadFile = vi.mocked(fs.readFile);
|
||||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||||
@@ -90,6 +96,8 @@ const mockedSubagentWatcher = vi.mocked(subagentWatcher);
|
|||||||
const mockedGetLifecycleLog = vi.mocked(getLifecycleLog);
|
const mockedGetLifecycleLog = vi.mocked(getLifecycleLog);
|
||||||
const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
|
const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
|
||||||
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
|
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
|
||||||
|
const mockedIsGeminiAvailable = vi.mocked(isGeminiAvailable);
|
||||||
|
const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir);
|
||||||
|
|
||||||
describe('system-routes', () => {
|
describe('system-routes', () => {
|
||||||
let harness: RouteTestHarness;
|
let harness: RouteTestHarness;
|
||||||
@@ -117,6 +125,8 @@ describe('system-routes', () => {
|
|||||||
} as never);
|
} as never);
|
||||||
mockedIsOpenCodeAvailable.mockReturnValue(false);
|
mockedIsOpenCodeAvailable.mockReturnValue(false);
|
||||||
mockedResolveOpenCodeDir.mockReturnValue(null);
|
mockedResolveOpenCodeDir.mockReturnValue(null);
|
||||||
|
mockedIsGeminiAvailable.mockReturnValue(false);
|
||||||
|
mockedResolveGeminiDir.mockReturnValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -194,6 +204,72 @@ describe('system-routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== GET /api/away-digest ==========
|
||||||
|
|
||||||
|
describe('GET /api/away-digest', () => {
|
||||||
|
it('returns a classified digest from lifecycle, active sessions, and token stats', async () => {
|
||||||
|
const lifecycleQuery = vi.fn(async () => [
|
||||||
|
{
|
||||||
|
ts: Date.now() - 1000,
|
||||||
|
event: 'exit',
|
||||||
|
sessionId: harness.ctx._sessionId,
|
||||||
|
name: 'Route Session',
|
||||||
|
exitCode: 7,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockedGetLifecycleLog.mockReturnValue({
|
||||||
|
log: vi.fn(),
|
||||||
|
query: lifecycleQuery,
|
||||||
|
} as never);
|
||||||
|
harness.ctx._session.name = 'Route Session';
|
||||||
|
harness.ctx._session.status = 'working';
|
||||||
|
harness.ctx.store.getDailyStats.mockReturnValue([
|
||||||
|
{
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 200,
|
||||||
|
estimatedCost: 0.02,
|
||||||
|
sessions: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/away-digest?range=1h' });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.digest.sections.needsAttention[0]).toMatchObject({
|
||||||
|
sessionId: harness.ctx._sessionId,
|
||||||
|
source: 'lifecycle',
|
||||||
|
});
|
||||||
|
expect(body.digest.sections.stillRunning[0]).toMatchObject({
|
||||||
|
sessionId: harness.ctx._sessionId,
|
||||||
|
source: 'status',
|
||||||
|
});
|
||||||
|
expect(body.digest.totals).toMatchObject({
|
||||||
|
activeSessions: 1,
|
||||||
|
needsAttention: 1,
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 200,
|
||||||
|
tokenWindowPrecision: 'day',
|
||||||
|
});
|
||||||
|
expect(lifecycleQuery).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
limit: 1000,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid ranges', async () => {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/away-digest?range=forever' });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.message ?? body.error).toMatch(/range/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ========== GET /api/debug/memory ==========
|
// ========== GET /api/debug/memory ==========
|
||||||
|
|
||||||
describe('GET /api/debug/memory', () => {
|
describe('GET /api/debug/memory', () => {
|
||||||
@@ -699,6 +775,32 @@ describe('system-routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== GET /api/gemini/status ==========
|
||||||
|
|
||||||
|
describe('GET /api/gemini/status', () => {
|
||||||
|
it('returns unavailable when gemini is not installed', async () => {
|
||||||
|
mockedIsGeminiAvailable.mockReturnValue(false);
|
||||||
|
mockedResolveGeminiDir.mockReturnValue(null);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.available).toBe(false);
|
||||||
|
expect(body.path).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns available with path when gemini is installed', async () => {
|
||||||
|
mockedIsGeminiAvailable.mockReturnValue(true);
|
||||||
|
mockedResolveGeminiDir.mockReturnValue('/usr/local/bin');
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.available).toBe(true);
|
||||||
|
expect(body.path).toBe('/usr/local/bin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ========== GET /api/execution/model-config ==========
|
// ========== GET /api/execution/model-config ==========
|
||||||
|
|
||||||
describe('GET /api/execution/model-config', () => {
|
describe('GET /api/execution/model-config', () => {
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ describe('run mode UI', () => {
|
|||||||
expect(app.runMode).toBe('claude');
|
expect(app.runMode).toBe('claude');
|
||||||
expect(runBtnLabel.textContent).toBe('Run');
|
expect(runBtnLabel.textContent).toBe('Run');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts Gemini mode from server sync and updates the run button label', async () => {
|
||||||
|
const { app, storage, runBtnLabel } = loadRunModeHarness();
|
||||||
|
|
||||||
|
storage.set('codeman_runMode', 'claude');
|
||||||
|
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'gemini' }));
|
||||||
|
|
||||||
|
expect(app.runMode).toBe('gemini');
|
||||||
|
expect(runBtnLabel.textContent).toBe('Run GM');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Codex quick start settings', () => {
|
describe('Codex quick start settings', () => {
|
||||||
@@ -133,3 +143,56 @@ describe('Codex quick start settings', () => {
|
|||||||
expect(selected).toEqual(['sess-1']);
|
expect(selected).toEqual(['sess-1']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Gemini quick start', () => {
|
||||||
|
// Regression guard for the ApiResponse-envelope unwrap in runGemini(): the
|
||||||
|
// status check must read `.data.available` and the quick-start response must
|
||||||
|
// read `.data.sessionId`. Reading the raw shape (pre-fix) silently bails on
|
||||||
|
// the status check and never selects the new tab — exactly the two blockers
|
||||||
|
// caught in PR #134 review.
|
||||||
|
it('drives runGemini() through the {success,data} envelope and selects the new session', async () => {
|
||||||
|
const elements: Record<string, any> = {
|
||||||
|
quickStartCase: { value: 'gemini-case' },
|
||||||
|
};
|
||||||
|
const requests: Array<{ url: string; body?: any }> = [];
|
||||||
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
localStorage: { getItem: () => null, setItem: () => {} },
|
||||||
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
||||||
|
// Mock responses use the real wire shape: the server.ts preSerialization
|
||||||
|
// hook wraps raw route payloads into the { success, data } envelope.
|
||||||
|
fetch: async (url: string, init?: { body?: string }) => {
|
||||||
|
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
||||||
|
if (url === '/api/gemini/status') return { json: async () => ({ success: true, data: { available: true } }) };
|
||||||
|
if (url === '/api/quick-start')
|
||||||
|
return { json: async () => ({ success: true, data: { sessionId: 'sess-gm' } }) };
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
},
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||||
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||||
|
|
||||||
|
const app = new (CodemanApp as any)();
|
||||||
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
app.getCaseSettings = () => ({});
|
||||||
|
app.buildEnvOverrides = () => ({});
|
||||||
|
const selected: string[] = [];
|
||||||
|
app.selectSession = async (id: string) => {
|
||||||
|
selected.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
await app.runGemini();
|
||||||
|
|
||||||
|
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
||||||
|
caseName: 'gemini-case',
|
||||||
|
mode: 'gemini',
|
||||||
|
geminiConfig: { approvalMode: 'yolo' },
|
||||||
|
});
|
||||||
|
expect(selected).toEqual(['sess-gm']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the pure cross-session search core (COD-9).
|
||||||
|
*
|
||||||
|
* The core (`searchSources`) takes already-collected, in-memory source data
|
||||||
|
* plus a normalized query and returns grouped/ranked/capped results. No I/O,
|
||||||
|
* no live server — these tests exercise grouping order, exact-before-recency
|
||||||
|
* ranking, caps (total + per-group), snippet shaping, and path safety.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { searchSources, SEARCH_TOTAL_CAP, SEARCH_PER_GROUP_CAP, type SearchSources } from '../src/search-service.js';
|
||||||
|
|
||||||
|
function sources(overrides: Partial<SearchSources> = {}): SearchSources {
|
||||||
|
return {
|
||||||
|
sessions: [],
|
||||||
|
events: [],
|
||||||
|
files: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('searchSources — grouping & order', () => {
|
||||||
|
it('orders groups sessions → events → files', () => {
|
||||||
|
const data = sources({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'Alpha',
|
||||||
|
fileName: 'query.txt',
|
||||||
|
relativePath: 'docs/query.txt',
|
||||||
|
timestamp: 100,
|
||||||
|
itemId: 'f1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{ sessionId: 's1', sessionName: 'Alpha', eventId: 'e1', title: 'query started', details: '', timestamp: 100 },
|
||||||
|
],
|
||||||
|
sessions: [{ sessionId: 's1', sessionName: 'query session', workingDir: '/home/u/proj', timestamp: 100 }],
|
||||||
|
});
|
||||||
|
const res = searchSources('query', data);
|
||||||
|
expect(res.groups.map((g) => g.type)).toEqual(['session', 'event', 'file']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits empty groups', () => {
|
||||||
|
const data = sources({
|
||||||
|
sessions: [{ sessionId: 's1', sessionName: 'query session', workingDir: '/home/u/proj', timestamp: 100 }],
|
||||||
|
});
|
||||||
|
const res = searchSources('query', data);
|
||||||
|
expect(res.groups.map((g) => g.type)).toEqual(['session']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchSources — matching across distinct sources', () => {
|
||||||
|
it('returns results from at least two distinct sources', () => {
|
||||||
|
const data = sources({
|
||||||
|
sessions: [{ sessionId: 's1', sessionName: 'needle project', workingDir: '/home/u/proj', timestamp: 100 }],
|
||||||
|
events: [
|
||||||
|
{ sessionId: 's2', sessionName: 'Other', eventId: 'e1', title: 'found a needle', details: '', timestamp: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = searchSources('needle', data);
|
||||||
|
const types = res.groups.map((g) => g.type);
|
||||||
|
expect(types).toContain('session');
|
||||||
|
expect(types).toContain('event');
|
||||||
|
expect(res.totalResults).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches session working directory', () => {
|
||||||
|
const data = sources({
|
||||||
|
sessions: [{ sessionId: 's1', sessionName: 'Unrelated', workingDir: '/home/u/needle-dir', timestamp: 100 }],
|
||||||
|
});
|
||||||
|
const res = searchSources('needle', data);
|
||||||
|
expect(res.totalResults).toBe(1);
|
||||||
|
expect(res.groups[0].results[0].sessionId).toBe('s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches event details, not just title', () => {
|
||||||
|
const data = sources({
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'A',
|
||||||
|
eventId: 'e1',
|
||||||
|
title: 'nothing here',
|
||||||
|
details: 'a needle in details',
|
||||||
|
timestamp: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = searchSources('needle', data);
|
||||||
|
expect(res.totalResults).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
const data = sources({
|
||||||
|
sessions: [{ sessionId: 's1', sessionName: 'NEEDLE', workingDir: '/x', timestamp: 100 }],
|
||||||
|
});
|
||||||
|
expect(searchSources('needle', data).totalResults).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchSources — ranking (exact before recency)', () => {
|
||||||
|
it('places exact name matches before more-recent partial matches', () => {
|
||||||
|
const data = sources({
|
||||||
|
sessions: [
|
||||||
|
{ sessionId: 'old-exact', sessionName: 'needle', workingDir: '/x', timestamp: 1 },
|
||||||
|
{ sessionId: 'new-partial', sessionName: 'needle-haystack', workingDir: '/x', timestamp: 9999 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = searchSources('needle', data);
|
||||||
|
const ids = res.groups[0].results.map((r) => r.sessionId);
|
||||||
|
expect(ids).toEqual(['old-exact', 'new-partial']);
|
||||||
|
expect(res.groups[0].results[0].exactMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('within the same exactness tier, sorts newest first', () => {
|
||||||
|
const data = sources({
|
||||||
|
sessions: [
|
||||||
|
{ sessionId: 'older', sessionName: 'needle-a', workingDir: '/x', timestamp: 10 },
|
||||||
|
{ sessionId: 'newer', sessionName: 'needle-b', workingDir: '/x', timestamp: 20 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = searchSources('needle', data);
|
||||||
|
expect(res.groups[0].results.map((r) => r.sessionId)).toEqual(['newer', 'older']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchSources — caps', () => {
|
||||||
|
it('enforces the per-group cap and flags truncated', () => {
|
||||||
|
const sessions = Array.from({ length: SEARCH_PER_GROUP_CAP + 5 }, (_, i) => ({
|
||||||
|
sessionId: `s${i}`,
|
||||||
|
sessionName: `needle ${i}`,
|
||||||
|
workingDir: '/x',
|
||||||
|
timestamp: i,
|
||||||
|
}));
|
||||||
|
const res = searchSources('needle', sources({ sessions }));
|
||||||
|
expect(res.groups[0].results.length).toBe(SEARCH_PER_GROUP_CAP);
|
||||||
|
expect(res.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the total cap across groups', () => {
|
||||||
|
// Fill every group to its per-group cap; total must not exceed SEARCH_TOTAL_CAP.
|
||||||
|
const mk = <T>(n: number, f: (i: number) => T) => Array.from({ length: n }, (_, i) => f(i));
|
||||||
|
const data = sources({
|
||||||
|
sessions: mk(SEARCH_PER_GROUP_CAP, (i) => ({
|
||||||
|
sessionId: `s${i}`,
|
||||||
|
sessionName: `needle ${i}`,
|
||||||
|
workingDir: '/x',
|
||||||
|
timestamp: i,
|
||||||
|
})),
|
||||||
|
events: mk(SEARCH_PER_GROUP_CAP, (i) => ({
|
||||||
|
sessionId: `e${i}`,
|
||||||
|
sessionName: 'E',
|
||||||
|
eventId: `e${i}`,
|
||||||
|
title: `needle ${i}`,
|
||||||
|
details: '',
|
||||||
|
timestamp: i,
|
||||||
|
})),
|
||||||
|
files: mk(SEARCH_PER_GROUP_CAP, (i) => ({
|
||||||
|
sessionId: `f${i}`,
|
||||||
|
sessionName: 'F',
|
||||||
|
fileName: `needle${i}.txt`,
|
||||||
|
relativePath: `d/needle${i}.txt`,
|
||||||
|
timestamp: i,
|
||||||
|
itemId: `f${i}`,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const res = searchSources('needle', data);
|
||||||
|
expect(res.totalResults).toBeLessThanOrEqual(SEARCH_TOTAL_CAP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchSources — result card shape & path safety', () => {
|
||||||
|
it('shapes a file result with a relative-path jump target and no absolute leakage', () => {
|
||||||
|
const data = sources({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'Alpha',
|
||||||
|
fileName: 'needle.txt',
|
||||||
|
relativePath: 'docs/needle.txt',
|
||||||
|
timestamp: 123,
|
||||||
|
itemId: 'item-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = searchSources('needle', data).groups[0].results[0];
|
||||||
|
expect(r.type).toBe('file');
|
||||||
|
expect(r.sessionId).toBe('s1');
|
||||||
|
expect(r.sessionName).toBe('Alpha');
|
||||||
|
expect(r.timestamp).toBe(123);
|
||||||
|
expect(r.jumpTo).toEqual({
|
||||||
|
kind: 'file-preview',
|
||||||
|
sessionId: 's1',
|
||||||
|
targetId: 'item-1',
|
||||||
|
relativePath: 'docs/needle.txt',
|
||||||
|
});
|
||||||
|
// No absolute path anywhere in the serialized result.
|
||||||
|
expect(JSON.stringify(r)).not.toContain('/home/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops files that only have a server-private absolute path (no relativePath)', () => {
|
||||||
|
const data = sources({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'Alpha',
|
||||||
|
fileName: 'needle.txt',
|
||||||
|
relativePath: undefined,
|
||||||
|
timestamp: 1,
|
||||||
|
itemId: 'i1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// fileName still matches, but there is no safe relativePath to expose → still
|
||||||
|
// returned, but jumpTo must not carry an absolute path.
|
||||||
|
const res = searchSources('needle', data);
|
||||||
|
if (res.totalResults > 0) {
|
||||||
|
expect(res.groups[0].results[0].jumpTo.relativePath).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long snippets', () => {
|
||||||
|
const longDetail = 'needle ' + 'x'.repeat(500);
|
||||||
|
const data = sources({
|
||||||
|
events: [{ sessionId: 's1', sessionName: 'A', eventId: 'e1', title: 'evt', details: longDetail, timestamp: 1 }],
|
||||||
|
});
|
||||||
|
const r = searchSources('needle', data).groups[0].results[0];
|
||||||
|
expect(r.snippet.length).toBeLessThanOrEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for a blank query', () => {
|
||||||
|
const data = sources({ sessions: [{ sessionId: 's1', sessionName: 'needle', workingDir: '/x', timestamp: 1 }] });
|
||||||
|
expect(searchSources('', data).totalResults).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||||
|
import {
|
||||||
|
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MAX_TERMINAL_BUFFER_BYTES,
|
||||||
|
MIN_TERMINAL_BUFFER_BYTES,
|
||||||
|
} from '../src/config/terminal-history.js';
|
||||||
|
|
||||||
|
describe('SettingsUpdateSchema — terminal history keys', () => {
|
||||||
|
it('accepts valid in-range terminal-history settings', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({
|
||||||
|
terminalScrollbackLines: 100_000,
|
||||||
|
tmuxHistoryLimit: 100_000,
|
||||||
|
terminalBufferMaxBytes: 32 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 24 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a settings object that omits the terminal-history keys', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({ allowedTools: 'Bash' });
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects scrollback below the minimum', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({ terminalScrollbackLines: MIN_TERMINAL_SCROLLBACK_LINES - 1 });
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects scrollback above the maximum', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({ tmuxHistoryLimit: MAX_TERMINAL_SCROLLBACK_LINES + 1 });
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects buffer bytes outside the byte bounds', () => {
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ terminalBufferMaxBytes: MIN_TERMINAL_BUFFER_BYTES - 1 }).success).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ terminalBufferMaxBytes: MAX_TERMINAL_BUFFER_BYTES + 1 }).success).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-integer values', () => {
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ terminalScrollbackLines: 12_345.5 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects trim > max via the cross-field superRefine', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({
|
||||||
|
terminalBufferMaxBytes: 4 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
if (!res.success) {
|
||||||
|
expect(res.error.issues.some((i) => i.path.includes('terminalBufferTrimBytes'))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows trim == max', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({
|
||||||
|
terminalBufferMaxBytes: 8 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
resolveTerminalHistoryConfig,
|
||||||
|
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
|
DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
|
||||||
|
DEFAULT_TERMINAL_BUFFER_TRIM_BYTES,
|
||||||
|
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MIN_TERMINAL_BUFFER_BYTES,
|
||||||
|
MAX_TERMINAL_BUFFER_BYTES,
|
||||||
|
} from '../src/config/terminal-history.js';
|
||||||
|
|
||||||
|
describe('resolveTerminalHistoryConfig', () => {
|
||||||
|
it('returns the documented defaults for empty settings', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({});
|
||||||
|
expect(cfg).toEqual({
|
||||||
|
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
tmuxHistoryLimit: DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
|
terminalBufferMaxBytes: DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
|
||||||
|
terminalBufferTrimBytes: DEFAULT_TERMINAL_BUFFER_TRIM_BYTES,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults match the prior hardcoded values (behavior-neutral)', () => {
|
||||||
|
expect(DEFAULT_TMUX_HISTORY_LIMIT).toBe(50_000);
|
||||||
|
expect(DEFAULT_TERMINAL_SCROLLBACK_LINES).toBe(50_000);
|
||||||
|
expect(DEFAULT_TERMINAL_BUFFER_MAX_BYTES).toBe(2 * 1024 * 1024);
|
||||||
|
expect(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES).toBe(1.5 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes valid in-range values through unchanged', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalScrollbackLines: 50_000,
|
||||||
|
tmuxHistoryLimit: 75_000,
|
||||||
|
terminalBufferMaxBytes: 16 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(cfg).toEqual({
|
||||||
|
terminalScrollbackLines: 50_000,
|
||||||
|
tmuxHistoryLimit: 75_000,
|
||||||
|
terminalBufferMaxBytes: 16 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps scrollback/history below MIN up to the floor', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalScrollbackLines: 1,
|
||||||
|
tmuxHistoryLimit: 0,
|
||||||
|
});
|
||||||
|
expect(cfg.terminalScrollbackLines).toBe(MIN_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
expect(cfg.tmuxHistoryLimit).toBe(MIN_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps scrollback/history above MAX down to the ceiling', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalScrollbackLines: 999_999_999,
|
||||||
|
tmuxHistoryLimit: 999_999_999,
|
||||||
|
});
|
||||||
|
expect(cfg.terminalScrollbackLines).toBe(MAX_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
expect(cfg.tmuxHistoryLimit).toBe(MAX_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the buffer byte caps to their MIN/MAX bounds', () => {
|
||||||
|
const tooSmall = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1 });
|
||||||
|
expect(tooSmall.terminalBufferMaxBytes).toBe(MIN_TERMINAL_BUFFER_BYTES);
|
||||||
|
|
||||||
|
const tooLarge = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1024 * 1024 * 1024 });
|
||||||
|
expect(tooLarge.terminalBufferMaxBytes).toBe(MAX_TERMINAL_BUFFER_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps trim <= max (trim is capped to the resolved max)', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalBufferMaxBytes: 4 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(cfg.terminalBufferTrimBytes).toBeLessThanOrEqual(cfg.terminalBufferMaxBytes);
|
||||||
|
expect(cfg.terminalBufferTrimBytes).toBe(4 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the default trim to a lowered max', () => {
|
||||||
|
// Default trim (1.5MB) exceeds a 1MB max → trim must fall to the max.
|
||||||
|
const cfg = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1 * 1024 * 1024 });
|
||||||
|
expect(cfg.terminalBufferTrimBytes).toBe(1 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates fractional inputs to integers', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({ terminalScrollbackLines: 12_345.9 });
|
||||||
|
expect(cfg.terminalScrollbackLines).toBe(12_345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to defaults for non-number / non-finite inputs', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalScrollbackLines: 'lots' as unknown as number,
|
||||||
|
tmuxHistoryLimit: NaN,
|
||||||
|
terminalBufferMaxBytes: null as unknown as number,
|
||||||
|
terminalBufferTrimBytes: Infinity,
|
||||||
|
});
|
||||||
|
expect(cfg.terminalScrollbackLines).toBe(DEFAULT_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
expect(cfg.tmuxHistoryLimit).toBe(DEFAULT_TMUX_HISTORY_LIMIT);
|
||||||
|
expect(cfg.terminalBufferMaxBytes).toBe(DEFAULT_TERMINAL_BUFFER_MAX_BYTES);
|
||||||
|
// trim default is min(DEFAULT_TRIM, resolvedMax) — here resolvedMax is the default max.
|
||||||
|
expect(cfg.terminalBufferTrimBytes).toBe(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an empty object when called with no argument', () => {
|
||||||
|
expect(resolveTerminalHistoryConfig().tmuxHistoryLimit).toBe(DEFAULT_TMUX_HISTORY_LIMIT);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user