Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b8b175133 | ||
|
|
c3027b21e1 | ||
|
|
0b231edd43 | ||
|
|
ea1c2ee4ec | ||
|
|
b4a808adcf | ||
|
|
f3cbe9bca6 | ||
|
|
a11bcb0029 | ||
|
|
47fd9a922f | ||
|
|
14f7d8298d | ||
|
|
d32f4debb2 | ||
|
|
3cb7b510f8 | ||
|
|
6a12a72c9c | ||
|
|
f1a126efeb | ||
|
|
12fd780af8 | ||
|
|
fd74a42933 | ||
|
|
7101e64800 | ||
|
|
1b10d9b733 | ||
|
|
5078f5251d | ||
|
|
196af8fba7 | ||
|
|
a9b22b86a4 | ||
|
|
28cace5858 | ||
|
|
0a594b61bd | ||
|
|
89d787a949 | ||
|
|
bd9797b68c | ||
|
|
0e6cd94312 | ||
|
|
24a6f1cac8 | ||
|
|
8e679a280b | ||
|
|
c642689bbd | ||
|
|
28a6247c27 | ||
|
|
0ceb455c4b | ||
|
|
e51117dfa9 | ||
|
|
13d41cf7c7 | ||
|
|
2c7557d002 | ||
|
|
53b473708f | ||
|
|
2cba393ae5 | ||
|
|
64b8ea30b2 | ||
|
|
5743af3339 | ||
|
|
2011bd8d89 | ||
|
|
b76724690d | ||
|
|
f277f9664c | ||
|
|
cd49171bbc | ||
|
|
0f57342b10 | ||
|
|
e1f0ac993a | ||
|
|
a84ef52992 | ||
|
|
692c894760 | ||
|
|
ad0acb6d58 | ||
|
|
d866c8f30e | ||
|
|
28537de39d | ||
|
|
ba09184efa | ||
|
|
93719b41cd | ||
|
|
a448983be3 | ||
|
|
3145eac6d9 | ||
|
|
e3c609f5f0 | ||
|
|
52e774f83c | ||
|
|
82d08df53f | ||
|
|
2709b2fe49 | ||
|
|
b1d3b27e5b | ||
|
|
47963b54fa | ||
|
|
b7c3c30c8c | ||
|
|
0d80524f10 | ||
|
|
a9d83ec4e3 | ||
|
|
84137cdba4 | ||
|
|
6eb3969816 | ||
|
|
de49437a6f | ||
|
|
6a27639083 | ||
|
|
eb1b38c718 | ||
|
|
ea7b103b47 | ||
|
|
bec8e2f9ee | ||
|
|
2203f3a347 | ||
|
|
867a10d78a | ||
|
|
e54d7badc4 | ||
|
|
40dfac3534 | ||
|
|
e899a43a18 | ||
|
|
0cab8a7ece | ||
|
|
7b7cf958c0 | ||
|
|
6e64ddd853 | ||
|
|
afea91b92b | ||
|
|
9449a8f157 | ||
|
|
d322f17f73 | ||
|
|
61b5ec095c | ||
|
|
497ca4891a | ||
|
|
580b7a3f90 | ||
|
|
34c3d8f5ff | ||
|
|
2491471ba5 | ||
|
|
0c4aac8029 | ||
|
|
d436c6375f | ||
|
|
e96baf9f66 |
@@ -48,7 +48,12 @@ Thumbs.db
|
||||
# Generated output
|
||||
out/
|
||||
screenshots-echo-diag/
|
||||
tools/remotion/out/
|
||||
scripts/remotion/out/
|
||||
|
||||
# Artifacts that should not be tracked
|
||||
test-results/
|
||||
tmp/
|
||||
public
|
||||
|
||||
# Claude Code plan tracking
|
||||
plan.json
|
||||
|
||||
@@ -6,4 +6,4 @@ src/web/public/app.js
|
||||
src/web/public/styles.css
|
||||
src/web/public/mobile.css
|
||||
src/web/public/index.html
|
||||
tools/
|
||||
scripts/remotion/
|
||||
|
||||
@@ -1,5 +1,180 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.5.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix quick-start to resolve linked cases before codeman-cases fallback. `/api/quick-start` was always resolving `caseName` against `CASES_DIR`, ignoring entries in `~/.codeman/linked-cases.json`. Sessions started via quick-start now correctly honour linked external project directories, consistent with regular case routes.
|
||||
|
||||
## 0.5.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Community contributions and security hardening:
|
||||
- Mobile response viewer: native-scroll panel for reading full Claude responses with markdown rendering via marked.js (PR #62)
|
||||
- PWA support: service worker caching, web app manifest, and Android home screen install (PR #59)
|
||||
- Named Cloudflare tunnel support (PR #58)
|
||||
- Markdown rendering for response viewer with HTML sanitization (XSS prevention) — strips dangerous elements, event handlers, and javascript: URIs
|
||||
- Service worker switched from stale-while-revalidate to network-first caching so deploys take effect immediately
|
||||
- Content-Disposition filename sanitization to prevent header injection in file downloads
|
||||
- Expose session.muxName public getter, replace unsafe `as any` cast in session-routes
|
||||
- Static import for execFile in session-routes
|
||||
- Keyboard shortcut updates: Alt+1-9 tab switching, Shift+Enter newline
|
||||
- Repo restructure for cleaner GitHub landing page
|
||||
- Mobile logo, expandable history, session resume fixes
|
||||
|
||||
## 0.5.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: allow bracket characters in model validation regex so models like opus[1m] (1M context window) are accepted instead of silently dropped. Quote the model flag value in tmux spawn commands to prevent bash glob expansion of bracket patterns.
|
||||
|
||||
docs: update macOS launchd instructions to use `launchctl bootstrap` instead of deprecated `load`. Clean up README install and service sections.
|
||||
|
||||
## 0.5.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile keyboard accessory bar: add configurable "Extended Keyboard Bar" setting (Settings > Display > Input) that toggles between simple mode (up/down arrows, /init, /clear, /compact, paste, dismiss) and extended mode (adds left/right arrows, Tab, Shift+Tab, Ctrl+O, Alt+Enter, Esc). Default is simple mode. Setting is device-specific (not synced to server).
|
||||
|
||||
Restyle dismiss button: muted steel-blue tone, fills remaining bar space via flex, larger tap target. Arrow buttons now blue.
|
||||
|
||||
Fix paste overlay visibility on mobile: dialog repositioned to top of screen (15vh from top) so the virtual keyboard doesn't cover it. Textarea enlarged for better usability.
|
||||
|
||||
(Also includes all v0.5.8 changes: case reorder/delete, XSS sanitization, auto-attach PTY on restart, mobile keyboard buttons, macOS installer fixes, terminal flicker fix, state store collision fix.)
|
||||
|
||||
## 0.5.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Case management: add Manage tab with reorder (up/down arrows) and delete for cases; linked cases are unlinked (folder preserved), CASES_DIR cases are permanently deleted. New endpoints: DELETE /api/cases/:name, PUT /api/cases/order. SSE events: case:deleted, case:order-changed.
|
||||
|
||||
Security: sanitize case names from filesystem with /^[a-zA-Z0-9_-]+$/ regex before returning from GET /api/cases to prevent XSS via maliciously-named directories reaching frontend inline onclick handlers.
|
||||
|
||||
Auto-attach PTY: server now calls startInteractive() for recovered tmux sessions during startup so all sessions resume capturing output immediately after deploy, instead of waiting for client selection. Frontend auto-attach condition relaxed from (pid===null && status==='idle') to (pid===null && !\_ended).
|
||||
|
||||
Mobile keyboard accessory: add Shift+Tab, Tab, Esc, Alt+Enter, Left/Right arrow, and Ctrl+O buttons.
|
||||
|
||||
Terminal: fix flicker regression by moving viewport clear inside dimension guard.
|
||||
|
||||
State store: fix temp file collisions on concurrent writes.
|
||||
|
||||
macOS: fix installer failures when piped via curl | bash, add HTML cache support, launchd service template, and trust dialog handling.
|
||||
|
||||
Housekeeping: remove accidentally committed dist/state-store.js build artifact.
|
||||
|
||||
## 0.5.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feat: support "Default (CLI default)" option for model selection. Adds a new empty-value option to the model dropdown that defers to the CLI's own default model instead of forcing a specific model. Ensures empty defaultModel values are treated as undefined when passed to session creation and Ralph loop start, preventing empty strings from being sent as model flags.
|
||||
|
||||
## 0.5.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: default new sessions to opus[1m] (1M context window) instead of plain opus (200k context)
|
||||
|
||||
## 0.5.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add 1M Opus context quick setting — per-case and global toggle that writes `model: "opus[1m]"` to `.claude/settings.local.json` when creating new sessions. Fix mobile layout: banners (respawn, timer, orchestrator) between header and main content now visible by switching from margin-top on `.main` to padding-top on `.app`. Add tablet-optimized respawn banner styles and mobile phone banner refinements.
|
||||
|
||||
## 0.5.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix terminal flicker regression — re-add server-side DEC 2026 synchronized output wrapping around batched terminal data. Ink spinner frames (cursor-up + redraw cycles) do not emit their own DEC 2026 markers, so without the server wrapper each partial cursor update rendered individually causing visible flicker. Also: extract SSE stream management, session listener wiring, and respawn event wiring from server.ts into dedicated modules; deduplicate error message extraction across 7 files with shared getErrorMessage() helper; update SSE event count in CLAUDE.md (106 → 117).
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Readability refactor across 12 core files, extracting ~35 helper methods to reduce duplication:
|
||||
- state-store: extract serializeState(), split assembleStateJson() into focused sub-methods
|
||||
- session: extract \_resetBuffers() (3x dedup), \_clearAllTimers() (10 timer cleanups), \_handleJsonMessage()
|
||||
- ralph-tracker: extract completeAllTodos() (4x dedup), emitValidationWarning(), named similarity constants
|
||||
- subagent-watcher: extract markSubagentAsCompleted(), extractFirstTextContent(), emitToolResult(), findOldestInactiveAgent()
|
||||
- respawn-controller: extract recoveryResetToWatching(), canAutoAccept(), formatRemainingSeconds(), validatePositiveTimeout()
|
||||
- tmux-manager: replace 15 path.includes() with UNSAFE_PATH_CHARS regex, extract buildEnvExports/buildPathExport/\_configureOpenCode helpers
|
||||
- session-auto-ops: extract executeWhenIdle() shared retry helper, convert to options object, add validateThreshold()
|
||||
- app.js: add \_clearTimer() (11 call sites), \_isStaleSelect(), keyboard shortcut lookup table, \_cleanupPreviousSession(), \_resetAllAppState()
|
||||
- route-helpers: add readJsonConfig() (5 inline patterns replaced), validateSessionFilePath() (2 duplicated blocks replaced)
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Make buffer size limits configurable via CODEMAN\_\* environment variables (MAX_TERMINAL_BUFFER, TRIM_TERMINAL_TO, MAX_TEXT_OUTPUT, TRIM_TEXT_TO, MAX_MESSAGES), falling back to existing defaults. Allows users with fewer sessions or more RAM to tune buffer sizes without patching source.
|
||||
|
||||
Fix duplicate terminal output on tab switch to busy sessions by clearing the terminal before writing the new buffer.
|
||||
|
||||
Fix stale Ink CUP frames after tab switch by sending Ctrl+L to force a clean redraw.
|
||||
|
||||
Fix mobile CJK input handling: resolve textarea positioning, terminal flicker during composition, and layout overflow on small screens. Improve CJK composition lifecycle with better event handling and fallback flush timers.
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- refactor: codebase cleanup — extract route helpers, eliminate boilerplate, optimize hot paths
|
||||
- Add `parseBody()` helper to route-helpers.ts: validates request body against Zod schema with structured 400 error on failure, replacing 37 identical safeParse + error-check blocks across 10 route files
|
||||
- Add `persistAndBroadcastSession()` helper: combines persist + SessionUpdated broadcast into one call, replacing 5 repeated 2-line pairs
|
||||
- Migrate session-routes.ts to use `findSessionOrFail()` consistently (17 inline session lookups replaced) and `parseBody()` (12 patterns)
|
||||
- Migrate ralph-routes.ts to use `findSessionOrFail()` (9 lookups) and `parseBody()` (4 patterns)
|
||||
- Migrate 8 remaining route files to use `parseBody()` (21 patterns total)
|
||||
- Fix O(n log n) eviction in bash-tool-parser.ts: replace `Array.from().sort()[0]` with O(n) min-scan for oldest active tool
|
||||
- Extract `_debouncedCall()` utility in frontend: replaces 4 manual debounce patterns (7 lines each → 1 line) in app.js, panels-ui.js, ralph-panel.js
|
||||
- Net reduction: 208 lines removed across 16 files
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Visual redesign with glass morphism, refined colors, and polished UI. Optimize history endpoint with buffer reuse and line iterator. Fix Ink frame search window (4KB→64KB) to prevent partial frames. Fix stale terminal data on tab switch via chunkedTerminalWrite cancellation. Improve history prompt extraction with expanded command filtering and tail scan fallback. Align case select group height to match dropdown. Fix no-control-regex lint error for ANSI strip pattern. Add browser-testing-guide to CLAUDE.md references.
|
||||
|
||||
## 0.4.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feat: improve session navigability in history and monitor panel (closes #45)
|
||||
- History items now show the first user prompt as the title with the project path as a subtitle, making it much easier to distinguish sessions from the same project
|
||||
- The `/api/history/sessions` endpoint extracts the first user message from each transcript JSONL, stripping system-injected XML tags and command artifacts, truncating to 120 chars
|
||||
- Monitor panel session rows are now clickable — clicking navigates directly to that session's tab via `selectSession()`; Kill button retains independent behavior via `stopPropagation()`
|
||||
- Updated CLAUDE.md architecture tables to reflect Orchestrator Loop additions (14 route modules, 15 type files, orchestrator domain files, orchestrator-panel.js frontend module)
|
||||
- fix: stop subagent monitor windows from auto-opening on discovery
|
||||
- feat: add Orchestrator Loop with phased plan execution, live progress during plan generation, and toolbar button (hidden until fully tested)
|
||||
- fix: patch 3 production bugs found during deep audit
|
||||
- fix: restore mobile terminal scrollback using JS scrollLines() instead of broken native scroll
|
||||
|
||||
## 0.4.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix mobile keyboard scroll and layout issues:
|
||||
- Prevent iOS Safari from scrolling the page when typing with the keyboard open (position:fixed on .app + window.scroll reset)
|
||||
- Eliminate dead space between terminal and keyboard accessory bar by removing redundant CSS padding, tightening JS padding constant, and adding row quantization gap compensation
|
||||
- Fix toolbar overlapping terminal content when keyboard is hidden by adding proper padding-bottom to .main, including iOS Safari bottom bar offset
|
||||
- Strip Ink spinner bloat from terminal buffer before tailing
|
||||
- Fix resolveCasePath priority order and suppress JSON parse warnings
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix mobile keyboard toolbar positioning on iOS Safari: toolbar (Run/Stop/Run Shell) was hidden behind the accessory bar when virtual keyboard was active due to overlapping CSS positions. Remove the aggressive safety check in `updateLayoutForKeyboard()` that incorrectly dismissed keyboard state when iOS scrolled the visual viewport during typing. Add Safari-bar CSS offset to accessory bar so it properly stacks above the toolbar. Remove the double-counted Safari-bar offset when keyboard is visible since the JS transform already covers the full distance.
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: mobile keyboard hides terminal content on iPhone
|
||||
|
||||
Fixed a bug where opening the virtual keyboard on iPhone left zero visible terminal space. Two independent mechanisms were both accounting for the keyboard height: `MobileDetection.updateAppHeight()` shrunk `--app-height` to the visual viewport height, while `KeyboardHandler.updateLayoutForKeyboard()` added a large `paddingBottom`. These double-counted, leaving negative space for the terminal (user saw accessory bar + toolbar but no terminal content).
|
||||
|
||||
Fix: `updateAppHeight()` now skips when the keyboard is visible, and `handleViewportResize()` restores `--app-height` to the pre-keyboard value on first detection (since MobileDetection's listener fires before KeyboardHandler's). On keyboard close, `--app-height` is re-synced to the current visual viewport.
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Dev server | `npx tsx src/index.ts web` |
|
||||
| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) |
|
||||
| Type check | `tsc --noEmit` |
|
||||
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
|
||||
| Format | `npm run format` (check: `npm run format:check`) |
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.4.3 (must match `package.json`)
|
||||
**Version**: 0.5.12 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -78,9 +78,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master (Node 22). Tests excluded (they spawn tmux).
|
||||
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
|
||||
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `tools/**`, `remotion/**`.
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
@@ -102,15 +102,16 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | |
|
||||
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
|
||||
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
|
||||
| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
|
||||
| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts` | |
|
||||
| **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | |
|
||||
| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 route modules incl. `ws-routes.ts` + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 6 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~2.8K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
|
||||
@@ -118,7 +119,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Config**: `src/config/` — 9 files. Import from specific files, not barrel.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks).
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
### Data Flow
|
||||
|
||||
@@ -135,7 +136,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**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`.
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `agent-teams/`.
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
|
||||
|
||||
@@ -143,13 +144,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7).
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+Enter (quick start), Ctrl+W (kill), Ctrl+Tab (next), Ctrl+K (kill all), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
|
||||
|
||||
### Security
|
||||
|
||||
@@ -166,11 +167,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### SSE Event Registry
|
||||
|
||||
~106 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
~117 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
|
||||
### API Routes
|
||||
|
||||
~114 handlers across 13 route files in `src/web/routes/`: system (36), sessions (25), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~124 handlers across 14 route files in `src/web/routes/`: system (36), sessions (25), orchestrator (10), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
## Adding Features
|
||||
|
||||
@@ -203,7 +204,7 @@ npx vitest run -t "pattern" # By name (SAFE)
|
||||
|
||||
**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()` in `test/routes/`. **Mobile tests**: Playwright suite in `mobile-test/` (135 device profiles).
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -225,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
||||
|
||||
## References
|
||||
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `docs/agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
@@ -30,24 +30,6 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||
|
||||
**Install from a fork or specific branch:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/<user>/Codeman/<branch>/install.sh | \
|
||||
CODEMAN_REPO_URL=https://github.com/<user>/Codeman.git \
|
||||
CODEMAN_BRANCH=<branch> bash
|
||||
```
|
||||
|
||||
The installer supports these environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CODEMAN_REPO_URL` | upstream Codeman | Custom git repository URL |
|
||||
| `CODEMAN_BRANCH` | `master` | Git branch to install |
|
||||
| `CODEMAN_INSTALL_DIR` | `~/.codeman/app` | Custom install directory |
|
||||
| `CODEMAN_SKIP_SYSTEMD` | `0` | Skip systemd service setup prompt |
|
||||
| `CODEMAN_NODE_VERSION` | `22` | Node.js major version to install |
|
||||
| `CODEMAN_NONINTERACTIVE` | `0` | Skip all prompts (for CI/automation) |
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
|
||||
|
||||
```bash
|
||||
@@ -60,12 +42,53 @@ codeman web
|
||||
|
||||
**Linux (systemd):**
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user && printf '[Unit]\nDescription=Codeman Web Server\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=%s %s/dist/index.js web\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n' "$(which node)" "$HOME/.codeman/app" > ~/.config/systemd/user/codeman-web.service && systemctl --user daemon-reload && systemctl --user enable --now codeman-web && loginctl enable-linger $USER
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/codeman-web.service << EOF
|
||||
[Unit]
|
||||
Description=Codeman Web Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$(which node) $HOME/.codeman/app/dist/index.js web
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now codeman-web
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
**macOS (launchd):**
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents && printf '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.codeman.web</string><key>ProgramArguments</key><array><string>%s</string><string>%s/dist/index.js</string><string>web</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>StandardOutPath</key><string>/tmp/codeman.log</string><key>StandardErrorPath</key><string>/tmp/codeman.log</string></dict></plist>\n' "$(which node)" "$HOME/.codeman/app" > ~/Library/LaunchAgents/com.codeman.web.plist && launchctl load ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
mkdir -p ~/Library/LaunchAgents
|
||||
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.codeman.web</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$(which node)</string>
|
||||
<string>$HOME/.codeman/app/dist/index.js</string>
|
||||
<string>web</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ export default tseslint.config(
|
||||
'src/web/public/vendor/**',
|
||||
'src/web/public/app.js',
|
||||
'scripts/**/*.mjs',
|
||||
'tools/**',
|
||||
'remotion/**',
|
||||
'scripts/remotion/**',
|
||||
],
|
||||
}
|
||||
);
|
||||
@@ -1,7 +1,11 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
@@ -0,0 +1,367 @@
|
||||
# Orchestrator Loop — Architecture & Data Flow
|
||||
|
||||
> Technical architecture document. Not for GitHub.
|
||||
|
||||
## System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ CODEMAN WEB UI │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Orchestrator Dashboard │ │
|
||||
│ │ [Goal Input] [Plan View] [Phase Progress] [Agent Activity] │ │
|
||||
│ └───────────────────────────┬──────────────────────────────────┘ │
|
||||
│ │ SSE Events │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Orchestrator API Routes (/api/orchestrator/*) │ │
|
||||
│ └───────────────────────────┬──────────────────────────────────┘ │
|
||||
└───────────────────────────────┼─────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ORCHESTRATOR LOOP │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Orchestrator │ │ Orchestrator │ │ Orchestrator │ │
|
||||
│ │ Planner │ │ Loop (state │ │ Verifier │ │
|
||||
│ │ │ │ machine) │ │ │ │
|
||||
│ │ • Research │◄──►│ • Phase mgmt │◄──►│ • Test runner │ │
|
||||
│ │ • Plan gen │ │ • Task queue │ │ • AI review │ │
|
||||
│ │ • Phasing │ │ • Event loop │ │ • Output checks │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ EXISTING CODEMAN INFRASTRUCTURE │ │
|
||||
│ │ │ │
|
||||
│ │ SessionManager ←→ Sessions ←→ PTY (Claude CLI) │ │
|
||||
│ │ ↑ ↑ ↑ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ TaskQueue RalphTracker RespawnController │ │
|
||||
│ │ StateStore HooksConfig TeamWatcher │ │
|
||||
│ │ Auto-Ops SubagentWatcher SSE Broadcast │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Data Flow: Complete Lifecycle
|
||||
|
||||
### 1. User Submits Goal
|
||||
|
||||
```
|
||||
User → POST /api/orchestrator/start { goal: "Build a REST API...", config: {...} }
|
||||
→ OrchestratorLoop.start(goal)
|
||||
→ state = PLANNING
|
||||
→ emit('stateChanged', 'planning')
|
||||
→ SSE: orchestrator:stateChanged
|
||||
```
|
||||
|
||||
### 2. Planning Phase
|
||||
|
||||
```
|
||||
OrchestratorPlanner.generatePlan(goal)
|
||||
→ PlanOrchestrator.generateDetailedPlan(goal)
|
||||
→ [Research Agent] → enriched task description
|
||||
→ [Planner Agent] → PlanItem[]
|
||||
→ groupIntoPhases(planItems)
|
||||
→ topological sort by dependencies
|
||||
→ group into layers
|
||||
→ assign team strategies
|
||||
→ OrchestratorPlan { phases: [...] }
|
||||
→ state = APPROVAL
|
||||
→ emit('planReady', plan)
|
||||
→ SSE: orchestrator:planReady
|
||||
```
|
||||
|
||||
### 3. User Approves Plan
|
||||
|
||||
```
|
||||
User → POST /api/orchestrator/approve
|
||||
→ OrchestratorLoop.approvePlan()
|
||||
→ state = EXECUTING
|
||||
→ executePhase(phases[0])
|
||||
```
|
||||
|
||||
### 4. Phase Execution
|
||||
|
||||
```
|
||||
executePhase(phase)
|
||||
→ For each task in phase:
|
||||
→ Convert to CreateTaskOptions
|
||||
→ Add to TaskQueue with completion phrase "PHASE_{N}_TASK_{M}_DONE"
|
||||
→ If phase.teamStrategy.type === 'team':
|
||||
→ Start session with AGENT_TEAMS enabled
|
||||
→ Send team orchestration prompt to lead
|
||||
→ Else:
|
||||
→ Assign tasks to available sessions (same as RalphLoop)
|
||||
|
||||
→ Listen for task completion events:
|
||||
→ TaskQueue emits taskCompleted
|
||||
→ Check: all phase tasks done?
|
||||
→ Yes → state = VERIFYING → verifyPhase(phase)
|
||||
→ No → wait for more completions
|
||||
```
|
||||
|
||||
### 5. Verification
|
||||
|
||||
```
|
||||
verifyPhase(phase)
|
||||
→ OrchestratorVerifier.verify(phase, session)
|
||||
→ Run test commands via session
|
||||
→ Check file existence
|
||||
→ AI review (optional)
|
||||
→ If passed:
|
||||
→ phase.status = 'passed'
|
||||
→ emit('phaseCompleted', phase)
|
||||
→ If more phases: executePhase(nextPhase)
|
||||
→ If last phase: state = COMPLETED
|
||||
→ If failed:
|
||||
→ phase.attempts++
|
||||
→ If attempts < maxAttempts:
|
||||
→ state = REPLANNING
|
||||
→ Generate recovery tasks
|
||||
→ state = EXECUTING (retry)
|
||||
→ Else:
|
||||
→ state = FAILED
|
||||
→ emit('phaseFailed', phase, reason)
|
||||
```
|
||||
|
||||
### 6. Context Management Between Phases
|
||||
|
||||
```
|
||||
After phase completion:
|
||||
→ If config.compactBetweenPhases:
|
||||
→ session.sendInput('/compact')
|
||||
→ Wait for compact to complete
|
||||
→ If config.respawnBetweenMilestones && phase is a milestone:
|
||||
→ Save orchestrator state to StateStore
|
||||
→ Respawn session (kill + recreate)
|
||||
→ Send resume prompt with phase context
|
||||
```
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── orchestrator-loop.ts # Main state machine (~400 lines)
|
||||
├── orchestrator-planner.ts # Plan generation + phase grouping (~300 lines)
|
||||
├── orchestrator-verifier.ts # Phase verification (~200 lines)
|
||||
├── types/
|
||||
│ └── orchestrator.ts # All orchestrator types (~150 lines)
|
||||
├── prompts/
|
||||
│ └── orchestrator.ts # Prompt templates (~200 lines)
|
||||
├── web/
|
||||
│ ├── routes/
|
||||
│ │ └── orchestrator-routes.ts # API endpoints (~250 lines)
|
||||
│ └── public/
|
||||
│ └── orchestrator-ui.js # Frontend panel (~500 lines)
|
||||
```
|
||||
|
||||
## Integration Points with Existing Code
|
||||
|
||||
### StateStore (`src/state-store.ts`)
|
||||
```typescript
|
||||
// Add to AppState interface
|
||||
orchestrator?: OrchestratorPersistState;
|
||||
|
||||
// Add methods
|
||||
getOrchestratorState(): OrchestratorPersistState;
|
||||
setOrchestratorState(state: Partial<OrchestratorPersistState>): void;
|
||||
```
|
||||
|
||||
### SSE Events (`src/web/sse-events.ts`)
|
||||
```typescript
|
||||
// Add ~8 new events
|
||||
export const SseEvent = {
|
||||
// ... existing
|
||||
ORCHESTRATOR_STATE_CHANGED: 'orchestrator:stateChanged',
|
||||
ORCHESTRATOR_PLAN_READY: 'orchestrator:planReady',
|
||||
ORCHESTRATOR_PHASE_STARTED: 'orchestrator:phaseStarted',
|
||||
ORCHESTRATOR_PHASE_COMPLETED: 'orchestrator:phaseCompleted',
|
||||
ORCHESTRATOR_PHASE_FAILED: 'orchestrator:phaseFailed',
|
||||
ORCHESTRATOR_VERIFICATION: 'orchestrator:verificationResult',
|
||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Frontend Constants (`src/web/public/constants.js`)
|
||||
```javascript
|
||||
// Mirror SSE events
|
||||
SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED = 'orchestrator:stateChanged';
|
||||
// ... etc
|
||||
```
|
||||
|
||||
### Route Registration (`src/web/routes/index.ts`)
|
||||
```typescript
|
||||
import { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||
// Add to barrel export
|
||||
```
|
||||
|
||||
### Server (`src/web/server.ts`)
|
||||
```typescript
|
||||
// Initialize OrchestratorLoop alongside RalphLoop
|
||||
const orchestratorLoop = new OrchestratorLoop(config);
|
||||
|
||||
// Register routes
|
||||
registerOrchestratorRoutes(app, { ...ctx, orchestrator: orchestratorLoop });
|
||||
```
|
||||
|
||||
### Port Interface (`src/web/ports/`)
|
||||
```typescript
|
||||
// New port
|
||||
export interface OrchestratorPort {
|
||||
orchestrator: OrchestratorLoop;
|
||||
}
|
||||
```
|
||||
|
||||
## Prompt Flow Through System
|
||||
|
||||
The key insight is how prompts flow from Orchestrator → Session → Claude:
|
||||
|
||||
```
|
||||
OrchestratorLoop decides to execute Phase 3, Task 2
|
||||
│
|
||||
▼
|
||||
Converts OrchestratorTask to CreateTaskOptions:
|
||||
{
|
||||
prompt: "Implement the rate limiter middleware. Read src/middleware/auth.ts
|
||||
for the pattern. Add to src/middleware/rate-limiter.ts. Must export
|
||||
a Fastify plugin. When done: <promise>PHASE_3_TASK_2_DONE</promise>",
|
||||
priority: 100,
|
||||
dependencies: ["phase-3-task-1"], // Must finish auth middleware first
|
||||
completionPhrase: "PHASE_3_TASK_2_DONE",
|
||||
timeoutMs: 600000 // 10 minutes
|
||||
}
|
||||
│
|
||||
▼
|
||||
TaskQueue.addTask(options)
|
||||
│
|
||||
▼
|
||||
RalphLoop.tick() → assignTasks() // OR OrchestratorLoop does its own assignment
|
||||
│
|
||||
▼
|
||||
session.sendInput(task.prompt)
|
||||
│
|
||||
▼
|
||||
writeViaMux() → tmux send-keys -l "prompt..." + Enter
|
||||
│
|
||||
▼
|
||||
Claude CLI receives prompt, executes, outputs results
|
||||
│
|
||||
▼
|
||||
RalphTracker.processData() → detects "PHASE_3_TASK_2_DONE"
|
||||
│
|
||||
▼
|
||||
emit('completionDetected') → OrchestratorLoop.handleTaskCompleted()
|
||||
│
|
||||
▼
|
||||
Check: all tasks in Phase 3 done? → If yes → verifyPhase(phase3)
|
||||
```
|
||||
|
||||
## Team Agent Flow (When Enabled)
|
||||
|
||||
```
|
||||
Phase has teamStrategy.type === 'team'
|
||||
│
|
||||
▼
|
||||
OrchestratorLoop creates/reuses a session with:
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }
|
||||
│
|
||||
▼
|
||||
Sends team orchestration prompt:
|
||||
"You're the team lead for Phase 3: Core Implementation.
|
||||
|
||||
Your team should work on these tasks in parallel:
|
||||
1. Rate limiter middleware (teammate 1)
|
||||
2. Error handling middleware (teammate 2)
|
||||
3. Validation layer (teammate 3)
|
||||
|
||||
Context files to read first: [...]
|
||||
Each teammate should output their task's completion phrase when done.
|
||||
When ALL tasks are complete, output: <promise>PHASE_3_COMPLETE</promise>"
|
||||
│
|
||||
▼
|
||||
Claude Code team-lead spawns teammates
|
||||
│
|
||||
▼
|
||||
TeamWatcher detects new team in ~/.claude/teams/
|
||||
→ Matches to session via leadSessionId
|
||||
→ Tracks teammate activity
|
||||
│
|
||||
▼
|
||||
Teammates work in parallel (in-process threads)
|
||||
│
|
||||
▼
|
||||
hook: teammate_idle → POST /api/hook-event
|
||||
→ OrchestratorLoop notes teammate finished
|
||||
│
|
||||
▼
|
||||
hook: task_completed → POST /api/hook-event
|
||||
→ Or: RalphTracker detects PHASE_3_COMPLETE
|
||||
→ OrchestratorLoop → phase complete → verify
|
||||
```
|
||||
|
||||
## Error Recovery Strategy
|
||||
|
||||
```
|
||||
Task fails (timeout, error, session crash)
|
||||
│
|
||||
├─ Task-level retry (up to 2 retries per task)
|
||||
│ → Reset task to pending
|
||||
│ → Re-queue with modified prompt: "Previous attempt failed: {error}. Try again..."
|
||||
│
|
||||
├─ Phase-level retry (up to 3 retries per phase)
|
||||
│ → Respawn session (fresh context)
|
||||
│ → Re-execute entire phase with learnings from failure
|
||||
│ → Modified prompt includes what went wrong
|
||||
│
|
||||
└─ Orchestration-level failure
|
||||
→ All retries exhausted
|
||||
→ state = FAILED
|
||||
→ Notify user with detailed failure report
|
||||
→ User can: modify plan → retry, skip phase → continue, or stop
|
||||
```
|
||||
|
||||
## Interaction with Ralph Loop
|
||||
|
||||
Ralph Loop and Orchestrator Loop are **mutually exclusive** on the same sessions:
|
||||
|
||||
```
|
||||
if (orchestratorLoop.isRunning()) {
|
||||
// Orchestrator controls task assignment
|
||||
// Ralph Loop should not interfere
|
||||
// Respawn Controller uses 'orchestrator' preset
|
||||
}
|
||||
|
||||
if (ralphLoop.isRunning()) {
|
||||
// Ralph controls task assignment
|
||||
// Orchestrator should not start
|
||||
}
|
||||
```
|
||||
|
||||
The Orchestrator can optionally USE the Ralph Loop internally for phase execution (delegate phase tasks to Ralph's queue), or manage task assignment directly. Decision: **manage directly** — gives more control over phase boundaries and verification timing.
|
||||
|
||||
## Summary of What Touches What
|
||||
|
||||
| Existing File | Change |
|
||||
|---|---|
|
||||
| `src/types/index.ts` | Export orchestrator types |
|
||||
| `src/state-store.ts` | Add orchestrator state persistence |
|
||||
| `src/web/sse-events.ts` | Add ~8 orchestrator events |
|
||||
| `src/web/routes/index.ts` | Register orchestrator routes |
|
||||
| `src/web/server.ts` | Initialize OrchestratorLoop |
|
||||
| `src/web/public/constants.js` | Mirror SSE events |
|
||||
| `src/web/public/app.js` | Add orchestrator event listeners, panel toggle |
|
||||
| `src/web/route-helpers.ts` | Add 'orchestrator' respawn preset |
|
||||
|
||||
| New File | Purpose |
|
||||
|---|---|
|
||||
| `src/orchestrator-loop.ts` | Core state machine |
|
||||
| `src/orchestrator-planner.ts` | Plan generation + phasing |
|
||||
| `src/orchestrator-verifier.ts` | Phase verification |
|
||||
| `src/types/orchestrator.ts` | Type definitions |
|
||||
| `src/prompts/orchestrator.ts` | Prompt templates |
|
||||
| `src/web/routes/orchestrator-routes.ts` | API endpoints |
|
||||
| `src/web/public/orchestrator-ui.js` | Frontend panel |
|
||||
| `src/web/ports/orchestrator-port.ts` | Port interface |
|
||||
@@ -0,0 +1,633 @@
|
||||
# Orchestrator Loop — Detailed Implementation Plan (v2)
|
||||
|
||||
> Internal research/planning document. Not for GitHub.
|
||||
|
||||
## Vision
|
||||
|
||||
The **Orchestrator Loop** is a new autonomous execution mode that transforms high-level user goals into phased, verified, team-coordinated implementations. Unlike Ralph Loop (flat task queue → idle sessions), the Orchestrator manages the full lifecycle: **plan → approve → execute → verify → adapt → complete**.
|
||||
|
||||
```
|
||||
USER: "Add OAuth2 login with Google/GitHub, role-based access control, and API key management"
|
||||
|
||||
ORCHESTRATOR:
|
||||
Phase 1: Research & Setup ✅ (3m) — scaffold, deps, config
|
||||
Phase 2: Auth Core ✅ (8m) — OAuth2 flow, session mgmt
|
||||
Phase 3: Provider Integration 🔄 (12m) — Google + GitHub (parallel via team agents)
|
||||
Phase 4: RBAC ⏳ — roles, permissions, middleware
|
||||
Phase 5: API Keys ⏳ — generation, validation, rate limits
|
||||
Phase 6: Testing & Review ⏳ — integration tests, security review
|
||||
|
||||
Progress: ━━━━━━━━━━━━━━━━━━━━ 40% | Agents: 3 active | Time: 23m
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OrchestratorLoop │
|
||||
│ │
|
||||
│ ┌────────────────┐ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Orchestrator │ │ Orchestrator │ │ Orchestrator │ │
|
||||
│ │ Planner │ │ Executor │ │ Verifier │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ PlanOrchestrator│ │ TaskQueue │ │ AI review │ │
|
||||
│ │ + phase grouper│ │ SessionManager │ │ Test commands │ │
|
||||
│ │ + team strategy│ │ Team prompts │ │ File checks │ │
|
||||
│ └───────┬────────┘ └───────┬────────┘ └─────────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └───────────────────┼──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ Existing Codeman │ │
|
||||
│ │ Infrastructure │ │
|
||||
│ │ │ │
|
||||
│ │ SessionManager │ │
|
||||
│ │ TaskQueue │ │
|
||||
│ │ RespawnController │ │
|
||||
│ │ TeamWatcher │ │
|
||||
│ │ PlanOrchestrator │ │
|
||||
│ │ StateStore │ │
|
||||
│ │ Hooks + SSE │ │
|
||||
│ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ IDLE │
|
||||
└────┬────┘
|
||||
│ start(goal)
|
||||
▼
|
||||
┌─────────┐
|
||||
┌────────│PLANNING │────────┐
|
||||
│ fail └────┬────┘ │
|
||||
▼ │ plan ready │ user cancels
|
||||
┌────────┐ ▼ ▼
|
||||
│ FAILED │ ┌─────────┐ ┌────────┐
|
||||
└────────┘ │APPROVAL │ │ IDLE │
|
||||
▲ └────┬────┘ └────────┘
|
||||
│ │ approve
|
||||
│ ▼
|
||||
│ ┌──────────┐
|
||||
│ ┌───►│EXECUTING │◄────────────────────┐
|
||||
│ │ └────┬─────┘ │
|
||||
│ │ │ all tasks in phase done │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────┐ │
|
||||
│ │ │VERIFYING │ │
|
||||
│ │ └────┬─────┘ │
|
||||
│ │ pass │ │ fail │
|
||||
│ │ ▼ ▼ │
|
||||
│ │ more ┌──────────┐ │
|
||||
│ │ phases?│REPLANNING│── retry ────────┘
|
||||
│ │ │ └────┬─────┘
|
||||
│ │ │ │ max retries
|
||||
│ │ │ ▼
|
||||
│ │ │ ┌────────┐
|
||||
│ └────┘ │ FAILED │
|
||||
│ next └────────┘
|
||||
│ phase
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌───────────┐
|
||||
└─│ COMPLETED │
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
**States:** `idle` | `planning` | `approval` | `executing` | `verifying` | `replanning` | `completed` | `failed` | `paused`
|
||||
|
||||
Transitions are event-driven. The state machine is the single source of truth — all methods check `this.state` before acting.
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### `src/types/orchestrator.ts`
|
||||
|
||||
```typescript
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State Machine
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export type OrchestratorState =
|
||||
| 'idle'
|
||||
| 'planning'
|
||||
| 'approval'
|
||||
| 'executing'
|
||||
| 'verifying'
|
||||
| 'replanning'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'paused';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Structure
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface OrchestratorPlan {
|
||||
id: string;
|
||||
goal: string;
|
||||
createdAt: number;
|
||||
phases: OrchestratorPhase[];
|
||||
metadata: {
|
||||
totalTasks: number;
|
||||
estimatedComplexity: 'low' | 'medium' | 'high';
|
||||
modelUsed: string;
|
||||
planDurationMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrchestratorPhase {
|
||||
id: string; // "phase-1", "phase-2"
|
||||
name: string; // Human-readable name
|
||||
description: string;
|
||||
order: number;
|
||||
status: PhaseStatus;
|
||||
tasks: OrchestratorTask[];
|
||||
verificationCriteria: string[];
|
||||
testCommands: string[];
|
||||
maxAttempts: number; // Default: 3
|
||||
attempts: number; // Current attempt count
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
durationMs: number | null;
|
||||
teamStrategy: TeamStrategy;
|
||||
}
|
||||
|
||||
export type PhaseStatus =
|
||||
| 'pending'
|
||||
| 'executing'
|
||||
| 'verifying'
|
||||
| 'passed'
|
||||
| 'failed'
|
||||
| 'skipped';
|
||||
|
||||
export interface OrchestratorTask {
|
||||
id: string; // "phase-1-task-1"
|
||||
phaseId: string;
|
||||
prompt: string; // Single-line prompt for Claude
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
assignedSessionId: string | null;
|
||||
queueTaskId: string | null; // Links to TaskQueue task
|
||||
parallel: boolean; // Can run in parallel with sibling tasks
|
||||
completionPhrase: string; // Unique phrase for completion detection
|
||||
timeoutMs: number;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
error: string | null;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Team Strategy
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export type TeamStrategy =
|
||||
| { type: 'single' } // One session handles all
|
||||
| { type: 'parallel'; maxSessions: number } // Multiple sessions
|
||||
| { type: 'team'; config: TeamSetup } // Agent teams
|
||||
|
||||
export interface TeamSetup {
|
||||
leadPrompt: string;
|
||||
suggestedTeammates: string[]; // Role descriptions
|
||||
maxTeammates: number;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Verification
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface VerificationResult {
|
||||
passed: boolean;
|
||||
checks: VerificationCheck[];
|
||||
summary: string;
|
||||
suggestions: string[]; // Recovery hints for replanning
|
||||
}
|
||||
|
||||
export interface VerificationCheck {
|
||||
type: 'test_command' | 'ai_review' | 'file_check';
|
||||
description: string;
|
||||
passed: boolean;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Configuration
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface OrchestratorConfig {
|
||||
plannerModel: string; // Default: 'opus'
|
||||
researchEnabled: boolean; // Default: true
|
||||
autoApprove: boolean; // Default: false
|
||||
maxPhaseRetries: number; // Default: 3
|
||||
phaseTimeoutMs: number; // Default: 1800000 (30min)
|
||||
enableTeamAgents: boolean; // Default: true
|
||||
maxParallelSessions: number; // Default: 3
|
||||
verificationMode: 'strict' | 'moderate' | 'lenient';
|
||||
compactBetweenPhases: boolean; // Default: true
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Persistence (saved to ~/.codeman/state.json)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface OrchestratorPersistState {
|
||||
state: OrchestratorState;
|
||||
plan: OrchestratorPlan | null;
|
||||
currentPhaseIndex: number;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
config: OrchestratorConfig;
|
||||
stats: OrchestratorStats;
|
||||
}
|
||||
|
||||
export interface OrchestratorStats {
|
||||
phasesCompleted: number;
|
||||
phasesFailed: number;
|
||||
totalTasksCompleted: number;
|
||||
totalTasksFailed: number;
|
||||
totalDurationMs: number;
|
||||
replanCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
## New Files (Implementation Order)
|
||||
|
||||
### Step 1: `src/types/orchestrator.ts` — Type definitions
|
||||
All interfaces above. No dependencies. ~120 lines.
|
||||
|
||||
### Step 2: `src/orchestrator-planner.ts` — Plan generation + phase grouping
|
||||
~300 lines. Wraps existing PlanOrchestrator.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @fileoverview Orchestrator plan generation — converts goals into phased plans.
|
||||
*
|
||||
* Uses PlanOrchestrator for AI plan generation, then groups PlanItems into
|
||||
* sequential phases with team strategies and verification criteria.
|
||||
*
|
||||
* @module orchestrator-planner
|
||||
*/
|
||||
|
||||
export class OrchestratorPlanner {
|
||||
constructor(mux: TerminalMultiplexer, workingDir: string, config: OrchestratorConfig);
|
||||
|
||||
/** Generate plan from goal. Uses PlanOrchestrator internally. */
|
||||
async generatePlan(goal: string, onProgress?: ProgressCallback): Promise<OrchestratorPlan>;
|
||||
|
||||
/** Cancel in-progress plan generation. */
|
||||
async cancel(): Promise<void>;
|
||||
|
||||
// Internal
|
||||
private groupIntoPhases(items: PlanItem[], goal: string): OrchestratorPhase[];
|
||||
private assignTeamStrategies(phases: OrchestratorPhase[]): void;
|
||||
private generateCompletionPhrases(plan: OrchestratorPlan): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Phase grouping algorithm:**
|
||||
1. Topological sort by `PlanItem.dependencies`
|
||||
2. Group into dependency layers (Kahn's algorithm)
|
||||
3. Within each layer, sub-group by `tddPhase` (setup → test → impl → verify → review)
|
||||
4. Merge adjacent small phases (< 2 tasks) if they share the same tddPhase
|
||||
5. Assign team strategies:
|
||||
- 1-2 tasks → `{ type: 'single' }`
|
||||
- 3+ independent tasks → `{ type: 'parallel', maxSessions: Math.min(taskCount, config.maxParallelSessions) }`
|
||||
- 4+ tasks with high complexity → `{ type: 'team', config: { ... } }`
|
||||
6. Generate unique completion phrases per task: `ORCH_P{phaseOrder}_T{taskIndex}`
|
||||
|
||||
### Step 3: `src/orchestrator-verifier.ts` — Phase verification
|
||||
~200 lines.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @fileoverview Orchestrator phase verification.
|
||||
*
|
||||
* Runs verification checks after each phase completes:
|
||||
* test commands, AI review, and file existence checks.
|
||||
*
|
||||
* @module orchestrator-verifier
|
||||
*/
|
||||
|
||||
export class OrchestratorVerifier {
|
||||
constructor(config: OrchestratorConfig);
|
||||
|
||||
/** Run all verification checks for a completed phase. */
|
||||
async verifyPhase(
|
||||
phase: OrchestratorPhase,
|
||||
session: Session,
|
||||
mode: 'strict' | 'moderate' | 'lenient'
|
||||
): Promise<VerificationResult>;
|
||||
|
||||
// Verification strategies
|
||||
private async runTestCommands(commands: string[], session: Session): Promise<VerificationCheck[]>;
|
||||
private async aiReview(phase: OrchestratorPhase, session: Session): Promise<VerificationCheck>;
|
||||
}
|
||||
```
|
||||
|
||||
**Verification modes:**
|
||||
- `strict`: ALL test commands must pass AND AI review must approve
|
||||
- `moderate`: Test commands must pass, AI review is advisory
|
||||
- `lenient`: At least one test command passes, AI review skipped
|
||||
|
||||
**AI review prompt (sent as a task to the session):**
|
||||
```
|
||||
Review Phase "{phase.name}" completion. Check:
|
||||
1. Expected functionality works
|
||||
2. No obvious regressions
|
||||
3. Code quality is acceptable
|
||||
|
||||
Criteria: {phase.verificationCriteria.join('\n')}
|
||||
|
||||
If ALL criteria are met, respond: ORCH_VERIFY_PASS
|
||||
If ANY criteria fail, respond: ORCH_VERIFY_FAIL and explain what failed.
|
||||
```
|
||||
|
||||
### Step 4: `src/orchestrator-loop.ts` — Core state machine
|
||||
~500 lines. Main orchestrator engine.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @fileoverview Orchestrator Loop — phased plan execution with team agents.
|
||||
*
|
||||
* State machine that generates plans from user goals, executes them
|
||||
* phase-by-phase with verification gates, and adapts on failure.
|
||||
*
|
||||
* @module orchestrator-loop
|
||||
*/
|
||||
|
||||
export interface OrchestratorLoopEvents {
|
||||
stateChanged: (state: OrchestratorState, prevState: OrchestratorState) => void;
|
||||
planReady: (plan: OrchestratorPlan) => void;
|
||||
phaseStarted: (phase: OrchestratorPhase) => void;
|
||||
phaseCompleted: (phase: OrchestratorPhase) => void;
|
||||
phaseFailed: (phase: OrchestratorPhase, reason: string) => void;
|
||||
taskAssigned: (task: OrchestratorTask, sessionId: string) => void;
|
||||
taskCompleted: (task: OrchestratorTask) => void;
|
||||
taskFailed: (task: OrchestratorTask, error: string) => void;
|
||||
verificationResult: (phase: OrchestratorPhase, result: VerificationResult) => void;
|
||||
completed: (stats: OrchestratorStats) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
|
||||
export class OrchestratorLoop extends EventEmitter {
|
||||
private state: OrchestratorState = 'idle';
|
||||
private plan: OrchestratorPlan | null = null;
|
||||
private currentPhaseIndex = 0;
|
||||
private config: OrchestratorConfig;
|
||||
private planner: OrchestratorPlanner;
|
||||
private verifier: OrchestratorVerifier;
|
||||
private sessionManager: SessionManager;
|
||||
private taskQueue: TaskQueue;
|
||||
private store: StateStore;
|
||||
private stats: OrchestratorStats;
|
||||
private cleanup: CleanupManager;
|
||||
private pausedState: OrchestratorState | null = null; // State before pause
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────
|
||||
|
||||
constructor(mux: TerminalMultiplexer, workingDir: string, config?: Partial<OrchestratorConfig>);
|
||||
|
||||
/** Start orchestration with a goal. Transitions: idle → planning */
|
||||
async start(goal: string): Promise<void>;
|
||||
|
||||
/** Approve the generated plan. Transitions: approval → executing */
|
||||
async approve(): Promise<void>;
|
||||
|
||||
/** Reject plan with feedback. Transitions: approval → planning (regenerate) */
|
||||
async reject(feedback: string): Promise<void>;
|
||||
|
||||
/** Pause execution. Saves current state. */
|
||||
pause(): void;
|
||||
|
||||
/** Resume from pause. */
|
||||
resume(): void;
|
||||
|
||||
/** Stop everything and clean up. → idle */
|
||||
async stop(): Promise<void>;
|
||||
|
||||
/** Skip current phase. → executing (next phase) or completed */
|
||||
async skipPhase(phaseId: string): Promise<void>;
|
||||
|
||||
/** Retry a failed phase. → executing */
|
||||
async retryPhase(phaseId: string): Promise<void>;
|
||||
|
||||
// ── Getters ────────────────────────────────────────────────
|
||||
|
||||
getState(): OrchestratorState;
|
||||
getPlan(): OrchestratorPlan | null;
|
||||
getCurrentPhase(): OrchestratorPhase | null;
|
||||
getStats(): OrchestratorStats;
|
||||
getStatus(): OrchestratorPersistState;
|
||||
|
||||
// ── Internal: Phase Execution ──────────────────────────────
|
||||
|
||||
private async executeCurrentPhase(): Promise<void>;
|
||||
private async executePhase(phase: OrchestratorPhase): Promise<void>;
|
||||
private async assignPhaseTasks(phase: OrchestratorPhase): Promise<void>;
|
||||
private handleTaskCompleted(taskId: string): void;
|
||||
private handleTaskFailed(taskId: string, error: string): void;
|
||||
private async onPhaseTasksComplete(phase: OrchestratorPhase): Promise<void>;
|
||||
|
||||
// ── Internal: Verification ─────────────────────────────────
|
||||
|
||||
private async verifyCurrentPhase(): Promise<void>;
|
||||
private async handleVerificationResult(phase: OrchestratorPhase, result: VerificationResult): Promise<void>;
|
||||
|
||||
// ── Internal: Replanning ───────────────────────────────────
|
||||
|
||||
private async replanPhase(phase: OrchestratorPhase, failures: string[]): Promise<void>;
|
||||
|
||||
// ── Internal: State Machine ────────────────────────────────
|
||||
|
||||
private setState(newState: OrchestratorState): void;
|
||||
private advanceToNextPhase(): Promise<void>;
|
||||
private persist(): void;
|
||||
private restore(): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Key execution flow in `executePhase()`:**
|
||||
1. Mark phase as `executing`, emit `phaseStarted`
|
||||
2. For each task in phase:
|
||||
- Create a `CreateTaskOptions` from `OrchestratorTask`
|
||||
- Add to `TaskQueue` with proper dependencies + completion phrase
|
||||
- Store the TaskQueue task ID in `OrchestratorTask.queueTaskId`
|
||||
3. Poll task completion (listen to TaskQueue events)
|
||||
4. When all tasks complete → call `onPhaseTasksComplete()`
|
||||
5. `onPhaseTasksComplete()` triggers verification
|
||||
|
||||
**How tasks get assigned to sessions:**
|
||||
The OrchestratorLoop does NOT manage session assignment directly. It adds tasks to the existing TaskQueue and starts a mini poll loop that assigns pending tasks to idle sessions — the same pattern as RalphLoop's `assignTasks()`. This reuses existing session management.
|
||||
|
||||
**Team agent flow:**
|
||||
For phases with `teamStrategy.type === 'team'`:
|
||||
- Start a single session with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
|
||||
- Instead of adding individual tasks to TaskQueue, send ONE comprehensive prompt to the lead
|
||||
- The prompt instructs the lead to create teammates and delegate
|
||||
- Monitor via TeamWatcher for team task completion + hook events
|
||||
- Phase completion is detected via the lead's completion phrase
|
||||
|
||||
### Step 5: `src/web/routes/orchestrator-routes.ts` — API endpoints
|
||||
~300 lines.
|
||||
|
||||
```
|
||||
POST /api/orchestrator/start — { goal, config? } → start planning
|
||||
POST /api/orchestrator/approve — approve generated plan
|
||||
POST /api/orchestrator/reject — { feedback } → reject + replan
|
||||
POST /api/orchestrator/pause — pause execution
|
||||
POST /api/orchestrator/resume — resume execution
|
||||
POST /api/orchestrator/stop — stop orchestration
|
||||
GET /api/orchestrator/status — full state + plan + stats
|
||||
GET /api/orchestrator/plan — plan details only
|
||||
POST /api/orchestrator/phase/:id/skip — skip a phase
|
||||
POST /api/orchestrator/phase/:id/retry — retry a failed phase
|
||||
```
|
||||
|
||||
Port dependency: `SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort`
|
||||
|
||||
The route module receives the OrchestratorLoop instance via the InfraPort (added to `createRouteContext()`).
|
||||
|
||||
### Step 6: SSE Events — `src/web/sse-events.ts` additions
|
||||
|
||||
```typescript
|
||||
// ─── Orchestrator ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Orchestrator state machine transitioned. */
|
||||
export const OrchestratorStateChanged = 'orchestrator:stateChanged' as const;
|
||||
/** Orchestrator plan generated and ready for approval. */
|
||||
export const OrchestratorPlanReady = 'orchestrator:planReady' as const;
|
||||
/** Orchestrator phase started executing. */
|
||||
export const OrchestratorPhaseStarted = 'orchestrator:phaseStarted' as const;
|
||||
/** Orchestrator phase completed successfully. */
|
||||
export const OrchestratorPhaseCompleted = 'orchestrator:phaseCompleted' as const;
|
||||
/** Orchestrator phase failed. */
|
||||
export const OrchestratorPhaseFailed = 'orchestrator:phaseFailed' as const;
|
||||
/** Orchestrator verification result for a phase. */
|
||||
export const OrchestratorVerification = 'orchestrator:verification' as const;
|
||||
/** Orchestrator task assigned to session. */
|
||||
export const OrchestratorTaskAssigned = 'orchestrator:taskAssigned' as const;
|
||||
/** Orchestrator task completed. */
|
||||
export const OrchestratorTaskCompleted = 'orchestrator:taskCompleted' as const;
|
||||
/** Orchestrator task failed. */
|
||||
export const OrchestratorTaskFailed = 'orchestrator:taskFailed' as const;
|
||||
/** All phases completed successfully. */
|
||||
export const OrchestratorCompleted = 'orchestrator:completed' as const;
|
||||
/** Orchestrator error. */
|
||||
export const OrchestratorError = 'orchestrator:error' as const;
|
||||
```
|
||||
|
||||
11 new events. Add to `SseEvent` namespace object + mirror in `constants.js`.
|
||||
|
||||
### Step 7: State persistence — `src/state-store.ts` additions
|
||||
|
||||
Add to `AppState`:
|
||||
```typescript
|
||||
orchestrator?: OrchestratorPersistState;
|
||||
```
|
||||
|
||||
Add methods:
|
||||
```typescript
|
||||
getOrchestratorState(): OrchestratorPersistState | null;
|
||||
setOrchestratorState(state: Partial<OrchestratorPersistState>): void;
|
||||
clearOrchestratorState(): void;
|
||||
```
|
||||
|
||||
### Step 8: Server integration — `src/web/server.ts` modifications
|
||||
|
||||
1. Import `OrchestratorLoop` and `registerOrchestratorRoutes`
|
||||
2. Add `private orchestratorLoop: OrchestratorLoop` field
|
||||
3. Initialize in constructor (lazy — created on first start, not at boot)
|
||||
4. Add to `createRouteContext()` InfraPort: `orchestratorLoop: this.orchestratorLoop`
|
||||
5. Wire up OrchestratorLoop events → SSE broadcasts
|
||||
6. Register routes: `registerOrchestratorRoutes(this.app, ctx)`
|
||||
7. Clean up in `stop()`
|
||||
|
||||
### Step 9: `src/web/public/orchestrator-ui.js` — Frontend panel
|
||||
~500 lines. New frontend module.
|
||||
|
||||
**Load order**: After `panels-ui.js` (11), before `ralph-wizard.js` (13). So load order = 11.5.
|
||||
|
||||
**UI elements:**
|
||||
- Goal input form (text area + config toggles)
|
||||
- Plan approval view (phase list, task details, approve/reject buttons)
|
||||
- Execution dashboard (progress bar, phase cards, task status indicators)
|
||||
- Agent activity panel (session count, team status)
|
||||
- Controls (pause, resume, stop, skip phase, retry phase)
|
||||
|
||||
**SSE listeners:**
|
||||
- All 11 orchestrator events → update UI state
|
||||
- Reuses existing session/respawn/team event handlers for agent monitoring
|
||||
|
||||
### Step 10: `src/prompts/orchestrator.ts` — Prompt templates
|
||||
~200 lines.
|
||||
|
||||
Templates for:
|
||||
- Phase execution prompt (tells Claude what to do in this phase)
|
||||
- Team lead delegation prompt (instructs lead to create and coordinate teammates)
|
||||
- Verification prompt (asks Claude to verify phase output)
|
||||
- Replan prompt (gives failure context, asks for recovery steps)
|
||||
|
||||
### Step 11: Constants, schemas, route barrel updates
|
||||
|
||||
- `src/web/public/constants.js` — Add 11 SSE event mirrors
|
||||
- `src/web/schemas.ts` — Add Zod schemas for orchestrator API input validation
|
||||
- `src/web/routes/index.ts` — Export `registerOrchestratorRoutes`
|
||||
- `src/web/ports/infra-port.ts` — Add `orchestratorLoop` to InfraPort
|
||||
- `src/types/index.ts` — Export orchestrator types
|
||||
|
||||
## Existing File Modifications Summary
|
||||
|
||||
| File | Change | Lines |
|
||||
|------|--------|-------|
|
||||
| `src/types/index.ts` | Add orchestrator barrel export | +1 |
|
||||
| `src/web/sse-events.ts` | Add 11 orchestrator events + SseEvent entries | +30 |
|
||||
| `src/web/public/constants.js` | Mirror 11 SSE events | +15 |
|
||||
| `src/web/routes/index.ts` | Export registerOrchestratorRoutes | +1 |
|
||||
| `src/web/ports/infra-port.ts` | Add orchestratorLoop to InfraPort | +3 |
|
||||
| `src/web/server.ts` | Initialize OrchestratorLoop, wire events, register routes | +40 |
|
||||
| `src/web/schemas.ts` | Add orchestrator Zod schemas | +20 |
|
||||
| `src/state-store.ts` | Add orchestrator state persistence | +20 |
|
||||
| `src/web/public/app.js` | Add orchestrator SSE listeners + panel toggle | +30 |
|
||||
| `src/web/public/index.html` | Add orchestrator-ui.js script tag | +1 |
|
||||
|
||||
**Total new code**: ~2,300 lines across 6 new files
|
||||
**Total modifications**: ~160 lines across 10 existing files
|
||||
|
||||
## Implementation Execution Order
|
||||
|
||||
This is the actual build order — each step is a commit checkpoint:
|
||||
|
||||
1. **Types** — `src/types/orchestrator.ts` + barrel export. Zero risk, pure types.
|
||||
2. **SSE events** — Add all 11 events to both `sse-events.ts` and `constants.js`. Wire in SseEvent namespace.
|
||||
3. **State persistence** — Add orchestrator state to StateStore. Small, isolated change.
|
||||
4. **Schemas** — Add Zod validation schemas for API input.
|
||||
5. **Planner** — `src/orchestrator-planner.ts`. Can test in isolation.
|
||||
6. **Verifier** — `src/orchestrator-verifier.ts`. Can test in isolation.
|
||||
7. **Core loop** — `src/orchestrator-loop.ts`. The big one. Depends on planner + verifier.
|
||||
8. **Prompts** — `src/prompts/orchestrator.ts`. Templates used by core loop.
|
||||
9. **Port + routes** — `src/web/ports/infra-port.ts` update + `src/web/routes/orchestrator-routes.ts`.
|
||||
10. **Server integration** — Wire OrchestratorLoop into WebServer. Routes become live.
|
||||
11. **Frontend** — `src/web/public/orchestrator-ui.js` + app.js listeners + index.html script tag.
|
||||
12. **Tests** — `test/orchestrator-*.test.ts`.
|
||||
13. **Typecheck + lint** — Fix all issues, ensure CI passes.
|
||||
|
||||
## Edge Cases & Error Handling
|
||||
|
||||
- **Session limit reached**: Queue tasks and wait for sessions to free up (existing SessionManager handles this)
|
||||
- **All sessions crash during phase**: Mark phase as failed, attempt replan
|
||||
- **Verification flaky**: `moderate` mode allows test retries; `lenient` skips AI review
|
||||
- **Plan too large**: Cap at 10 phases, 50 total tasks. Warn user.
|
||||
- **Context overflow**: Auto-compact between phases. Respawn if needed (orchestrator state is external).
|
||||
- **User pauses mid-phase**: Pause task assignment, don't cancel running tasks. Resume picks up where it left off.
|
||||
- **Network/API errors during planning**: Retry plan generation up to 2 times, then fail with clear message.
|
||||
- **Orchestrator vs Ralph conflict**: Mutually exclusive. Starting orchestrator stops Ralph if running. Starting Ralph stops orchestrator.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests**: `test/orchestrator-planner.test.ts` — phase grouping algorithm, team strategy assignment
|
||||
- **Unit tests**: `test/orchestrator-verifier.test.ts` — verification logic with mocked sessions
|
||||
- **Integration tests**: `test/orchestrator-loop.test.ts` — state machine transitions, task lifecycle
|
||||
- **Route tests**: `test/routes/orchestrator-routes.test.ts` — API validation, status responses
|
||||
|
||||
All tests use `MockSession` pattern from existing test infrastructure. No real tmux needed.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Orchestrator Loop — Research Findings
|
||||
|
||||
> Research doc for the new "Orchestrator Loop" feature. Not for GitHub.
|
||||
|
||||
## What We're Building
|
||||
|
||||
A new autonomous loop variant — **Orchestrator Loop** — that takes high-level user tasks, decomposes them into a detailed plan using team agents, and executes the plan step-by-step with quality gates. Unlike Ralph Loop (which executes a flat task queue), the Orchestrator coordinates **planning, delegation, and verification** as a continuous cycle.
|
||||
|
||||
**Core idea**: User inputs a goal → Orchestrator creates a detailed plan → spins up team agents for parallel execution → validates each step → adapts the plan based on results → delivers polished output.
|
||||
|
||||
## Existing Infrastructure Analysis
|
||||
|
||||
### What We Can Reuse
|
||||
|
||||
#### 1. Ralph Loop (`src/ralph-loop.ts`)
|
||||
- **Pattern**: Poll loop with `start() → tick() → stop()` lifecycle
|
||||
- **Reusable**: Event-driven task assignment, session completion handling, timeout management
|
||||
- **Limitation**: Flat task queue — no concept of phases, dependencies between task groups, or adaptive replanning
|
||||
- **Key insight**: `assignTaskToSession()` uses `session.sendInput(task.prompt)` — simple prompt injection into PTY
|
||||
|
||||
#### 2. Task Queue (`src/task-queue.ts`) + Task (`src/task.ts`)
|
||||
- **Already has**: Priority ordering, dependency tracking between tasks, completion phrase detection
|
||||
- **Limitation**: No task *groups* or *phases*. Dependencies are task-to-task, not phase-to-phase
|
||||
- **Key insight**: Tasks support `completionPhrase` — a string the task watches for in output. This is how Ralph knows a task is done
|
||||
|
||||
#### 3. Plan Orchestrator (`src/plan-orchestrator.ts`)
|
||||
- **Already has**: 2-agent plan generation (Research Agent → Planner Agent), TDD-aware plan items with P0/P1/P2 priorities
|
||||
- **Output**: `PlanItem[]` with dependencies, verification criteria, TDD phases, complexity ratings
|
||||
- **Limitation**: Plan generation only — no execution. Plans are generated then sit in state/UI for human review
|
||||
- **Key insight**: Uses `Session` directly to run Claude subagent instances for research and planning. Returns structured JSON
|
||||
|
||||
#### 4. Team Agents (`src/team-watcher.ts`, `~/.claude/teams/`)
|
||||
- **Already has**: Team creation, member tracking, filesystem inbox messaging, task management via `~/.claude/tasks/{team-name}/`
|
||||
- **Limitation**: Codeman can only *observe* teams (TeamWatcher is read-only polling), not *create* or *orchestrate* them
|
||||
- **Key insight**: Teams are a Claude Code feature. Codeman monitors them but doesn't control them. We can't programmatically create teammates — Claude Code does that when you use `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
|
||||
|
||||
#### 5. Respawn Controller (`src/respawn-controller.ts`)
|
||||
- **Already has**: Preset-based automation (ralph-todo, overnight-autonomous), circuit breaker, health scoring
|
||||
- **Key insight**: The `ralph-todo` preset (8s idle, 480min max) is designed for autonomous task execution. We'd need a new preset or make Orchestrator Loop set its own timing
|
||||
|
||||
#### 6. Session Auto-Ops (`src/session-auto-ops.ts`)
|
||||
- **Already has**: Auto-compact at token thresholds, auto-clear for context management
|
||||
- **Key insight**: Critical for long Orchestrator runs — prevents context overflow during multi-step execution
|
||||
|
||||
#### 7. Hooks (`src/hooks-config.ts`)
|
||||
- **Already has**: `idle_prompt`, `stop`, `teammate_idle`, `task_completed` hook events
|
||||
- **Key insight**: Hooks fire POST to `/api/hook-event` — this is how Codeman knows when Claude is idle, stopped, or completed a task. The Orchestrator Loop can listen to these same events
|
||||
|
||||
### What We Need to Build New
|
||||
|
||||
1. **Plan → Task decomposition**: Convert PlanOrchestrator output (PlanItem[]) into executable task groups with phase ordering
|
||||
2. **Multi-phase execution engine**: Execute plan phases sequentially, tasks within phases in parallel
|
||||
3. **Verification gates**: After each phase, run verification (test commands, AI review) before proceeding
|
||||
4. **Adaptive replanning**: When a task fails or verification fails, generate a recovery plan
|
||||
5. **Team agent orchestration**: Leverage Claude Code's agent teams for parallel execution within phases
|
||||
6. **Progress tracking & UI**: Real-time dashboard showing plan progress, phase status, agent activity
|
||||
|
||||
## How Teams Actually Work (Important Constraint)
|
||||
|
||||
After deep research, here's the reality of agent teams:
|
||||
|
||||
```
|
||||
User starts session with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
|
||||
→ Claude Code creates a team-lead
|
||||
→ Team-lead spawns teammates (in-process threads)
|
||||
→ Teammates appear as subagents (detected by SubagentWatcher)
|
||||
→ Communication via ~/.claude/teams/{name}/inboxes/{member}.json
|
||||
→ Tasks tracked in ~/.claude/tasks/{team-name}/{N}.json
|
||||
```
|
||||
|
||||
**Codeman cannot programmatically create team members.** This is a Claude Code internal feature. However, Codeman CAN:
|
||||
- Start a session that has teams enabled
|
||||
- Send a prompt to the lead that instructs it to use agent teams
|
||||
- Monitor team activity via TeamWatcher
|
||||
- React to teammate_idle and task_completed hook events
|
||||
- Read team task status from the filesystem
|
||||
|
||||
**This means**: The Orchestrator Loop orchestrates at the *session prompt* level, not the *team member* level. We tell the lead what to do, and the lead decides how to use its team.
|
||||
|
||||
## Architecture Decision: Prompt-Level Orchestration
|
||||
|
||||
Given the team constraint, the Orchestrator Loop works by:
|
||||
|
||||
1. **Planning phase**: Use PlanOrchestrator to generate a detailed plan from user input
|
||||
2. **Execution phase**: Feed plan steps as prompts to sessions, one phase at a time
|
||||
3. **Verification phase**: After each phase, run verification prompts and check results
|
||||
4. **Adaptation phase**: If verification fails, generate recovery prompts
|
||||
|
||||
The "team agents" aspect works by:
|
||||
- Starting sessions with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
|
||||
- Crafting prompts that *instruct the lead to delegate* to teammates
|
||||
- Monitoring team activity to track parallel progress
|
||||
- The lead agent is smart enough to decompose work across its team
|
||||
|
||||
## Key Technical Findings
|
||||
|
||||
### Session Input Mechanics
|
||||
```typescript
|
||||
// From session.ts - how we send prompts
|
||||
await session.sendInput(task.prompt); // Uses writeViaMux() internally
|
||||
// writeViaMux() does: tmux send-keys -l "prompt text" + tmux send-keys Enter
|
||||
// CRITICAL: Single-line only! Multi-line breaks Ink rendering
|
||||
```
|
||||
|
||||
### Completion Detection Chain
|
||||
```
|
||||
PTY output → RalphTracker.processData() → completion phrase fuzzy match
|
||||
→ CompletionConfidence scoring (multi-signal: promise tag + todos + exit signal)
|
||||
→ If confident → emit 'completionDetected'
|
||||
→ RalphLoop listens → marks task complete → assigns next
|
||||
```
|
||||
|
||||
### How Plan Items Map to Tasks
|
||||
```typescript
|
||||
// PlanItem has:
|
||||
interface PlanItem {
|
||||
id: string; // "P0-001"
|
||||
content: string; // "Implement error handling for API endpoints"
|
||||
priority: 'P0' | 'P1' | 'P2';
|
||||
dependencies: string[]; // ["P0-000"] — other PlanItem IDs
|
||||
verificationCriteria: string;
|
||||
testCommand: string;
|
||||
tddPhase: 'setup' | 'test' | 'impl' | 'verify' | 'review';
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
// Task has:
|
||||
interface CreateTaskOptions {
|
||||
prompt: string;
|
||||
priority: number;
|
||||
dependencies: string[]; // Task IDs
|
||||
completionPhrase: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
// Natural mapping: PlanItem.content → Task.prompt
|
||||
// PlanItem.dependencies → Task.dependencies
|
||||
// PlanItem.priority → Task.priority (P0=100, P1=50, P2=10)
|
||||
// PlanItem.verificationCriteria → verification task prompt
|
||||
```
|
||||
|
||||
### Context Management for Long Runs
|
||||
- Auto-compact at ~110k tokens (configurable)
|
||||
- Auto-clear at ~140k tokens (configurable)
|
||||
- Respawn cycling: kill + restart session to reset context entirely
|
||||
- For Orchestrator: we want compact between phases, respawn between major milestones
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Context overflow during complex phases | High | Auto-compact between tasks, respawn between phases |
|
||||
| Team agents not predictable | Medium | Orchestrate at session level, let Claude decide team delegation |
|
||||
| Plan too ambitious → infinite loop | High | Phase budgets (max attempts per phase), circuit breaker |
|
||||
| Verification too strict → blocks progress | Medium | Configurable strictness, human override via UI |
|
||||
| Single-line prompt limit | Medium | Use CLAUDE.md file for complex instructions, prompt references file |
|
||||
| Long planning phase delays execution | Low | Show plan for approval before execution |
|
||||
@@ -7,7 +7,7 @@
|
||||
# Environment variables:
|
||||
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
|
||||
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
|
||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd service setup prompt
|
||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
|
||||
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
|
||||
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
|
||||
# CODEMAN_BRANCH - Git branch to install (default: master)
|
||||
@@ -353,8 +353,15 @@ ensure_sudo() {
|
||||
die "sudo is required but not installed. Please install packages manually or run as root."
|
||||
fi
|
||||
# Validate sudo access
|
||||
if ! sudo -v 2>/dev/null; then
|
||||
die "Failed to obtain sudo privileges."
|
||||
# When piped (curl | bash), stdin is the pipe — redirect from /dev/tty so sudo can prompt
|
||||
if [[ -e /dev/tty ]]; then
|
||||
if ! sudo -v 2>/dev/null < /dev/tty; then
|
||||
die "Failed to obtain sudo privileges."
|
||||
fi
|
||||
else
|
||||
if ! sudo -v 2>/dev/null; then
|
||||
die "Failed to obtain sudo privileges. Try running the script directly instead of piping."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -372,7 +379,12 @@ ensure_homebrew() {
|
||||
fi
|
||||
|
||||
info "Installing Homebrew first..."
|
||||
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
# When piped (curl | bash), stdin is the pipe — Homebrew needs TTY for sudo password prompt
|
||||
if [[ -e /dev/tty ]]; then
|
||||
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/tty
|
||||
else
|
||||
NONINTERACTIVE=1 /bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
fi
|
||||
|
||||
# Add Homebrew to PATH for Apple Silicon
|
||||
if [[ -f /opt/homebrew/bin/brew ]]; then
|
||||
@@ -787,9 +799,84 @@ setup_sc_alias() {
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Systemd Service Setup (Linux only)
|
||||
# Service Setup (Linux systemd / macOS launchd)
|
||||
# ============================================================================
|
||||
|
||||
setup_launchd_service() {
|
||||
local plist_label="com.codeman.web"
|
||||
local agent_dir="$HOME/Library/LaunchAgents"
|
||||
local agent_plist="$agent_dir/$plist_label.plist"
|
||||
local daemon_plist="/Library/LaunchDaemons/$plist_label.plist"
|
||||
|
||||
info "Setting up macOS LaunchAgent..."
|
||||
|
||||
# Remove any existing LaunchDaemon (system-level) to prevent duplicates.
|
||||
# We standardize on LaunchAgent (user-level) — it doesn't require sudo,
|
||||
# inherits the user's environment, and is the correct choice for user apps.
|
||||
if [[ -f "$daemon_plist" ]]; then
|
||||
warn "Found system-level LaunchDaemon at $daemon_plist — removing to prevent duplicate"
|
||||
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||
sudo rm -f "$daemon_plist"
|
||||
success "Removed duplicate LaunchDaemon"
|
||||
fi
|
||||
|
||||
# Unload existing agent before overwriting
|
||||
if [[ -f "$agent_plist" ]]; then
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
mkdir -p "$agent_dir"
|
||||
|
||||
# Build PATH: ensure /opt/homebrew/bin (Apple Silicon) and ~/.local/bin are included
|
||||
local svc_path="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
|
||||
# Find node binary path
|
||||
local node_path
|
||||
node_path=$(command -v node)
|
||||
|
||||
cat > "$agent_plist" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$plist_label</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$node_path</string>
|
||||
<string>$INSTALL_DIR/dist/index.js</string>
|
||||
<string>web</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>$svc_path</string>
|
||||
<key>HOME</key>
|
||||
<string>$HOME</string>
|
||||
<key>LANG</key>
|
||||
<string>en_US.UTF-8</string>
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$HOME</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
|
||||
success "LaunchAgent installed and started"
|
||||
}
|
||||
|
||||
setup_systemd_service() {
|
||||
local service_dir="$HOME/.config/systemd/user"
|
||||
local service_file="$service_dir/codeman-web.service"
|
||||
@@ -1139,17 +1226,25 @@ main() {
|
||||
echo ""
|
||||
|
||||
local launch_choice=""
|
||||
local has_systemd=false
|
||||
local has_service=false
|
||||
local service_type=""
|
||||
|
||||
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
|
||||
has_systemd=true
|
||||
has_service=true
|
||||
service_type="systemd"
|
||||
elif [[ "$os" == "macos" ]] && [[ "$SKIP_SYSTEMD" != "1" ]]; then
|
||||
has_service=true
|
||||
service_type="launchd"
|
||||
fi
|
||||
|
||||
if [[ "$has_systemd" == "true" ]]; then
|
||||
if [[ "$has_service" == "true" ]]; then
|
||||
local service_label="systemd service"
|
||||
[[ "$service_type" == "launchd" ]] && service_label="LaunchAgent"
|
||||
|
||||
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||
echo -e " ${CYAN}2)${NC} Install as systemd service (auto-start on boot)"
|
||||
echo -e " ${CYAN}2)${NC} Install as $service_label (auto-start on boot)"
|
||||
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
|
||||
echo ""
|
||||
|
||||
@@ -1166,7 +1261,7 @@ main() {
|
||||
done
|
||||
fi
|
||||
else
|
||||
# macOS or no systemd — only offer run now or skip
|
||||
# No service manager available — only offer run now or skip
|
||||
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||
@@ -1192,12 +1287,16 @@ main() {
|
||||
|
||||
echo ""
|
||||
|
||||
# Handle systemd setup
|
||||
# Handle service setup
|
||||
if [[ "$launch_choice" == "2" ]]; then
|
||||
setup_systemd_service
|
||||
if [[ "$service_type" == "launchd" ]]; then
|
||||
setup_launchd_service
|
||||
else
|
||||
setup_systemd_service
|
||||
fi
|
||||
|
||||
# Offer tunnel service if cloudflared is available
|
||||
if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||
# Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
|
||||
if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||
echo ""
|
||||
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
|
||||
setup_tunnel_service
|
||||
@@ -1212,10 +1311,16 @@ main() {
|
||||
echo ""
|
||||
echo -e " ${BOLD}Manage the service:${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
||||
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
||||
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
||||
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
||||
if [[ "$service_type" == "launchd" ]]; then
|
||||
echo -e " ${CYAN}launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Stop"
|
||||
echo -e " ${CYAN}launchctl load ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Start"
|
||||
echo -e " ${CYAN}tail -f /tmp/codeman.log${NC} # View logs"
|
||||
else
|
||||
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
||||
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
||||
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
||||
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
@@ -1289,11 +1394,17 @@ update() {
|
||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||
echo ""
|
||||
|
||||
# Auto-restart systemd service if it's running, otherwise tell the user
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null; then
|
||||
# Auto-restart service if running, otherwise tell the user
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service
|
||||
success "codeman-web service restarted"
|
||||
elif [[ -f "$agent_plist" ]]; then
|
||||
info "Restarting LaunchAgent..."
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
success "LaunchAgent restarted"
|
||||
else
|
||||
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
||||
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
||||
@@ -1306,9 +1417,9 @@ uninstall() {
|
||||
info "Uninstalling Codeman..."
|
||||
echo ""
|
||||
|
||||
# Stop and remove systemd services
|
||||
# Stop and remove systemd services (Linux)
|
||||
for svc in codeman-web codeman-tunnel; do
|
||||
if systemctl --user is-active "${svc}.service" &>/dev/null; then
|
||||
if systemctl --user is-active "${svc}.service" &>/dev/null 2>&1; then
|
||||
info "Stopping ${svc} service..."
|
||||
systemctl --user stop "${svc}.service"
|
||||
fi
|
||||
@@ -1324,6 +1435,20 @@ uninstall() {
|
||||
done
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
|
||||
# Stop and remove launchd services (macOS)
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
local daemon_plist="/Library/LaunchDaemons/com.codeman.web.plist"
|
||||
if [[ -f "$agent_plist" ]]; then
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
rm -f "$agent_plist"
|
||||
success "Removed LaunchAgent"
|
||||
fi
|
||||
if [[ -f "$daemon_plist" ]]; then
|
||||
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||
sudo rm -f "$daemon_plist"
|
||||
success "Removed LaunchDaemon"
|
||||
fi
|
||||
|
||||
# Remove symlinks
|
||||
local symlink_dir="$HOME/.local/bin"
|
||||
if [[ -L "$symlink_dir/codeman" ]]; then
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.4.3",
|
||||
"version": "0.5.12",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -15,12 +15,12 @@
|
||||
"dev": "tsx src/index.ts web",
|
||||
"web": "node dist/index.js web",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test": "vitest run --config config/vitest.config.ts",
|
||||
"test:watch": "vitest --config config/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
||||
|
||||
@@ -428,7 +428,7 @@ const SUBAGENT_ACTIVITY = {
|
||||
'agent-002': [
|
||||
{ type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/config/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Usage: node scripts/capture-video-screenshots.mjs
|
||||
* Port: 3198 (static file server)
|
||||
* Output: remotion/public/ (6 PNGs)
|
||||
* Output: scripts/scripts/remotion/public/ (6 PNGs)
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'remotion', 'public');
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public');
|
||||
const PORT = 3198;
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
|
||||
@@ -516,7 +516,7 @@ async function captureDesktopWelcome(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-welcome.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -545,7 +545,7 @@ async function captureDesktopClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -575,7 +575,7 @@ async function captureDesktopBothClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-both-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -605,7 +605,7 @@ async function captureDesktopBothOpencode(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-both-opencode.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -634,7 +634,7 @@ async function captureMobileClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'mobile-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/mobile-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/mobile-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -663,7 +663,7 @@ async function captureMobileOpencode(browser) {
|
||||
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/mobile-opencode.png');
|
||||
console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -706,12 +706,12 @@ async function main() {
|
||||
console.log('All 6 screenshots captured!');
|
||||
console.log('='.repeat(60));
|
||||
console.log('\nOutput files:');
|
||||
console.log(' remotion/public/desktop-welcome.png');
|
||||
console.log(' remotion/public/desktop-claude.png');
|
||||
console.log(' remotion/public/desktop-both-claude.png');
|
||||
console.log(' remotion/public/desktop-both-opencode.png');
|
||||
console.log(' remotion/public/mobile-claude.png');
|
||||
console.log(' remotion/public/mobile-opencode.png');
|
||||
console.log(' scripts/remotion/public/desktop-welcome.png');
|
||||
console.log(' scripts/remotion/public/desktop-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-opencode.png');
|
||||
console.log(' scripts/remotion/public/mobile-claude.png');
|
||||
console.log(' scripts/remotion/public/mobile-opencode.png');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Codeman Cloudflare Named Tunnel
|
||||
After=network-online.target codeman-web.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/cloudflared tunnel --config %h/.cloudflared/codeman.yml run codeman
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillMode=process
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=codeman-tunnel-named
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -1,45 +1,209 @@
|
||||
#!/usr/bin/env bash
|
||||
# Quick Cloudflare Tunnel for Codeman
|
||||
# Usage: ./scripts/tunnel.sh [start|stop|status|url]
|
||||
# Cloudflare Tunnel manager for Codeman
|
||||
# Usage: ./scripts/tunnel.sh [quick|named] [start|stop|status|url]
|
||||
#
|
||||
# Modes:
|
||||
# quick — Quick tunnel with random trycloudflare.com URL (default)
|
||||
# named — Named tunnel on a fixed hostname (requires setup, see below)
|
||||
#
|
||||
# Environment variables:
|
||||
# CLOUDFLARED_TUNNEL_NAME — tunnel name (default: codeman)
|
||||
# CLOUDFLARED_TUNNEL_ID — tunnel UUID (from: cloudflared tunnel list)
|
||||
# CODEMAN_TUNNEL_HOSTNAME — public hostname (e.g. codeman.example.com)
|
||||
#
|
||||
# First-time named tunnel setup:
|
||||
# cloudflared tunnel login
|
||||
# cloudflared tunnel create <tunnel-name>
|
||||
# cloudflared tunnel route dns <tunnel-name> <hostname>
|
||||
# ./scripts/tunnel.sh named setup # writes ~/.cloudflared/<tunnel-name>.yml
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="codeman-tunnel"
|
||||
QUICK_SERVICE="codeman-tunnel"
|
||||
NAMED_SERVICE="codeman-tunnel-named"
|
||||
TUNNEL_NAME="${CLOUDFLARED_TUNNEL_NAME:-codeman}"
|
||||
TUNNEL_HOSTNAME="${CODEMAN_TUNNEL_HOSTNAME:-codeman.example.com}"
|
||||
CODEMAN_PORT="3000"
|
||||
LOG_FILE="$HOME/.codeman/tunnel.log"
|
||||
|
||||
case "${1:-start}" in
|
||||
start)
|
||||
if ! systemctl --user is-active "$SERVICE" &>/dev/null; then
|
||||
# Install service if not already
|
||||
if ! systemctl --user cat "$SERVICE" &>/dev/null 2>&1; then
|
||||
cp "$(dirname "$0")/codeman-tunnel.service" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
fi
|
||||
systemctl --user start "$SERVICE"
|
||||
echo "Tunnel starting... waiting for URL"
|
||||
sleep 6
|
||||
fi
|
||||
# Extract the tunnel URL from journal
|
||||
URL=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1)
|
||||
if [ -n "$URL" ]; then
|
||||
echo "$URL"
|
||||
else
|
||||
echo "URL not ready yet, try: $0 url"
|
||||
fi
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_require_cloudflared() {
|
||||
if ! command -v cloudflared &>/dev/null; then
|
||||
echo "Error: cloudflared not found. Install with: yay -S cloudflared" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_cloudflared_bin() {
|
||||
command -v cloudflared
|
||||
}
|
||||
|
||||
_install_service() {
|
||||
local svc_file="$1"
|
||||
local svc_name="$2"
|
||||
if ! systemctl --user cat "$svc_name" &>/dev/null 2>&1; then
|
||||
cp "$(dirname "$0")/$svc_file" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
echo "Service $svc_name installed."
|
||||
fi
|
||||
}
|
||||
|
||||
_install_named_service() {
|
||||
if ! systemctl --user cat "$NAMED_SERVICE" &>/dev/null 2>&1; then
|
||||
# Generate service file with the configured tunnel name
|
||||
sed "s/codeman\.yml/$TUNNEL_NAME.yml/g; s/run codeman/run $TUNNEL_NAME/g" \
|
||||
"$(dirname "$0")/codeman-tunnel-named.service" \
|
||||
> "$HOME/.config/systemd/user/codeman-tunnel-named.service"
|
||||
systemctl --user daemon-reload
|
||||
echo "Service $NAMED_SERVICE installed (tunnel: $TUNNEL_NAME)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── named tunnel setup ───────────────────────────────────────────────────────
|
||||
|
||||
_named_setup() {
|
||||
_require_cloudflared
|
||||
|
||||
local creds_dir="$HOME/.cloudflared"
|
||||
local config_file="$creds_dir/$TUNNEL_NAME.yml"
|
||||
# Replace with your tunnel ID (from: cloudflared tunnel list)
|
||||
local tunnel_id="${CLOUDFLARED_TUNNEL_ID:-YOUR_TUNNEL_ID_HERE}"
|
||||
local creds_file="$creds_dir/$tunnel_id.json"
|
||||
|
||||
if [ ! -f "$creds_file" ]; then
|
||||
echo "Credentials not found: $creds_file"
|
||||
echo "Run: cloudflared tunnel create $TUNNEL_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$config_file" <<EOF
|
||||
tunnel: $tunnel_id
|
||||
credentials-file: $creds_file
|
||||
|
||||
ingress:
|
||||
- hostname: $TUNNEL_HOSTNAME
|
||||
service: http://localhost:$CODEMAN_PORT
|
||||
- service: http_status:404
|
||||
EOF
|
||||
|
||||
echo "Config written to $config_file"
|
||||
echo "Tunnel ID: $tunnel_id"
|
||||
echo "Hostname: $TUNNEL_HOSTNAME"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Add Cloudflare Access policy for $TUNNEL_HOSTNAME (Zero Trust dashboard)"
|
||||
echo " 2. ./scripts/tunnel.sh named start"
|
||||
}
|
||||
|
||||
# ── quick mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
_quick_start() {
|
||||
if ! systemctl --user is-active "$QUICK_SERVICE" &>/dev/null; then
|
||||
_install_service "codeman-tunnel.service" "$QUICK_SERVICE"
|
||||
systemctl --user start "$QUICK_SERVICE"
|
||||
echo "Quick tunnel starting... waiting for URL"
|
||||
sleep 6
|
||||
fi
|
||||
local url
|
||||
url=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1)
|
||||
if [ -n "$url" ]; then
|
||||
echo "$url"
|
||||
else
|
||||
echo "URL not ready yet, try: $0 quick url"
|
||||
fi
|
||||
}
|
||||
|
||||
_quick_stop() {
|
||||
systemctl --user stop "$QUICK_SERVICE"
|
||||
echo "Quick tunnel stopped"
|
||||
}
|
||||
|
||||
_quick_status() {
|
||||
systemctl --user status "$QUICK_SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL:"
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
_quick_url() {
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
# ── named mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
_named_start() {
|
||||
_require_cloudflared
|
||||
if [ ! -f "$HOME/.cloudflared/$TUNNEL_NAME.yml" ]; then
|
||||
echo "Config not found. Run: $0 named setup"
|
||||
exit 1
|
||||
fi
|
||||
if ! systemctl --user is-active "$NAMED_SERVICE" &>/dev/null; then
|
||||
_install_named_service
|
||||
systemctl --user start "$NAMED_SERVICE"
|
||||
echo "Named tunnel starting..."
|
||||
sleep 3
|
||||
fi
|
||||
echo "https://$TUNNEL_HOSTNAME"
|
||||
}
|
||||
|
||||
_named_stop() {
|
||||
systemctl --user stop "$NAMED_SERVICE"
|
||||
echo "Named tunnel stopped"
|
||||
}
|
||||
|
||||
_named_status() {
|
||||
systemctl --user status "$NAMED_SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL: https://$TUNNEL_HOSTNAME"
|
||||
}
|
||||
|
||||
_named_enable() {
|
||||
_install_named_service
|
||||
systemctl --user enable "$NAMED_SERVICE"
|
||||
echo "Named tunnel enabled at boot."
|
||||
}
|
||||
|
||||
_named_disable() {
|
||||
systemctl --user disable "$NAMED_SERVICE"
|
||||
echo "Named tunnel disabled."
|
||||
}
|
||||
|
||||
# ── dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
MODE="${1:-quick}"
|
||||
CMD="${2:-start}"
|
||||
|
||||
case "$MODE" in
|
||||
quick)
|
||||
case "$CMD" in
|
||||
start) _quick_start ;;
|
||||
stop) _quick_stop ;;
|
||||
status) _quick_status ;;
|
||||
url) _quick_url ;;
|
||||
*) echo "Usage: $0 quick [start|stop|status|url]"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
stop)
|
||||
systemctl --user stop "$SERVICE"
|
||||
echo "Tunnel stopped"
|
||||
;;
|
||||
status)
|
||||
systemctl --user status "$SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL:"
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
||||
;;
|
||||
url)
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
||||
named)
|
||||
case "$CMD" in
|
||||
start) _named_start ;;
|
||||
stop) _named_stop ;;
|
||||
status) _named_status ;;
|
||||
url) echo "https://$TUNNEL_HOSTNAME" ;;
|
||||
setup) _named_setup ;;
|
||||
enable) _named_enable ;;
|
||||
disable) _named_disable ;;
|
||||
*) echo "Usage: $0 named [start|stop|status|url|setup|enable|disable]"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
# backward compat: no mode prefix → quick tunnel
|
||||
start) _quick_start ;;
|
||||
stop) _quick_stop ;;
|
||||
status) _quick_status ;;
|
||||
url) _quick_url ;;
|
||||
*)
|
||||
echo "Usage: $0 [start|stop|status|url]"
|
||||
echo "Usage: $0 [quick|named] [start|stop|status|url]"
|
||||
echo " $0 named setup # first-time named tunnel configuration"
|
||||
echo " $0 named enable # start at boot"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -30,6 +30,7 @@ import { join } from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getAugmentedPath, ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
|
||||
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
|
||||
// ========== Security Validation ==========
|
||||
|
||||
@@ -293,7 +294,7 @@ export abstract class AiCheckerBase<
|
||||
this.emit('checkCompleted', result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = getErrorMessage(err);
|
||||
this.handleError(errorMsg);
|
||||
const result = this.createErrorResult(errorMsg, Date.now() - this.checkStartTime);
|
||||
this.emit('checkFailed', errorMsg);
|
||||
@@ -412,9 +413,7 @@ export abstract class AiCheckerBase<
|
||||
});
|
||||
muxProcess.unref();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Poll the temp file for completion
|
||||
|
||||
@@ -470,113 +470,91 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
* Process a single pre-stripped line of terminal output.
|
||||
*/
|
||||
private processCleanLine(cleanLine: string): void {
|
||||
// Check for tool start
|
||||
if (this._handleToolStart(cleanLine)) return;
|
||||
if (this._handleToolCompletion(cleanLine)) return;
|
||||
if (this._handleTextCommand(cleanLine)) return;
|
||||
this._handleLogFileMention(cleanLine);
|
||||
}
|
||||
|
||||
private _handleToolStart(cleanLine: string): boolean {
|
||||
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
||||
if (startMatch) {
|
||||
const command = startMatch[1];
|
||||
const timeout = startMatch[2]?.trim();
|
||||
if (!startMatch) return false;
|
||||
|
||||
// Check if this is a file-viewing command
|
||||
if (this.isFileViewerCommand(command)) {
|
||||
const filePaths = this.extractFilePaths(command);
|
||||
const command = startMatch[1];
|
||||
const timeout = startMatch[2]?.trim();
|
||||
|
||||
// Skip if any file path is already tracked (cross-pattern dedup)
|
||||
if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
|
||||
return;
|
||||
}
|
||||
if (!this.isFileViewerCommand(command)) return true;
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
const tool: ActiveBashTool = {
|
||||
id: uuidv4(),
|
||||
command,
|
||||
filePaths,
|
||||
timeout,
|
||||
startedAt: Date.now(),
|
||||
status: 'running',
|
||||
sessionId: this._sessionId,
|
||||
};
|
||||
const filePaths = this.extractFilePaths(command);
|
||||
|
||||
// Enforce max tools limit
|
||||
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
||||
// Remove oldest tool
|
||||
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
|
||||
if (oldest) {
|
||||
this._activeTools.delete(oldest[0]);
|
||||
}
|
||||
// Skip if any file path is already tracked (cross-pattern dedup)
|
||||
if (filePaths.some((fp) => this.isFilePathTracked(fp))) return true;
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
const tool = this._createActiveTool(command, filePaths, 'running', timeout);
|
||||
|
||||
// Enforce max tools limit
|
||||
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
||||
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
|
||||
let oldestKey: string | undefined;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, entry] of this._activeTools) {
|
||||
if (entry.startedAt < oldestTime) {
|
||||
oldestTime = entry.startedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
|
||||
this._activeTools.set(tool.id, tool);
|
||||
this._lastToolId = tool.id;
|
||||
|
||||
this.emit('toolStart', tool);
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for tool completion
|
||||
if (TOOL_COMPLETION_PATTERN.test(cleanLine) && this._lastToolId) {
|
||||
const tool = this._activeTools.get(this._lastToolId);
|
||||
if (tool && tool.status === 'running') {
|
||||
tool.status = 'completed';
|
||||
this.emit('toolEnd', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Remove completed tool after a short delay to allow UI to show completion
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
2000,
|
||||
{ description: 'auto-remove completed tool' }
|
||||
);
|
||||
}
|
||||
this._lastToolId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Check for command suggestions in plain text (e.g., "tail -f /tmp/file.log")
|
||||
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
|
||||
if (textCmdMatch) {
|
||||
const filePath = textCmdMatch[2];
|
||||
|
||||
// Create a suggestion tool (marked as 'suggestion' status)
|
||||
const tool: ActiveBashTool = {
|
||||
id: uuidv4(),
|
||||
command: cleanLine.trim(),
|
||||
filePaths: [filePath],
|
||||
timeout: undefined,
|
||||
startedAt: Date.now(),
|
||||
status: 'running', // Shows as clickable
|
||||
sessionId: this._sessionId,
|
||||
};
|
||||
|
||||
// Don't add if file path already tracked (cross-pattern dedup)
|
||||
if (this.isFilePathTracked(filePath)) {
|
||||
return;
|
||||
if (oldestKey) {
|
||||
this._activeTools.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
this._activeTools.set(tool.id, tool);
|
||||
this._lastToolId = tool.id;
|
||||
|
||||
this.emit('toolStart', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Auto-remove suggestions after 30 seconds
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
30000,
|
||||
{ description: 'auto-remove suggestion tool' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Last fallback: Check for log file paths mentioned anywhere in the line
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleToolCompletion(cleanLine: string): boolean {
|
||||
if (!TOOL_COMPLETION_PATTERN.test(cleanLine) || !this._lastToolId) return false;
|
||||
|
||||
const tool = this._activeTools.get(this._lastToolId);
|
||||
if (tool && tool.status === 'running') {
|
||||
tool.status = 'completed';
|
||||
this.emit('toolEnd', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
this._scheduleAutoRemove(tool.id, 2000, 'auto-remove completed tool');
|
||||
}
|
||||
this._lastToolId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleTextCommand(cleanLine: string): boolean {
|
||||
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
|
||||
if (!textCmdMatch) return false;
|
||||
|
||||
const filePath = textCmdMatch[2];
|
||||
|
||||
// Don't add if file path already tracked (cross-pattern dedup)
|
||||
if (this.isFilePathTracked(filePath)) return true;
|
||||
|
||||
const tool = this._createActiveTool(cleanLine.trim(), [filePath], 'running');
|
||||
|
||||
this._activeTools.set(tool.id, tool);
|
||||
this.emit('toolStart', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Auto-remove suggestions after 30 seconds
|
||||
this._scheduleAutoRemove(tool.id, 30000, 'auto-remove suggestion tool');
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleLogFileMention(cleanLine: string): void {
|
||||
LOG_FILE_MENTION_PATTERN.lastIndex = 0;
|
||||
let logMatch;
|
||||
while ((logMatch = LOG_FILE_MENTION_PATTERN.exec(cleanLine)) !== null) {
|
||||
@@ -588,33 +566,46 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
// Skip if file path already tracked (cross-pattern dedup)
|
||||
if (this.isFilePathTracked(filePath)) continue;
|
||||
|
||||
const tool: ActiveBashTool = {
|
||||
id: uuidv4(),
|
||||
command: `View: ${filePath}`,
|
||||
filePaths: [filePath],
|
||||
timeout: undefined,
|
||||
startedAt: Date.now(),
|
||||
status: 'running',
|
||||
sessionId: this._sessionId,
|
||||
};
|
||||
const tool = this._createActiveTool(`View: ${filePath}`, [filePath], 'running');
|
||||
|
||||
this._activeTools.set(tool.id, tool);
|
||||
this.emit('toolStart', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Auto-remove after 60 seconds
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
60000,
|
||||
{ description: 'auto-remove log file tool' }
|
||||
);
|
||||
this._scheduleAutoRemove(tool.id, 60000, 'auto-remove log file tool');
|
||||
}
|
||||
}
|
||||
|
||||
private _createActiveTool(
|
||||
command: string,
|
||||
filePaths: string[],
|
||||
status: ActiveBashTool['status'],
|
||||
timeout?: string
|
||||
): ActiveBashTool {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
command,
|
||||
filePaths,
|
||||
timeout,
|
||||
startedAt: Date.now(),
|
||||
status,
|
||||
sessionId: this._sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
private _scheduleAutoRemove(toolId: string, delayMs: number, description: string): void {
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(toolId);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
delayMs,
|
||||
{ description }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is a file-viewing command worth tracking.
|
||||
*/
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
* Maximum terminal buffer size in characters.
|
||||
* Contains raw terminal output with ANSI escape sequences.
|
||||
* Reduced from 5MB to 2MB for better render performance.
|
||||
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
|
||||
*/
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Size to trim terminal buffer to when max is exceeded.
|
||||
* Keeps the most recent portion to preserve context.
|
||||
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
|
||||
*/
|
||||
export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
|
||||
export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Text Output Buffer Limits
|
||||
@@ -38,13 +40,15 @@ export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
|
||||
/**
|
||||
* Maximum text output buffer size in characters.
|
||||
* Contains ANSI-stripped text for search and analysis.
|
||||
* Override: CODEMAN_MAX_TEXT_OUTPUT (bytes)
|
||||
*/
|
||||
export const MAX_TEXT_OUTPUT_SIZE = 1 * 1024 * 1024; // 1MB
|
||||
export const MAX_TEXT_OUTPUT_SIZE = parseInt(process.env.CODEMAN_MAX_TEXT_OUTPUT || '') || 1 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Size to trim text output buffer to when max is exceeded.
|
||||
* Override: CODEMAN_TRIM_TEXT_TO (bytes)
|
||||
*/
|
||||
export const TRIM_TEXT_TO = 768 * 1024; // 768KB
|
||||
export const TRIM_TEXT_TO = parseInt(process.env.CODEMAN_TRIM_TEXT_TO || '') || 768 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Message Buffer Limits
|
||||
@@ -53,8 +57,9 @@ export const TRIM_TEXT_TO = 768 * 1024; // 768KB
|
||||
/**
|
||||
* Maximum number of Claude JSON messages to keep in memory per session.
|
||||
* Older messages are discarded when limit is exceeded.
|
||||
* Override: CODEMAN_MAX_MESSAGES (count)
|
||||
*/
|
||||
export const MAX_MESSAGES = 1000;
|
||||
export const MAX_MESSAGES = parseInt(process.env.CODEMAN_MAX_MESSAGES || '') || 1000;
|
||||
|
||||
// ============================================================================
|
||||
// Line Buffer Limits
|
||||
|
||||
@@ -16,6 +16,7 @@ import { existsSync, statSync, realpathSync } from 'node:fs';
|
||||
import { resolve, relative, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
@@ -172,10 +173,7 @@ export class FileStreamManager extends EventEmitter {
|
||||
}
|
||||
} catch (err) {
|
||||
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
|
||||
console.warn(
|
||||
`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
console.warn(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, getErrorMessage(err));
|
||||
return { success: false, error: 'File not found or not accessible' };
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,34 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the `model` field in .claude/settings.local.json for the given case path.
|
||||
* Pass a non-empty string to set, or empty/null to remove.
|
||||
*/
|
||||
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
if (model) {
|
||||
existing.model = model;
|
||||
} else {
|
||||
delete existing.model;
|
||||
}
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes hooks config to .claude/settings.local.json in the given case path.
|
||||
* Merges with existing file content, only touching the `hooks` key.
|
||||
|
||||
@@ -0,0 +1,993 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator Loop — phased plan execution with team agents.
|
||||
*
|
||||
* State machine that generates plans from user goals, executes them
|
||||
* phase-by-phase with verification gates, and adapts on failure.
|
||||
*
|
||||
* States: idle → planning → approval → executing → verifying → (replanning) → completed/failed
|
||||
*
|
||||
* Key exports:
|
||||
* - `OrchestratorLoop` class — main engine, extends EventEmitter
|
||||
* - `OrchestratorLoopEvents` interface — typed event map
|
||||
*
|
||||
* Lifecycle: `start(goal)` → plan → approve → execute phases → verify → complete
|
||||
*
|
||||
* @dependencies orchestrator-planner (plan generation), orchestrator-verifier (phase verification),
|
||||
* session-manager (sessions), task-queue (task execution), state-store (persistence),
|
||||
* prompts/orchestrator (prompt templates)
|
||||
* @consumedby web/server (orchestrator routes, SSE)
|
||||
* @emits stateChanged, planReady, phaseStarted, phaseCompleted, phaseFailed,
|
||||
* taskAssigned, taskCompleted, taskFailed, verificationResult, completed, error
|
||||
* @persistence Orchestrator state saved to `~/.codeman/state.json` (orchestrator key)
|
||||
*
|
||||
* @module orchestrator-loop
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getSessionManager, SessionManager } from './session-manager.js';
|
||||
import { getTaskQueue, TaskQueue } from './task-queue.js';
|
||||
import { getStore, StateStore } from './state-store.js';
|
||||
import { OrchestratorPlanner } from './orchestrator-planner.js';
|
||||
import { OrchestratorVerifier } from './orchestrator-verifier.js';
|
||||
import { PHASE_EXECUTION_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT, TEAM_LEAD_PROMPT } from './prompts/index.js';
|
||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import type { CreateTaskOptions } from './task.js';
|
||||
import {
|
||||
type OrchestratorState,
|
||||
type OrchestratorPlan,
|
||||
type OrchestratorPhase,
|
||||
type OrchestratorTask,
|
||||
type OrchestratorConfig,
|
||||
type OrchestratorStats,
|
||||
type OrchestratorPersistState,
|
||||
type VerificationResult,
|
||||
DEFAULT_ORCHESTRATOR_CONFIG,
|
||||
createInitialOrchestratorStats,
|
||||
getErrorMessage,
|
||||
} from './types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Poll interval for checking task completion within a phase (2 seconds) */
|
||||
const PHASE_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
/** Delay between phase completion and verification (1 second) */
|
||||
const POST_PHASE_DELAY_MS = 1000;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Events
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface OrchestratorLoopEvents {
|
||||
stateChanged: (state: OrchestratorState, prevState: OrchestratorState) => void;
|
||||
planProgress: (phase: string, detail: string) => void;
|
||||
planReady: (plan: OrchestratorPlan) => void;
|
||||
phaseStarted: (phase: OrchestratorPhase) => void;
|
||||
phaseCompleted: (phase: OrchestratorPhase) => void;
|
||||
phaseFailed: (phase: OrchestratorPhase, reason: string) => void;
|
||||
taskAssigned: (task: OrchestratorTask, sessionId: string) => void;
|
||||
taskCompleted: (task: OrchestratorTask) => void;
|
||||
taskFailed: (task: OrchestratorTask, error: string) => void;
|
||||
verificationResult: (phase: OrchestratorPhase, result: VerificationResult) => void;
|
||||
completed: (stats: OrchestratorStats) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OrchestratorLoop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export class OrchestratorLoop extends EventEmitter {
|
||||
private _state: OrchestratorState = 'idle';
|
||||
private plan: OrchestratorPlan | null = null;
|
||||
private currentPhaseIndex = 0;
|
||||
private config: OrchestratorConfig;
|
||||
private stats: OrchestratorStats;
|
||||
private startedAt: number | null = null;
|
||||
private completedAt: number | null = null;
|
||||
|
||||
private workingDir: string;
|
||||
private planner: OrchestratorPlanner;
|
||||
private verifier: OrchestratorVerifier;
|
||||
private sessionManager: SessionManager;
|
||||
private taskQueue: TaskQueue;
|
||||
private store: StateStore;
|
||||
|
||||
/** State before pause (to resume to correct state) */
|
||||
private pausedState: OrchestratorState | null = null;
|
||||
|
||||
/** Phase poll timer for checking task completion */
|
||||
private phasePollTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Phase-level timeout timer */
|
||||
private phaseTimeoutTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Post-phase delay timer before verification */
|
||||
private postPhaseTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Session completion listener (bound for cleanup) */
|
||||
private sessionCompletionListener: ((sessionId: string, phrase: string) => void) | null = null;
|
||||
|
||||
/** Active sessions assigned to current phase */
|
||||
private phaseSessionIds: Set<string> = new Set();
|
||||
|
||||
constructor(mux: TerminalMultiplexer, workingDir: string, config?: Partial<OrchestratorConfig>) {
|
||||
super();
|
||||
this.workingDir = workingDir;
|
||||
this.config = { ...DEFAULT_ORCHESTRATOR_CONFIG, ...config };
|
||||
this.stats = createInitialOrchestratorStats();
|
||||
this.sessionManager = getSessionManager();
|
||||
this.taskQueue = getTaskQueue();
|
||||
this.store = getStore();
|
||||
this.planner = new OrchestratorPlanner(mux, workingDir, this.config);
|
||||
this.verifier = new OrchestratorVerifier(this.config);
|
||||
|
||||
// Restore state if crashed while running
|
||||
this.restore();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Public API — Lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Start orchestration with a goal. Transitions: idle → planning */
|
||||
async start(goal: string): Promise<void> {
|
||||
if (this._state !== 'idle' && this._state !== 'failed' && this._state !== 'completed') {
|
||||
throw new Error(`Cannot start from state "${this._state}"`);
|
||||
}
|
||||
|
||||
this.reset();
|
||||
this.startedAt = Date.now();
|
||||
this.setState('planning');
|
||||
|
||||
try {
|
||||
const plan = await this.planner.generatePlan(goal, (phase, detail) => {
|
||||
this.emit('planProgress', phase, detail);
|
||||
});
|
||||
|
||||
if (this.currentState() !== 'planning') {
|
||||
// Cancelled during planning
|
||||
return;
|
||||
}
|
||||
|
||||
this.plan = plan;
|
||||
this.persist();
|
||||
|
||||
if (this.config.autoApprove) {
|
||||
this.setState('executing');
|
||||
await this.executeCurrentPhase();
|
||||
} else {
|
||||
this.setState('approval');
|
||||
this.emit('planReady', plan);
|
||||
}
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Approve the generated plan. Transitions: approval → executing */
|
||||
async approve(): Promise<void> {
|
||||
this.requireState('approval');
|
||||
if (!this.plan) {
|
||||
throw new Error('No plan to approve');
|
||||
}
|
||||
|
||||
this.setState('executing');
|
||||
await this.executeCurrentPhase();
|
||||
}
|
||||
|
||||
/** Reject plan with feedback. Transitions: approval → planning (regenerate) */
|
||||
async reject(feedback: string): Promise<void> {
|
||||
this.requireState('approval');
|
||||
if (!this.plan) {
|
||||
throw new Error('No plan to reject');
|
||||
}
|
||||
|
||||
const goal = this.plan.goal + '\n\nFeedback on previous plan: ' + feedback;
|
||||
this.plan = null;
|
||||
this.setState('planning');
|
||||
|
||||
try {
|
||||
const plan = await this.planner.generatePlan(goal);
|
||||
|
||||
if ((this._state as OrchestratorState) !== 'planning') return;
|
||||
|
||||
this.plan = plan;
|
||||
this.persist();
|
||||
this.setState('approval');
|
||||
this.emit('planReady', plan);
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pause execution. Saves current state. */
|
||||
pause(): void {
|
||||
if (this._state === 'idle' || this._state === 'paused' || this._state === 'completed' || this._state === 'failed') {
|
||||
return;
|
||||
}
|
||||
this.pausedState = this._state;
|
||||
this.clearPhasePoll();
|
||||
this.cleanupTaskHandlers();
|
||||
this.setState('paused');
|
||||
}
|
||||
|
||||
/** Resume from pause. */
|
||||
async resume(): Promise<void> {
|
||||
if (this._state !== 'paused' || !this.pausedState) {
|
||||
throw new Error('Not paused');
|
||||
}
|
||||
|
||||
const resumeTo = this.pausedState;
|
||||
this.pausedState = null;
|
||||
this.setState(resumeTo);
|
||||
|
||||
// Re-enter the appropriate phase of execution
|
||||
if (resumeTo === 'executing') {
|
||||
await this.executeCurrentPhase();
|
||||
} else if (resumeTo === 'verifying') {
|
||||
await this.verifyCurrentPhase();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop everything and clean up. */
|
||||
async stop(): Promise<void> {
|
||||
this.clearPhasePoll();
|
||||
this.cleanupTaskHandlers();
|
||||
await this.planner.cancel();
|
||||
this.setState('idle');
|
||||
this.store.clearOrchestratorState();
|
||||
}
|
||||
|
||||
/** Skip a specific phase. */
|
||||
async skipPhase(phaseId: string): Promise<void> {
|
||||
if (!this.plan) return;
|
||||
|
||||
const phase = this.plan.phases.find((p) => p.id === phaseId);
|
||||
if (!phase) throw new Error(`Phase "${phaseId}" not found`);
|
||||
|
||||
phase.status = 'skipped';
|
||||
phase.completedAt = Date.now();
|
||||
this.persist();
|
||||
|
||||
// If this is the current phase, advance
|
||||
if (this.plan.phases[this.currentPhaseIndex]?.id === phaseId) {
|
||||
await this.advanceToNextPhase();
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry a failed phase. */
|
||||
async retryPhase(phaseId: string): Promise<void> {
|
||||
if (!this.plan) return;
|
||||
if (this._state !== 'executing' && this._state !== 'failed') {
|
||||
throw new Error(`Cannot retry from state "${this._state}"`);
|
||||
}
|
||||
|
||||
const phaseIndex = this.plan.phases.findIndex((p) => p.id === phaseId);
|
||||
if (phaseIndex === -1) throw new Error(`Phase "${phaseId}" not found`);
|
||||
|
||||
const phase = this.plan.phases[phaseIndex];
|
||||
phase.status = 'pending';
|
||||
phase.attempts = 0;
|
||||
for (const task of phase.tasks) {
|
||||
task.status = 'pending';
|
||||
task.error = null;
|
||||
task.assignedSessionId = null;
|
||||
task.queueTaskId = null;
|
||||
}
|
||||
|
||||
this.currentPhaseIndex = phaseIndex;
|
||||
this.setState('executing');
|
||||
await this.executeCurrentPhase();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Public API — Getters
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
get state(): OrchestratorState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
getPlan(): OrchestratorPlan | null {
|
||||
return this.plan;
|
||||
}
|
||||
|
||||
getCurrentPhase(): OrchestratorPhase | null {
|
||||
if (!this.plan) return null;
|
||||
return this.plan.phases[this.currentPhaseIndex] ?? null;
|
||||
}
|
||||
|
||||
getStats(): OrchestratorStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
getStatus(): OrchestratorPersistState {
|
||||
return {
|
||||
state: this._state,
|
||||
plan: this.plan,
|
||||
currentPhaseIndex: this.currentPhaseIndex,
|
||||
startedAt: this.startedAt,
|
||||
completedAt: this.completedAt,
|
||||
config: this.config,
|
||||
stats: this.stats,
|
||||
};
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._state !== 'idle' && this._state !== 'completed' && this._state !== 'failed';
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Phase Execution
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private async executeCurrentPhase(): Promise<void> {
|
||||
if (!this.plan || this._state !== 'executing') return;
|
||||
|
||||
const phase = this.plan.phases[this.currentPhaseIndex];
|
||||
if (!phase) {
|
||||
// All phases done
|
||||
await this.handleCompletion();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip already completed/skipped phases
|
||||
if (phase.status === 'passed' || phase.status === 'skipped') {
|
||||
await this.advanceToNextPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
phase.status = 'executing';
|
||||
phase.startedAt = Date.now();
|
||||
phase.attempts++;
|
||||
this.persist();
|
||||
this.emit('phaseStarted', phase);
|
||||
|
||||
try {
|
||||
await this.assignPhaseTasks(phase);
|
||||
this.startPhasePoll(phase);
|
||||
} catch (err) {
|
||||
this.handlePhaseError(phase, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
private async assignPhaseTasks(phase: OrchestratorPhase): Promise<void> {
|
||||
// For team strategy, send a single comprehensive prompt to a lead session
|
||||
if (phase.teamStrategy.type === 'team') {
|
||||
await this.assignTeamPhase(phase);
|
||||
return;
|
||||
}
|
||||
|
||||
// For single/parallel strategy, add individual tasks to TaskQueue
|
||||
for (const task of phase.tasks) {
|
||||
if (task.status !== 'pending') continue;
|
||||
|
||||
const prompt = this.buildTaskPrompt(task, phase);
|
||||
const taskOptions: CreateTaskOptions = {
|
||||
prompt,
|
||||
workingDir: this.workingDir,
|
||||
priority: 100 - phase.order, // Earlier phases get higher priority
|
||||
completionPhrase: task.completionPhrase,
|
||||
timeoutMs: Math.min(task.timeoutMs, this.config.phaseTimeoutMs),
|
||||
};
|
||||
|
||||
const queueTask = this.taskQueue.addTask(taskOptions);
|
||||
task.queueTaskId = queueTask.id;
|
||||
task.status = 'running';
|
||||
}
|
||||
|
||||
this.persist();
|
||||
this.setupTaskHandlers();
|
||||
|
||||
// Manually assign tasks to idle sessions
|
||||
await this.assignQueuedTasksToSessions();
|
||||
}
|
||||
|
||||
private async assignTeamPhase(phase: OrchestratorPhase): Promise<void> {
|
||||
const teamConfig = phase.teamStrategy.type === 'team' ? phase.teamStrategy.config : null;
|
||||
if (!teamConfig) return;
|
||||
|
||||
// Find or use an idle session
|
||||
const sessions = this.sessionManager.getIdleSessions();
|
||||
if (sessions.length === 0) {
|
||||
throw new Error('No idle sessions available for team phase execution');
|
||||
}
|
||||
|
||||
const session = sessions[0];
|
||||
this.phaseSessionIds.add(session.id);
|
||||
|
||||
// Mark all tasks as running under this session
|
||||
for (const task of phase.tasks) {
|
||||
task.status = 'running';
|
||||
task.assignedSessionId = session.id;
|
||||
}
|
||||
|
||||
// Build and send the team lead prompt
|
||||
const prompt = TEAM_LEAD_PROMPT.replace('{PHASE_NAME}', phase.name)
|
||||
.replace('{TASK_LIST}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n'))
|
||||
.replace('{TEAMMATE_HINTS}', teamConfig.suggestedTeammates.map((h, i) => `${i + 1}. ${h}`).join('\n'))
|
||||
.replace('{COMPLETION_PHRASE}', `${phase.id.toUpperCase()}_COMPLETE`);
|
||||
|
||||
// Create a TaskQueue task for the entire phase
|
||||
const queueTask = this.taskQueue.addTask({
|
||||
prompt,
|
||||
workingDir: this.workingDir,
|
||||
priority: 100 - phase.order,
|
||||
completionPhrase: `${phase.id.toUpperCase()}_COMPLETE`,
|
||||
timeoutMs: this.config.phaseTimeoutMs,
|
||||
});
|
||||
|
||||
// Link all phase tasks to this single queue task
|
||||
for (const task of phase.tasks) {
|
||||
task.queueTaskId = queueTask.id;
|
||||
}
|
||||
|
||||
this.persist();
|
||||
this.setupTaskHandlers();
|
||||
|
||||
// Assign the task to the session
|
||||
try {
|
||||
queueTask.assign(session.id);
|
||||
session.assignTask(queueTask.id);
|
||||
this.taskQueue.updateTask(queueTask);
|
||||
await session.sendInput(prompt);
|
||||
} catch (err) {
|
||||
queueTask.fail(getErrorMessage(err));
|
||||
this.taskQueue.updateTask(queueTask);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async assignQueuedTasksToSessions(): Promise<void> {
|
||||
const idleSessions = this.sessionManager.getIdleSessions();
|
||||
const maxSessions =
|
||||
this.getCurrentPhase()?.teamStrategy.type === 'parallel'
|
||||
? (this.getCurrentPhase()?.teamStrategy as { type: 'parallel'; maxSessions: number }).maxSessions
|
||||
: 1;
|
||||
|
||||
const sessionsToUse = idleSessions.slice(0, maxSessions);
|
||||
|
||||
for (const session of sessionsToUse) {
|
||||
const task = this.taskQueue.next();
|
||||
if (!task) break;
|
||||
|
||||
try {
|
||||
task.assign(session.id);
|
||||
session.assignTask(task.id);
|
||||
this.taskQueue.updateTask(task);
|
||||
await session.sendInput(task.prompt);
|
||||
|
||||
this.phaseSessionIds.add(session.id);
|
||||
|
||||
// Find the orchestrator task linked to this queue task
|
||||
const orchTask = this.findOrchestratorTaskByQueueId(task.id);
|
||||
if (orchTask) {
|
||||
orchTask.assignedSessionId = session.id;
|
||||
orchTask.startedAt = Date.now();
|
||||
this.emit('taskAssigned', orchTask, session.id);
|
||||
}
|
||||
} catch (err) {
|
||||
task.fail(getErrorMessage(err));
|
||||
session.clearTask();
|
||||
this.taskQueue.updateTask(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Task Completion Tracking
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private setupTaskHandlers(): void {
|
||||
this.cleanupTaskHandlers();
|
||||
|
||||
this.sessionCompletionListener = (_sessionId: string, _phrase: string) => {
|
||||
// Session completion — check if it's related to our phase tasks
|
||||
this.checkPhaseCompletion();
|
||||
};
|
||||
|
||||
this.sessionManager.on('sessionCompletion', this.sessionCompletionListener);
|
||||
}
|
||||
|
||||
private cleanupTaskHandlers(): void {
|
||||
if (this.sessionCompletionListener) {
|
||||
this.sessionManager.off('sessionCompletion', this.sessionCompletionListener);
|
||||
this.sessionCompletionListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _finalizeTask(queueTaskId: string, status: 'completed' | 'failed', error?: string): OrchestratorTask | null {
|
||||
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId);
|
||||
if (!orchTask) return null;
|
||||
|
||||
orchTask.status = status;
|
||||
if (status === 'completed') {
|
||||
orchTask.completedAt = Date.now();
|
||||
this.stats.totalTasksCompleted++;
|
||||
} else {
|
||||
orchTask.error = error ?? null;
|
||||
this.stats.totalTasksFailed++;
|
||||
}
|
||||
this.persist();
|
||||
return orchTask;
|
||||
}
|
||||
|
||||
private handleTaskCompleted(queueTaskId: string): void {
|
||||
const orchTask = this._finalizeTask(queueTaskId, 'completed');
|
||||
if (!orchTask) return;
|
||||
|
||||
this.emit('taskCompleted', orchTask);
|
||||
this.checkPhaseCompletion();
|
||||
}
|
||||
|
||||
private handleTaskFailed(queueTaskId: string, error: string): void {
|
||||
const orchTask = this._finalizeTask(queueTaskId, 'failed', error);
|
||||
if (!orchTask) return;
|
||||
|
||||
this.emit('taskFailed', orchTask, error);
|
||||
|
||||
// Check if we should retry the task or fail the phase
|
||||
if (orchTask.retries < 2) {
|
||||
orchTask.retries++;
|
||||
orchTask.status = 'pending';
|
||||
orchTask.error = null;
|
||||
orchTask.queueTaskId = null;
|
||||
// Will be re-queued on next poll
|
||||
} else {
|
||||
this.checkPhaseCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
private startPhasePoll(phase: OrchestratorPhase): void {
|
||||
this.clearPhasePoll();
|
||||
this.phasePollTimer = setInterval(() => {
|
||||
if (this._state !== 'executing') {
|
||||
this.clearPhasePoll();
|
||||
return;
|
||||
}
|
||||
this.pollPhaseStatus(phase);
|
||||
}, PHASE_POLL_INTERVAL_MS);
|
||||
|
||||
// Phase-level timeout — fail the phase if it exceeds the configured timeout
|
||||
this.phaseTimeoutTimer = setTimeout(() => {
|
||||
if (this._state === 'executing' && phase.status === 'executing') {
|
||||
console.warn(`[Orchestrator] Phase "${phase.name}" timed out after ${this.config.phaseTimeoutMs}ms`);
|
||||
this.handlePhaseError(phase, `Phase timed out after ${Math.round(this.config.phaseTimeoutMs / 60000)} minutes`);
|
||||
}
|
||||
}, this.config.phaseTimeoutMs);
|
||||
}
|
||||
|
||||
private _clearTimer(
|
||||
timerKey: 'phasePollTimer' | 'phaseTimeoutTimer' | 'postPhaseTimer',
|
||||
clearFn: typeof clearInterval | typeof clearTimeout
|
||||
): void {
|
||||
if (this[timerKey]) {
|
||||
clearFn(this[timerKey]);
|
||||
this[timerKey] = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearPhasePoll(): void {
|
||||
this._clearTimer('phasePollTimer', clearInterval);
|
||||
this._clearTimer('phaseTimeoutTimer', clearTimeout);
|
||||
this._clearTimer('postPhaseTimer', clearTimeout);
|
||||
}
|
||||
|
||||
private pollPhaseStatus(phase: OrchestratorPhase): void {
|
||||
// Check for queued tasks that need assignment
|
||||
const pendingTasks = phase.tasks.filter((t) => t.status === 'pending' && !t.queueTaskId);
|
||||
if (pendingTasks.length > 0) {
|
||||
// Re-queue pending tasks
|
||||
for (const task of pendingTasks) {
|
||||
const prompt = this.buildTaskPrompt(task, phase);
|
||||
const queueTask = this.taskQueue.addTask({
|
||||
prompt,
|
||||
workingDir: this.workingDir,
|
||||
priority: 100 - phase.order,
|
||||
completionPhrase: task.completionPhrase,
|
||||
timeoutMs: Math.min(task.timeoutMs, this.config.phaseTimeoutMs),
|
||||
});
|
||||
task.queueTaskId = queueTask.id;
|
||||
task.status = 'running';
|
||||
}
|
||||
this.assignQueuedTasksToSessions().catch(() => {}); // Best effort
|
||||
}
|
||||
|
||||
// Check completion status of queue tasks
|
||||
for (const task of phase.tasks) {
|
||||
if (task.status === 'running' && task.queueTaskId) {
|
||||
const queueTask = this.taskQueue.getTask(task.queueTaskId);
|
||||
if (queueTask) {
|
||||
if (queueTask.isCompleted()) {
|
||||
this.handleTaskCompleted(task.queueTaskId);
|
||||
} else if (queueTask.isFailed()) {
|
||||
this.handleTaskFailed(task.queueTaskId, queueTask.error || 'Task failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.checkPhaseCompletion();
|
||||
}
|
||||
|
||||
private checkPhaseCompletion(): void {
|
||||
if (this._state !== 'executing') return;
|
||||
|
||||
const phase = this.getCurrentPhase();
|
||||
if (!phase) return;
|
||||
|
||||
const allDone = phase.tasks.every((t) => t.status === 'completed' || t.status === 'failed');
|
||||
if (!allDone) return;
|
||||
|
||||
const anyFailed = phase.tasks.some((t) => t.status === 'failed');
|
||||
|
||||
this.clearPhasePoll();
|
||||
|
||||
if (anyFailed) {
|
||||
// Phase has failed tasks
|
||||
this.handlePhaseError(phase, 'One or more tasks failed');
|
||||
} else {
|
||||
// All tasks completed — run verification after brief delay
|
||||
this.postPhaseTimer = setTimeout(() => {
|
||||
this.postPhaseTimer = null;
|
||||
this.verifyCurrentPhase().catch((err) => this.handleError(err));
|
||||
}, POST_PHASE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Verification
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private async verifyCurrentPhase(): Promise<void> {
|
||||
if (!this.plan) return;
|
||||
|
||||
const phase = this.plan.phases[this.currentPhaseIndex];
|
||||
if (!phase) return;
|
||||
|
||||
// Skip verification if no criteria defined
|
||||
if (phase.verificationCriteria.length === 0 && phase.testCommands.length === 0) {
|
||||
phase.status = 'passed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesCompleted++;
|
||||
this.persist();
|
||||
this.emit('phaseCompleted', phase);
|
||||
await this.advanceToNextPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState('verifying');
|
||||
|
||||
// Get a session for verification — wait briefly for sessions to become idle
|
||||
let sessions = this.sessionManager.getIdleSessions();
|
||||
if (sessions.length === 0) {
|
||||
// Wait up to 10s for a session to become idle
|
||||
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
||||
sessions = this.sessionManager.getIdleSessions();
|
||||
}
|
||||
if (sessions.length === 0) {
|
||||
// Still no sessions — log warning and skip verification (don't silently pass)
|
||||
console.warn('[Orchestrator] No idle sessions for verification — skipping (marking passed with warning)');
|
||||
phase.status = 'passed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesCompleted++;
|
||||
this.persist();
|
||||
this.emit('phaseCompleted', phase);
|
||||
this.setState('executing');
|
||||
await this.advanceToNextPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.verifier.verifyPhase(phase, sessions[0]);
|
||||
this.emit('verificationResult', phase, result);
|
||||
|
||||
if (result.passed) {
|
||||
phase.status = 'passed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesCompleted++;
|
||||
this.persist();
|
||||
this.emit('phaseCompleted', phase);
|
||||
this.setState('executing');
|
||||
await this.advanceToNextPhase();
|
||||
} else {
|
||||
// Verification failed — attempt replan
|
||||
await this.handleVerificationFailure(phase, result);
|
||||
}
|
||||
} catch (err) {
|
||||
// Verification error — treat as pass (don't block on verification bugs)
|
||||
console.warn('[Orchestrator] Verification error, treating as pass:', err);
|
||||
phase.status = 'passed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesCompleted++;
|
||||
this.persist();
|
||||
this.emit('phaseCompleted', phase);
|
||||
this.setState('executing');
|
||||
await this.advanceToNextPhase();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleVerificationFailure(phase: OrchestratorPhase, result: VerificationResult): Promise<void> {
|
||||
if (phase.attempts >= phase.maxAttempts) {
|
||||
// Max retries exceeded
|
||||
phase.status = 'failed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesFailed++;
|
||||
this.persist();
|
||||
this.emit('phaseFailed', phase, `Verification failed after ${phase.attempts} attempts: ${result.summary}`);
|
||||
this.setState('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Replan and retry
|
||||
this.stats.replanCount++;
|
||||
this.setState('replanning');
|
||||
|
||||
try {
|
||||
await this.replanPhase(phase, result);
|
||||
// Reset task states for retry
|
||||
for (const task of phase.tasks) {
|
||||
task.status = 'pending';
|
||||
task.error = null;
|
||||
task.assignedSessionId = null;
|
||||
task.queueTaskId = null;
|
||||
task.completedAt = null;
|
||||
task.startedAt = null;
|
||||
}
|
||||
phase.status = 'pending';
|
||||
phase.startedAt = null;
|
||||
this.persist();
|
||||
|
||||
this.setState('executing');
|
||||
await this.executeCurrentPhase();
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async replanPhase(phase: OrchestratorPhase, result: VerificationResult): Promise<void> {
|
||||
const completionPhrase = phase.tasks[0]?.completionPhrase || `${phase.id.toUpperCase()}_FIXED`;
|
||||
|
||||
const prompt = REPLAN_PROMPT.replace('{PHASE_NAME}', phase.name)
|
||||
.replace('{ATTEMPT_NUMBER}', String(phase.attempts))
|
||||
.replace('{MAX_ATTEMPTS}', String(phase.maxAttempts))
|
||||
.replace('{FAILURE_SUMMARY}', result.summary)
|
||||
.replace('{SUGGESTIONS}', result.suggestions.join('\n'))
|
||||
.replace('{ORIGINAL_TASKS}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n'))
|
||||
.replace('{COMPLETION_PHRASE}', completionPhrase);
|
||||
|
||||
// Create a tracked queue task for the replan (so completion is detected)
|
||||
const queueTask = this.taskQueue.addTask({
|
||||
prompt,
|
||||
workingDir: this.workingDir,
|
||||
priority: 100,
|
||||
completionPhrase,
|
||||
timeoutMs: this.config.phaseTimeoutMs,
|
||||
});
|
||||
|
||||
// Link to first phase task for tracking
|
||||
if (phase.tasks[0]) {
|
||||
phase.tasks[0].queueTaskId = queueTask.id;
|
||||
phase.tasks[0].status = 'running';
|
||||
}
|
||||
|
||||
this.persist();
|
||||
|
||||
// Set up handlers so task completion is tracked
|
||||
this.setupTaskHandlers();
|
||||
|
||||
// Assign to a session
|
||||
const sessions = this.sessionManager.getIdleSessions();
|
||||
if (sessions.length === 0) {
|
||||
console.warn('[Orchestrator] No idle sessions for replan — task queued, will pick up on next poll');
|
||||
// Start polling so the task gets assigned when a session becomes idle
|
||||
this.startPhasePoll(phase);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
queueTask.assign(sessions[0].id);
|
||||
sessions[0].assignTask(queueTask.id);
|
||||
this.taskQueue.updateTask(queueTask);
|
||||
await sessions[0].sendInput(prompt);
|
||||
} catch (err) {
|
||||
queueTask.fail(getErrorMessage(err));
|
||||
this.taskQueue.updateTask(queueTask);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — State Machine
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Read current state (bypasses TypeScript narrowing from guards) */
|
||||
private currentState(): OrchestratorState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/** Assert state matches expected or throw */
|
||||
private requireState(...expected: OrchestratorState[]): void {
|
||||
if (!expected.includes(this._state)) {
|
||||
throw new Error(`Expected state "${expected.join('|')}", got "${this._state}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private setState(newState: OrchestratorState): void {
|
||||
const prev = this._state;
|
||||
if (prev === newState) return;
|
||||
this._state = newState;
|
||||
this.persist();
|
||||
this.emit('stateChanged', newState, prev);
|
||||
}
|
||||
|
||||
private async advanceToNextPhase(): Promise<void> {
|
||||
this.currentPhaseIndex++;
|
||||
this.phaseSessionIds.clear();
|
||||
this.persist();
|
||||
|
||||
if (!this.plan || this.currentPhaseIndex >= this.plan.phases.length) {
|
||||
await this.handleCompletion();
|
||||
} else {
|
||||
// Compact between phases if configured
|
||||
if (this.config.compactBetweenPhases) {
|
||||
const sessions = this.sessionManager.getIdleSessions();
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
await session.writeViaMux('/compact');
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
// Brief delay for compact to take effect
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
await this.executeCurrentPhase();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCompletion(): Promise<void> {
|
||||
this.completedAt = Date.now();
|
||||
this.stats.totalDurationMs = this.startedAt ? this.completedAt - this.startedAt : 0;
|
||||
this.clearPhasePoll();
|
||||
this.cleanupTaskHandlers();
|
||||
this.setState('completed');
|
||||
this.emit('completed', this.stats);
|
||||
}
|
||||
|
||||
private handlePhaseError(phase: OrchestratorPhase, error: string): void {
|
||||
if (phase.attempts >= phase.maxAttempts) {
|
||||
phase.status = 'failed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesFailed++;
|
||||
this.persist();
|
||||
this.emit('phaseFailed', phase, error);
|
||||
this.setState('failed');
|
||||
} else {
|
||||
// Retry the phase
|
||||
for (const task of phase.tasks) {
|
||||
if (task.status === 'failed') {
|
||||
task.status = 'pending';
|
||||
task.error = null;
|
||||
task.queueTaskId = null;
|
||||
task.assignedSessionId = null;
|
||||
}
|
||||
}
|
||||
phase.status = 'pending';
|
||||
this.persist();
|
||||
this.executeCurrentPhase().catch((err) => this.handleError(err));
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(err: unknown): void {
|
||||
const error = err instanceof Error ? err : new Error(getErrorMessage(err));
|
||||
console.error('[Orchestrator] Error:', error.message);
|
||||
this.setState('failed');
|
||||
this.emit('error', error);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Persistence
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private persist(): void {
|
||||
this.store.setOrchestratorState(this.getStatus());
|
||||
}
|
||||
|
||||
private restore(): void {
|
||||
const saved = this.store.getOrchestratorState();
|
||||
if (!saved) return;
|
||||
|
||||
// If we crashed while running, reset to failed
|
||||
if (saved.state === 'executing' || saved.state === 'verifying' || saved.state === 'replanning') {
|
||||
this._state = 'failed';
|
||||
this.plan = saved.plan;
|
||||
this.currentPhaseIndex = saved.currentPhaseIndex;
|
||||
this.startedAt = saved.startedAt;
|
||||
this.config = saved.config;
|
||||
this.stats = saved.stats;
|
||||
this.store.setOrchestratorState({ ...saved, state: 'failed' });
|
||||
} else if (saved.state === 'planning' || saved.state === 'approval') {
|
||||
// Planning/approval — reset to idle (plan is lost)
|
||||
this.store.clearOrchestratorState();
|
||||
} else if (saved.state === 'completed' || saved.state === 'failed') {
|
||||
// Preserve completed/failed state for UI display
|
||||
this._state = saved.state;
|
||||
this.plan = saved.plan;
|
||||
this.currentPhaseIndex = saved.currentPhaseIndex;
|
||||
this.startedAt = saved.startedAt;
|
||||
this.completedAt = saved.completedAt;
|
||||
this.config = saved.config;
|
||||
this.stats = saved.stats;
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this._state = 'idle';
|
||||
this.plan = null;
|
||||
this.currentPhaseIndex = 0;
|
||||
this.startedAt = null;
|
||||
this.completedAt = null;
|
||||
this.stats = createInitialOrchestratorStats();
|
||||
this.pausedState = null;
|
||||
this.phaseSessionIds.clear();
|
||||
this.clearPhasePoll();
|
||||
this.cleanupTaskHandlers();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Helpers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private buildTaskPrompt(task: OrchestratorTask, phase: OrchestratorPhase): string {
|
||||
if (phase.tasks.length === 1) {
|
||||
// Single task — use simpler prompt
|
||||
const completedPhases = this.getCompletedPhasesSummary();
|
||||
return SINGLE_TASK_PROMPT.replace('{TASK}', task.prompt)
|
||||
.replace('{GOAL}', this.plan?.goal || '')
|
||||
.replace('{CONTEXT}', completedPhases ? `Previous phases completed: ${completedPhases}` : '')
|
||||
.replace('{COMPLETION_PHRASE}', task.completionPhrase);
|
||||
}
|
||||
|
||||
// Multi-task phase — use full prompt
|
||||
return PHASE_EXECUTION_PROMPT.replace('{PHASE_NAME}', phase.name)
|
||||
.replace('{GOAL}', this.plan?.goal || '')
|
||||
.replace('{COMPLETED_PHASES}', this.getCompletedPhasesSummary() || 'None yet')
|
||||
.replace('{TASK_LIST}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n'))
|
||||
.replace('{VERIFICATION_CRITERIA}', phase.verificationCriteria.join('\n') || 'No specific criteria')
|
||||
.replace('{COMPLETION_PHRASE}', task.completionPhrase);
|
||||
}
|
||||
|
||||
private getCompletedPhasesSummary(): string {
|
||||
if (!this.plan) return '';
|
||||
return this.plan.phases
|
||||
.filter((p) => p.status === 'passed' || p.status === 'skipped')
|
||||
.map((p) => `${p.name}: ${p.status}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
private findOrchestratorTaskByQueueId(queueTaskId: string): OrchestratorTask | null {
|
||||
if (!this.plan) return null;
|
||||
for (const phase of this.plan.phases) {
|
||||
for (const task of phase.tasks) {
|
||||
if (task.queueTaskId === queueTaskId) return task;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Clean up resources when the loop is being destroyed. */
|
||||
destroy(): void {
|
||||
this.clearPhasePoll();
|
||||
this.cleanupTaskHandlers();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator plan generation — converts goals into phased plans.
|
||||
*
|
||||
* Wraps PlanOrchestrator for AI-powered plan generation, then groups the
|
||||
* resulting PlanItems into sequential phases with team strategies and
|
||||
* verification criteria.
|
||||
*
|
||||
* Phase grouping algorithm:
|
||||
* 1. Topological sort by dependencies (Kahn's algorithm)
|
||||
* 2. Group into dependency layers
|
||||
* 3. Sub-group by TDD phase within layers
|
||||
* 4. Merge small adjacent phases
|
||||
* 5. Assign team strategies based on parallelism potential
|
||||
*
|
||||
* Key exports:
|
||||
* - `OrchestratorPlanner` class — plan generation + phase grouping
|
||||
*
|
||||
* @dependencies plan-orchestrator (AI plan generation), types (OrchestratorPlan, PlanItem)
|
||||
* @consumedby orchestrator-loop
|
||||
*
|
||||
* @module orchestrator-planner
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PlanOrchestrator, type DetailedPlanResult, type ProgressCallback } from './plan-orchestrator.js';
|
||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import type {
|
||||
PlanItem,
|
||||
TddPhase,
|
||||
OrchestratorPlan,
|
||||
OrchestratorPhase,
|
||||
OrchestratorTask,
|
||||
OrchestratorConfig,
|
||||
TeamStrategy,
|
||||
PhaseStatus,
|
||||
} from './types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Maximum number of phases (prevents runaway plans) */
|
||||
const MAX_PHASES = 10;
|
||||
|
||||
/** Maximum total tasks across all phases */
|
||||
const MAX_TOTAL_TASKS = 50;
|
||||
|
||||
/** Default task timeout (10 minutes) */
|
||||
const DEFAULT_TASK_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Minimum tasks in a phase before it gets merged with adjacent */
|
||||
const MIN_PHASE_TASKS = 2;
|
||||
|
||||
/** TDD phase ordering for grouping */
|
||||
const TDD_PHASE_ORDER: Record<TddPhase, number> = {
|
||||
setup: 0,
|
||||
test: 1,
|
||||
impl: 2,
|
||||
verify: 3,
|
||||
review: 4,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OrchestratorPlanner
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export class OrchestratorPlanner {
|
||||
private mux: TerminalMultiplexer;
|
||||
private workingDir: string;
|
||||
private config: OrchestratorConfig;
|
||||
private orchestrator: PlanOrchestrator | null = null;
|
||||
|
||||
constructor(mux: TerminalMultiplexer, workingDir: string, config: OrchestratorConfig) {
|
||||
this.mux = mux;
|
||||
this.workingDir = workingDir;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a phased plan from a user goal.
|
||||
*
|
||||
* Uses PlanOrchestrator for AI plan generation, then groups results into phases.
|
||||
*/
|
||||
async generatePlan(goal: string, onProgress?: ProgressCallback): Promise<OrchestratorPlan> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create a PlanOrchestrator for this plan generation
|
||||
this.orchestrator = new PlanOrchestrator(this.mux, this.workingDir, undefined, {
|
||||
defaultModel: this.config.plannerModel,
|
||||
});
|
||||
|
||||
try {
|
||||
onProgress?.('planning', 'Generating detailed plan...');
|
||||
|
||||
const result: DetailedPlanResult = await this.orchestrator.generateDetailedPlan(goal, onProgress);
|
||||
|
||||
if (!result.success || !result.items || result.items.length === 0) {
|
||||
throw new Error(result.error || 'Plan generation returned no items');
|
||||
}
|
||||
|
||||
// Cap total tasks
|
||||
const items = result.items.slice(0, MAX_TOTAL_TASKS);
|
||||
|
||||
onProgress?.('grouping', 'Organizing plan into phases...');
|
||||
|
||||
// Group items into phases
|
||||
const phases = this.groupIntoPhases(items, goal);
|
||||
|
||||
// Assign team strategies
|
||||
this.assignTeamStrategies(phases);
|
||||
|
||||
// Generate unique completion phrases
|
||||
this.generateCompletionPhrases(phases);
|
||||
|
||||
const plan: OrchestratorPlan = {
|
||||
id: uuidv4(),
|
||||
goal,
|
||||
createdAt: Date.now(),
|
||||
phases,
|
||||
metadata: {
|
||||
totalTasks: phases.reduce((sum, p) => sum + p.tasks.length, 0),
|
||||
estimatedComplexity: this.estimateComplexity(items),
|
||||
modelUsed: this.config.plannerModel,
|
||||
planDurationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
|
||||
return plan;
|
||||
} finally {
|
||||
this.orchestrator = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel in-progress plan generation. */
|
||||
async cancel(): Promise<void> {
|
||||
if (this.orchestrator) {
|
||||
await this.orchestrator.cancel();
|
||||
this.orchestrator = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Phase Grouping
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Group PlanItems into sequential phases.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Build dependency graph and assign IDs to items without them
|
||||
* 2. Topological sort into dependency layers (Kahn's algorithm)
|
||||
* 3. Sub-group within each layer by TDD phase
|
||||
* 4. Merge small phases with their neighbors
|
||||
*/
|
||||
private groupIntoPhases(items: PlanItem[], _goal: string): OrchestratorPhase[] {
|
||||
// Ensure all items have IDs
|
||||
const indexedItems = items.map((item, i) => ({
|
||||
...item,
|
||||
id: item.id || `task-${i}`,
|
||||
}));
|
||||
|
||||
// Build adjacency and in-degree for Kahn's algorithm
|
||||
const idSet = new Set(indexedItems.map((item) => item.id!));
|
||||
const inDegree = new Map<string, number>();
|
||||
const dependents = new Map<string, string[]>(); // id → items that depend on it
|
||||
|
||||
for (const item of indexedItems) {
|
||||
inDegree.set(item.id!, 0);
|
||||
dependents.set(item.id!, []);
|
||||
}
|
||||
|
||||
for (const item of indexedItems) {
|
||||
const deps = (item.dependencies || []).filter((d) => idSet.has(d));
|
||||
inDegree.set(item.id!, deps.length);
|
||||
for (const dep of deps) {
|
||||
dependents.get(dep)!.push(item.id!);
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm — produce dependency layers
|
||||
const layers: PlanItem[][] = [];
|
||||
const remaining = new Set(indexedItems.map((item) => item.id!));
|
||||
|
||||
while (remaining.size > 0) {
|
||||
// Find items with no remaining dependencies (in-degree 0)
|
||||
const layer: PlanItem[] = [];
|
||||
for (const id of remaining) {
|
||||
if (inDegree.get(id)! === 0) {
|
||||
layer.push(indexedItems.find((item) => item.id === id)!);
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.length === 0) {
|
||||
// Circular dependency — add all remaining items as a single layer
|
||||
for (const id of remaining) {
|
||||
layer.push(indexedItems.find((item) => item.id === id)!);
|
||||
}
|
||||
}
|
||||
|
||||
layers.push(layer);
|
||||
|
||||
// Remove this layer's items and update in-degrees
|
||||
for (const item of layer) {
|
||||
remaining.delete(item.id!);
|
||||
for (const dep of dependents.get(item.id!) || []) {
|
||||
if (remaining.has(dep)) {
|
||||
inDegree.set(dep, Math.max(0, inDegree.get(dep)! - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-group each layer by TDD phase
|
||||
const rawPhases: PlanItem[][] = [];
|
||||
for (const layer of layers) {
|
||||
const byPhase = new Map<string, PlanItem[]>();
|
||||
for (const item of layer) {
|
||||
const phase = item.tddPhase || 'impl';
|
||||
if (!byPhase.has(phase)) byPhase.set(phase, []);
|
||||
byPhase.get(phase)!.push(item);
|
||||
}
|
||||
|
||||
// Sort sub-groups by TDD phase order
|
||||
const sorted = [...byPhase.entries()].sort(
|
||||
([a], [b]) => (TDD_PHASE_ORDER[a as TddPhase] ?? 2) - (TDD_PHASE_ORDER[b as TddPhase] ?? 2)
|
||||
);
|
||||
|
||||
for (const [, items] of sorted) {
|
||||
rawPhases.push(items);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge small phases with their previous neighbor
|
||||
const mergedPhases: PlanItem[][] = [];
|
||||
for (const phase of rawPhases) {
|
||||
if (mergedPhases.length > 0 && phase.length < MIN_PHASE_TASKS) {
|
||||
const prev = mergedPhases[mergedPhases.length - 1];
|
||||
if (prev.length < MIN_PHASE_TASKS) {
|
||||
// Merge with previous
|
||||
prev.push(...phase);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
mergedPhases.push([...phase]);
|
||||
}
|
||||
|
||||
// Cap at MAX_PHASES by merging tail phases
|
||||
while (mergedPhases.length > MAX_PHASES) {
|
||||
const last = mergedPhases.pop()!;
|
||||
mergedPhases[mergedPhases.length - 1].push(...last);
|
||||
}
|
||||
|
||||
// Convert to OrchestratorPhase objects
|
||||
return mergedPhases.map((phaseItems, index) => this.createPhase(phaseItems, index));
|
||||
}
|
||||
|
||||
private createPhase(items: PlanItem[], order: number): OrchestratorPhase {
|
||||
// Derive phase name from TDD phases and priorities
|
||||
const tddPhases = [...new Set(items.map((i) => i.tddPhase).filter(Boolean))];
|
||||
const name = this.generatePhaseName(items, tddPhases as TddPhase[], order);
|
||||
const description = items.map((i) => i.content).join('; ');
|
||||
|
||||
const tasks: OrchestratorTask[] = items.map((item, i) => ({
|
||||
id: `phase-${order + 1}-task-${i + 1}`,
|
||||
phaseId: `phase-${order + 1}`,
|
||||
prompt: item.content,
|
||||
status: 'pending' as const,
|
||||
assignedSessionId: null,
|
||||
queueTaskId: null,
|
||||
parallel: items.length > 1, // Tasks within a phase are parallel by default
|
||||
completionPhrase: '', // Assigned later
|
||||
timeoutMs: DEFAULT_TASK_TIMEOUT_MS,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
retries: 0,
|
||||
}));
|
||||
|
||||
// Extract verification criteria and test commands from items
|
||||
const verificationCriteria = items
|
||||
.map((i) => i.verificationCriteria)
|
||||
.filter((v): v is string => v != null && v.length > 0);
|
||||
|
||||
const testCommands = items.map((i) => i.testCommand).filter((t): t is string => t != null && t.length > 0);
|
||||
|
||||
return {
|
||||
id: `phase-${order + 1}`,
|
||||
name,
|
||||
description,
|
||||
order,
|
||||
status: 'pending' as PhaseStatus,
|
||||
tasks,
|
||||
verificationCriteria,
|
||||
testCommands,
|
||||
maxAttempts: this.config.maxPhaseRetries,
|
||||
attempts: 0,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
teamStrategy: { type: 'single' }, // Assigned later
|
||||
};
|
||||
}
|
||||
|
||||
private generatePhaseName(items: PlanItem[], tddPhases: TddPhase[], order: number): string {
|
||||
// Try to create a meaningful name based on content
|
||||
const priorities = [...new Set(items.map((i) => i.priority).filter(Boolean))];
|
||||
|
||||
if (tddPhases.length === 1) {
|
||||
const phaseNames: Record<TddPhase, string> = {
|
||||
setup: 'Setup & Configuration',
|
||||
test: 'Test Definition',
|
||||
impl: 'Implementation',
|
||||
verify: 'Verification',
|
||||
review: 'Review & Polish',
|
||||
};
|
||||
return `Phase ${order + 1}: ${phaseNames[tddPhases[0]]}`;
|
||||
}
|
||||
|
||||
if (priorities.includes('P0') && priorities.length === 1) {
|
||||
return `Phase ${order + 1}: Critical Foundation`;
|
||||
}
|
||||
|
||||
return `Phase ${order + 1}: ${items.length > 1 ? 'Parallel Tasks' : items[0].content.slice(0, 50)}`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Team Strategy Assignment
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private assignTeamStrategies(phases: OrchestratorPhase[]): void {
|
||||
for (const phase of phases) {
|
||||
phase.teamStrategy = this.computeTeamStrategy(phase);
|
||||
}
|
||||
}
|
||||
|
||||
private computeTeamStrategy(phase: OrchestratorPhase): TeamStrategy {
|
||||
const taskCount = phase.tasks.length;
|
||||
const parallelTasks = phase.tasks.filter((t) => t.parallel).length;
|
||||
|
||||
// Single task or no parallel potential → single session
|
||||
if (taskCount <= 2 || parallelTasks <= 1) {
|
||||
return { type: 'single' };
|
||||
}
|
||||
|
||||
// If team agents are disabled, use parallel sessions instead
|
||||
if (!this.config.enableTeamAgents) {
|
||||
return {
|
||||
type: 'parallel',
|
||||
maxSessions: Math.min(parallelTasks, this.config.maxParallelSessions),
|
||||
};
|
||||
}
|
||||
|
||||
// 4+ parallel tasks with team agents enabled → team mode
|
||||
if (parallelTasks >= 4) {
|
||||
return {
|
||||
type: 'team',
|
||||
config: {
|
||||
leadPrompt: this.buildTeamLeadPrompt(phase),
|
||||
suggestedTeammates: phase.tasks.slice(0, 4).map((t) => `Specialist for: ${t.prompt.slice(0, 80)}`),
|
||||
maxTeammates: Math.min(parallelTasks, 4),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 3 parallel tasks → parallel sessions
|
||||
return {
|
||||
type: 'parallel',
|
||||
maxSessions: Math.min(parallelTasks, this.config.maxParallelSessions),
|
||||
};
|
||||
}
|
||||
|
||||
private buildTeamLeadPrompt(phase: OrchestratorPhase): string {
|
||||
const taskList = phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n');
|
||||
|
||||
return [
|
||||
`You are the team lead for "${phase.name}".`,
|
||||
`Create teammates and delegate the following tasks for parallel execution:`,
|
||||
'',
|
||||
taskList,
|
||||
'',
|
||||
`Each teammate should focus on one task area.`,
|
||||
`When all tasks are complete, verify the results and output: <promise>${phase.id.toUpperCase()}_COMPLETE</promise>`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Completion Phrases
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private generateCompletionPhrases(phases: OrchestratorPhase[]): void {
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
// Generate a unique, deterministic completion phrase per task
|
||||
task.completionPhrase = `ORCH_P${phase.order + 1}_T${phase.tasks.indexOf(task) + 1}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private estimateComplexity(items: PlanItem[]): 'low' | 'medium' | 'high' {
|
||||
const total = items.length;
|
||||
const highComplexity = items.filter((i) => i.complexity === 'high').length;
|
||||
const p0Count = items.filter((i) => i.priority === 'P0').length;
|
||||
|
||||
if (total > 20 || highComplexity > 5 || p0Count > 8) return 'high';
|
||||
if (total > 10 || highComplexity > 2 || p0Count > 4) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator phase verification.
|
||||
*
|
||||
* Runs verification checks after each phase completes:
|
||||
* - Test commands (shell commands via session)
|
||||
* - AI review (ask Claude to evaluate phase results)
|
||||
*
|
||||
* Three verification modes:
|
||||
* - strict: ALL test commands must pass AND AI review must approve
|
||||
* - moderate: Test commands must pass, AI review is advisory
|
||||
* - lenient: At least one test command passes, AI review skipped
|
||||
*
|
||||
* Key exports:
|
||||
* - `OrchestratorVerifier` class — phase verification engine
|
||||
*
|
||||
* @dependencies types (OrchestratorPhase, VerificationResult, VerificationCheck, OrchestratorConfig)
|
||||
* @consumedby orchestrator-loop
|
||||
*
|
||||
* @module orchestrator-verifier
|
||||
*/
|
||||
|
||||
import type { Session } from './session.js';
|
||||
import {
|
||||
getErrorMessage,
|
||||
type OrchestratorPhase,
|
||||
type OrchestratorConfig,
|
||||
type VerificationResult,
|
||||
type VerificationCheck,
|
||||
} from './types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Timeout for individual test command execution (2 minutes) */
|
||||
const TEST_COMMAND_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
/** Timeout for AI review (3 minutes) */
|
||||
const AI_REVIEW_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
/** Completion phrase for AI verification pass */
|
||||
const VERIFY_PASS_PHRASE = 'ORCH_VERIFY_PASS';
|
||||
|
||||
/** Completion phrase for AI verification fail */
|
||||
const VERIFY_FAIL_PHRASE = 'ORCH_VERIFY_FAIL';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OrchestratorVerifier
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export class OrchestratorVerifier {
|
||||
private config: OrchestratorConfig;
|
||||
|
||||
constructor(config: OrchestratorConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all verification checks for a completed phase.
|
||||
*
|
||||
* @param phase - The phase to verify
|
||||
* @param session - Session to use for running commands/reviews
|
||||
* @returns Verification result with pass/fail and suggestions
|
||||
*/
|
||||
async verifyPhase(phase: OrchestratorPhase, session: Session): Promise<VerificationResult> {
|
||||
const checks: VerificationCheck[] = [];
|
||||
const mode = this.config.verificationMode;
|
||||
|
||||
// Skip verification entirely in lenient mode with no test commands
|
||||
if (mode === 'lenient' && phase.testCommands.length === 0 && phase.verificationCriteria.length === 0) {
|
||||
return {
|
||||
passed: true,
|
||||
checks: [],
|
||||
summary: 'Verification skipped (lenient mode, no checks defined)',
|
||||
suggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Run test commands if any are defined
|
||||
if (phase.testCommands.length > 0) {
|
||||
const testChecks = await this.runTestCommands(phase.testCommands, session);
|
||||
checks.push(...testChecks);
|
||||
}
|
||||
|
||||
// Run AI review in strict and moderate modes
|
||||
if (mode !== 'lenient' && phase.verificationCriteria.length > 0) {
|
||||
const aiCheck = await this.aiReview(phase, session);
|
||||
checks.push(aiCheck);
|
||||
}
|
||||
|
||||
// Determine pass/fail based on mode
|
||||
const passed = this.evaluateChecks(checks, mode);
|
||||
|
||||
// Generate suggestions for failed checks
|
||||
const suggestions = this.generateSuggestions(checks, phase);
|
||||
|
||||
const passedCount = checks.filter((c) => c.passed).length;
|
||||
const summary =
|
||||
checks.length === 0 ? 'No verification checks defined' : `${passedCount}/${checks.length} checks passed`;
|
||||
|
||||
return { passed, checks, summary, suggestions };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Test Command Execution
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private async runTestCommands(commands: string[], session: Session): Promise<VerificationCheck[]> {
|
||||
const checks: VerificationCheck[] = [];
|
||||
|
||||
for (const command of commands) {
|
||||
try {
|
||||
const check = await this.runSingleTestCommand(command, session);
|
||||
checks.push(check);
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
type: 'test_command',
|
||||
description: `Run: ${command}`,
|
||||
passed: false,
|
||||
output: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
private async runSingleTestCommand(command: string, session: Session): Promise<VerificationCheck> {
|
||||
// Send the test command to the session and wait for completion
|
||||
// We use a unique marker to detect when the command finishes
|
||||
const marker = `ORCH_TEST_${Date.now()}`;
|
||||
const wrappedCommand = `${command} && echo ${marker}_PASS || echo ${marker}_FAIL`;
|
||||
|
||||
const result = await this.sendAndWaitForMarker(session, wrappedCommand, marker, TEST_COMMAND_TIMEOUT_MS);
|
||||
|
||||
return {
|
||||
type: 'test_command',
|
||||
description: `Run: ${command}`,
|
||||
passed: result.includes(`${marker}_PASS`),
|
||||
output: result.slice(0, 2000), // Truncate output
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AI Review
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private async aiReview(phase: OrchestratorPhase, session: Session): Promise<VerificationCheck> {
|
||||
const prompt = this.buildVerificationPrompt(phase);
|
||||
|
||||
try {
|
||||
const result = await this.sendAndWaitForMarker(
|
||||
session,
|
||||
prompt,
|
||||
VERIFY_PASS_PHRASE,
|
||||
AI_REVIEW_TIMEOUT_MS,
|
||||
VERIFY_FAIL_PHRASE
|
||||
);
|
||||
|
||||
const passed = result.includes(VERIFY_PASS_PHRASE);
|
||||
|
||||
return {
|
||||
type: 'ai_review',
|
||||
description: `AI review of "${phase.name}"`,
|
||||
passed,
|
||||
output: result.slice(0, 3000),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
type: 'ai_review',
|
||||
description: `AI review of "${phase.name}"`,
|
||||
passed: false,
|
||||
output: `AI review timed out or failed: ${getErrorMessage(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildVerificationPrompt(phase: OrchestratorPhase): string {
|
||||
const criteria = phase.verificationCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n');
|
||||
|
||||
return [
|
||||
`Review the work done in "${phase.name}". Check these criteria:`,
|
||||
'',
|
||||
criteria,
|
||||
'',
|
||||
`If ALL criteria are met, respond with: ${VERIFY_PASS_PHRASE}`,
|
||||
`If ANY criteria fail, respond with: ${VERIFY_FAIL_PHRASE} and explain what failed.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Evaluation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private evaluateChecks(checks: VerificationCheck[], mode: OrchestratorConfig['verificationMode']): boolean {
|
||||
if (checks.length === 0) return true;
|
||||
|
||||
const testChecks = checks.filter((c) => c.type === 'test_command');
|
||||
const aiChecks = checks.filter((c) => c.type === 'ai_review');
|
||||
|
||||
switch (mode) {
|
||||
case 'strict':
|
||||
// ALL checks must pass
|
||||
return checks.every((c) => c.passed);
|
||||
|
||||
case 'moderate':
|
||||
// All test commands must pass; AI review is advisory
|
||||
return testChecks.length === 0 || testChecks.every((c) => c.passed);
|
||||
|
||||
case 'lenient':
|
||||
// At least one test passes (AI review skipped in lenient mode)
|
||||
return testChecks.length === 0 || testChecks.some((c) => c.passed);
|
||||
|
||||
default:
|
||||
return aiChecks.every((c) => c.passed) && testChecks.every((c) => c.passed);
|
||||
}
|
||||
}
|
||||
|
||||
private generateSuggestions(checks: VerificationCheck[], phase: OrchestratorPhase): string[] {
|
||||
const suggestions: string[] = [];
|
||||
const failedChecks = checks.filter((c) => !c.passed);
|
||||
|
||||
if (failedChecks.length === 0) return suggestions;
|
||||
|
||||
for (const check of failedChecks) {
|
||||
if (check.type === 'test_command') {
|
||||
suggestions.push(`Fix failing test: ${check.description}`);
|
||||
} else if (check.type === 'ai_review' && check.output) {
|
||||
// Extract failure reasons from AI review output
|
||||
suggestions.push(`Address AI review feedback for "${phase.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Communication
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Send a prompt to a session and wait for a marker phrase in the output.
|
||||
*
|
||||
* @param session - Session to send to
|
||||
* @param input - Prompt/command to send
|
||||
* @param marker - Primary marker to watch for
|
||||
* @param timeoutMs - Maximum wait time
|
||||
* @param altMarker - Alternative marker (for pass/fail detection)
|
||||
* @returns Captured output containing the marker
|
||||
*/
|
||||
private sendAndWaitForMarker(
|
||||
session: Session,
|
||||
input: string,
|
||||
marker: string,
|
||||
timeoutMs: number,
|
||||
altMarker?: string
|
||||
): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let output = '';
|
||||
let resolved = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(new Error(`Timeout waiting for marker "${marker}" after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
const handler = (data: string) => {
|
||||
if (resolved) return;
|
||||
output += data;
|
||||
|
||||
if (output.includes(marker) || (altMarker && output.includes(altMarker))) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(output);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
session.off('terminal', handler);
|
||||
};
|
||||
|
||||
session.on('terminal', handler);
|
||||
|
||||
// Send the input
|
||||
session.sendInput(input).catch((err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
||||
import type { PlanItem } from './types.js';
|
||||
import { getErrorMessage, type PlanItem } from './types.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { PlanItem };
|
||||
@@ -231,6 +231,49 @@ export class PlanOrchestrator {
|
||||
return md;
|
||||
}
|
||||
|
||||
private _extractJsonFromResponse(response: string): string | null {
|
||||
let jsonMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
||||
if (jsonMatch) {
|
||||
jsonMatch = [jsonMatch[1]]; // Use captured group (inside code block)
|
||||
} else {
|
||||
jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
}
|
||||
return jsonMatch ? jsonMatch[0] : null;
|
||||
}
|
||||
|
||||
private _emitAgentFailure(
|
||||
onSubagent: SubagentCallback | undefined,
|
||||
agentId: string,
|
||||
agentType: 'research' | 'planner',
|
||||
model: string,
|
||||
error: string,
|
||||
durationMs: number
|
||||
): void {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType,
|
||||
model,
|
||||
status: 'failed',
|
||||
error,
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
private _formatResearchSection(
|
||||
parts: string[],
|
||||
title: string,
|
||||
items: unknown[],
|
||||
formatter: (item: unknown) => string[]
|
||||
): void {
|
||||
if (items.length === 0) return;
|
||||
parts.push(title);
|
||||
for (const item of items.slice(0, 5)) {
|
||||
parts.push(...formatter(item));
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.cancelled = true;
|
||||
// Stop all running sessions and await cleanup to prevent PTY process leaks
|
||||
@@ -312,7 +355,7 @@ export class PlanOrchestrator {
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
error: getErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -322,32 +365,23 @@ export class PlanOrchestrator {
|
||||
|
||||
const parts: string[] = ['## Research Context\n'];
|
||||
|
||||
if (research.findings.externalResources.length > 0) {
|
||||
parts.push('### External Resources');
|
||||
for (const r of research.findings.externalResources.slice(0, 5)) {
|
||||
parts.push(`- ${r.title}${r.url ? ` (${r.url})` : ''}`);
|
||||
if (r.keyInsights.length > 0) {
|
||||
parts.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
this._formatResearchSection(parts, '### External Resources', research.findings.externalResources, (item) => {
|
||||
const r = item as ResearchResult['findings']['externalResources'][number];
|
||||
const lines = [`- ${r.title}${r.url ? ` (${r.url})` : ''}`];
|
||||
if (r.keyInsights.length > 0) {
|
||||
lines.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
|
||||
if (research.findings.codebasePatterns.length > 0) {
|
||||
parts.push('### Existing Codebase Patterns');
|
||||
for (const p of research.findings.codebasePatterns.slice(0, 5)) {
|
||||
parts.push(`- ${p.pattern} at ${p.location}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
this._formatResearchSection(parts, '### Existing Codebase Patterns', research.findings.codebasePatterns, (item) => {
|
||||
const p = item as ResearchResult['findings']['codebasePatterns'][number];
|
||||
return [`- ${p.pattern} at ${p.location}`];
|
||||
});
|
||||
|
||||
if (research.findings.technicalRecommendations.length > 0) {
|
||||
parts.push('### Recommendations');
|
||||
for (const r of research.findings.technicalRecommendations.slice(0, 5)) {
|
||||
parts.push(`- ${r}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
this._formatResearchSection(parts, '### Recommendations', research.findings.technicalRecommendations, (item) => [
|
||||
`- ${item as string}`,
|
||||
]);
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -414,18 +448,20 @@ export class PlanOrchestrator {
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Extract JSON from response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'failed',
|
||||
error: 'No JSON found',
|
||||
durationMs,
|
||||
});
|
||||
console.log(
|
||||
`[PlanOrchestrator] Research response length: ${response.length}, first 500 chars:`,
|
||||
response.substring(0, 500)
|
||||
);
|
||||
|
||||
// Extract JSON from response — try multiple strategies
|
||||
const jsonStr = this._extractJsonFromResponse(response);
|
||||
|
||||
if (!jsonStr) {
|
||||
console.error(
|
||||
`[PlanOrchestrator] No JSON found in research response. Full response:`,
|
||||
response.substring(0, 2000)
|
||||
);
|
||||
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, 'No JSON found', durationMs);
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
@@ -441,17 +477,9 @@ export class PlanOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = tryParseJSON(jsonMatch[0]);
|
||||
const parsed = tryParseJSON(jsonStr);
|
||||
if (!parsed.success) {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'failed',
|
||||
error: parsed.error,
|
||||
durationMs,
|
||||
});
|
||||
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, parsed.error!, durationMs);
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
@@ -495,16 +523,8 @@ export class PlanOrchestrator {
|
||||
return result;
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'failed',
|
||||
error,
|
||||
durationMs,
|
||||
});
|
||||
const error = getErrorMessage(err);
|
||||
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, error, durationMs);
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
@@ -587,32 +607,26 @@ export class PlanOrchestrator {
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Extract JSON from response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'failed',
|
||||
error: 'No JSON found',
|
||||
durationMs,
|
||||
});
|
||||
console.log(
|
||||
`[PlanOrchestrator] Planner response length: ${response.length}, first 500 chars:`,
|
||||
response.substring(0, 500)
|
||||
);
|
||||
|
||||
// Extract JSON from response — try multiple strategies
|
||||
const jsonStr = this._extractJsonFromResponse(response);
|
||||
|
||||
if (!jsonStr) {
|
||||
console.error(
|
||||
`[PlanOrchestrator] No JSON found in planner response. Full response:`,
|
||||
response.substring(0, 2000)
|
||||
);
|
||||
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, 'No JSON found', durationMs);
|
||||
return { success: false, error: 'No JSON in response' };
|
||||
}
|
||||
|
||||
const parsed = tryParseJSON(jsonMatch[0]);
|
||||
const parsed = tryParseJSON(jsonStr);
|
||||
if (!parsed.success) {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'failed',
|
||||
error: parsed.error,
|
||||
durationMs,
|
||||
});
|
||||
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, parsed.error!, durationMs);
|
||||
return { success: false, error: parsed.error };
|
||||
}
|
||||
|
||||
@@ -637,16 +651,8 @@ export class PlanOrchestrator {
|
||||
return { success: true, items, gaps, warnings };
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'failed',
|
||||
error,
|
||||
durationMs,
|
||||
});
|
||||
const error = getErrorMessage(err);
|
||||
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, error, durationMs);
|
||||
return { success: false, error };
|
||||
} finally {
|
||||
// Always clean up session and progress interval — centralizing here
|
||||
|
||||
@@ -7,3 +7,10 @@
|
||||
|
||||
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
|
||||
export { PLANNER_PROMPT } from './planner.js';
|
||||
export {
|
||||
PHASE_EXECUTION_PROMPT,
|
||||
TEAM_LEAD_PROMPT,
|
||||
VERIFICATION_PROMPT,
|
||||
REPLAN_PROMPT,
|
||||
SINGLE_TASK_PROMPT,
|
||||
} from './orchestrator.js';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator Loop prompt templates.
|
||||
*
|
||||
* Templates for phase execution, team delegation, verification, and replanning.
|
||||
* Placeholders use {VARIABLE} syntax and are replaced at runtime.
|
||||
*
|
||||
* @module prompts/orchestrator
|
||||
*/
|
||||
|
||||
/**
|
||||
* Phase execution prompt — tells Claude what to accomplish in this phase.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {PHASE_NUMBER}: Phase index (1-based)
|
||||
* - {PHASE_NAME}: Human-readable phase name
|
||||
* - {GOAL}: Original user goal
|
||||
* - {COMPLETED_PHASES}: Summary of previously completed phases
|
||||
* - {TASK_LIST}: Numbered task list for this phase
|
||||
* - {VERIFICATION_CRITERIA}: What will be checked after this phase
|
||||
* - {COMPLETION_PHRASE}: The phrase to output when done
|
||||
*/
|
||||
export const PHASE_EXECUTION_PROMPT = `You are executing {PHASE_NAME} of a larger project.
|
||||
|
||||
OVERALL GOAL: {GOAL}
|
||||
|
||||
COMPLETED SO FAR:
|
||||
{COMPLETED_PHASES}
|
||||
|
||||
YOUR TASKS FOR THIS PHASE:
|
||||
{TASK_LIST}
|
||||
|
||||
Complete each task thoroughly. Run tests after each change to catch issues early.
|
||||
|
||||
VERIFICATION (will be checked after you finish):
|
||||
{VERIFICATION_CRITERIA}
|
||||
|
||||
When ALL tasks in this phase are complete and verified, output: <promise>{COMPLETION_PHRASE}</promise>`;
|
||||
|
||||
/**
|
||||
* Team lead delegation prompt — instructs a lead to coordinate teammates.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {PHASE_NAME}: Phase name
|
||||
* - {TASK_LIST}: Numbered task list
|
||||
* - {TEAMMATE_HINTS}: Suggested teammate specializations
|
||||
* - {COMPLETION_PHRASE}: Phrase for when all work is done
|
||||
*/
|
||||
export const TEAM_LEAD_PROMPT = `You are the team lead for {PHASE_NAME}.
|
||||
|
||||
Create teammates and delegate the following tasks for parallel execution:
|
||||
|
||||
{TASK_LIST}
|
||||
|
||||
Suggested teammate roles:
|
||||
{TEAMMATE_HINTS}
|
||||
|
||||
Each teammate should focus on their assigned task area. Monitor their progress.
|
||||
When ALL tasks are complete and you've verified the results, output: <promise>{COMPLETION_PHRASE}</promise>`;
|
||||
|
||||
/**
|
||||
* Verification prompt — asks Claude to verify phase completion.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {PHASE_NAME}: Phase name
|
||||
* - {CRITERIA}: Numbered verification criteria
|
||||
* - {PASS_PHRASE}: Phrase to output on success
|
||||
* - {FAIL_PHRASE}: Phrase to output on failure
|
||||
*/
|
||||
export const VERIFICATION_PROMPT = `Review the work done in "{PHASE_NAME}". Check these criteria:
|
||||
|
||||
{CRITERIA}
|
||||
|
||||
If ALL criteria are met, respond with: {PASS_PHRASE}
|
||||
If ANY criteria fail, respond with: {FAIL_PHRASE} and explain what failed.`;
|
||||
|
||||
/**
|
||||
* Replan prompt — gives failure context and asks for recovery.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {PHASE_NAME}: Phase name
|
||||
* - {ATTEMPT_NUMBER}: Current retry attempt
|
||||
* - {MAX_ATTEMPTS}: Maximum attempts allowed
|
||||
* - {FAILURE_SUMMARY}: What went wrong
|
||||
* - {SUGGESTIONS}: Recovery suggestions from verification
|
||||
* - {ORIGINAL_TASKS}: The original task list
|
||||
* - {COMPLETION_PHRASE}: Phrase for when recovery is done
|
||||
*/
|
||||
export const REPLAN_PROMPT = `Phase "{PHASE_NAME}" verification failed (attempt {ATTEMPT_NUMBER}/{MAX_ATTEMPTS}).
|
||||
|
||||
WHAT WENT WRONG:
|
||||
{FAILURE_SUMMARY}
|
||||
|
||||
SUGGESTIONS:
|
||||
{SUGGESTIONS}
|
||||
|
||||
ORIGINAL TASKS:
|
||||
{ORIGINAL_TASKS}
|
||||
|
||||
Fix the issues identified above. Focus on making the verification criteria pass.
|
||||
When the fixes are complete, output: <promise>{COMPLETION_PHRASE}</promise>`;
|
||||
|
||||
/**
|
||||
* Single-task execution prompt — for phases with a single task.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {TASK}: The task description
|
||||
* - {GOAL}: Original user goal
|
||||
* - {CONTEXT}: Any relevant context
|
||||
* - {COMPLETION_PHRASE}: Phrase for when done
|
||||
*/
|
||||
export const SINGLE_TASK_PROMPT = `{TASK}
|
||||
|
||||
Context: This is part of a larger project — {GOAL}
|
||||
{CONTEXT}
|
||||
|
||||
When done, output: <promise>{COMPLETION_PHRASE}</promise>`;
|
||||
@@ -91,6 +91,67 @@ const COMPLETION_INDICATOR_PATTERNS = [
|
||||
/project\s+(?:is\s+)?(?:completed?|done|finished)/i,
|
||||
];
|
||||
|
||||
interface FieldParser<T> {
|
||||
pattern: RegExp;
|
||||
field: keyof RalphStatusBlock;
|
||||
validate: (value: string) => boolean;
|
||||
transform: (value: string) => T;
|
||||
errorMsg: (value: string) => string;
|
||||
}
|
||||
|
||||
const FIELD_PARSERS: FieldParser<RalphStatusValue | RalphTestsStatus | RalphWorkType | number | boolean | string>[] = [
|
||||
{
|
||||
pattern: RALPH_STATUS_FIELD_PATTERN,
|
||||
field: 'status',
|
||||
validate: (v) => ['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(v.toUpperCase()),
|
||||
transform: (v) => v.toUpperCase() as RalphStatusValue,
|
||||
errorMsg: (v) => `Invalid STATUS value: "${v}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_TASKS_COMPLETED_PATTERN,
|
||||
field: 'tasksCompletedThisLoop',
|
||||
validate: (v) => !Number.isNaN(parseInt(v, 10)) && parseInt(v, 10) >= 0,
|
||||
transform: (v) => parseInt(v, 10),
|
||||
errorMsg: (v) => `Invalid TASKS_COMPLETED_THIS_LOOP value: "${v}". Expected: non-negative integer`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_FILES_MODIFIED_PATTERN,
|
||||
field: 'filesModified',
|
||||
validate: (v) => !Number.isNaN(parseInt(v, 10)) && parseInt(v, 10) >= 0,
|
||||
transform: (v) => parseInt(v, 10),
|
||||
errorMsg: (v) => `Invalid FILES_MODIFIED value: "${v}". Expected: non-negative integer`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_TESTS_STATUS_PATTERN,
|
||||
field: 'testsStatus',
|
||||
validate: (v) => ['PASSING', 'FAILING', 'NOT_RUN'].includes(v.toUpperCase()),
|
||||
transform: (v) => v.toUpperCase() as RalphTestsStatus,
|
||||
errorMsg: (v) => `Invalid TESTS_STATUS value: "${v}". Expected: PASSING, FAILING, or NOT_RUN`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_WORK_TYPE_PATTERN,
|
||||
field: 'workType',
|
||||
validate: (v) => ['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(v.toUpperCase()),
|
||||
transform: (v) => v.toUpperCase() as RalphWorkType,
|
||||
errorMsg: (v) =>
|
||||
`Invalid WORK_TYPE value: "${v}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_EXIT_SIGNAL_PATTERN,
|
||||
field: 'exitSignal',
|
||||
validate: () => true,
|
||||
transform: (v) => v.toLowerCase() === 'true',
|
||||
errorMsg: () => '',
|
||||
},
|
||||
{
|
||||
pattern: RALPH_RECOMMENDATION_PATTERN,
|
||||
field: 'recommendation',
|
||||
validate: () => true,
|
||||
transform: (v) => v.trim(),
|
||||
errorMsg: () => '',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* RalphStatusParser - Parses RALPH_STATUS blocks and manages circuit breaker.
|
||||
*
|
||||
@@ -303,85 +364,21 @@ export class RalphStatusParser extends EventEmitter {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
// Track whether this line matched any known field
|
||||
let matched = false;
|
||||
|
||||
// STATUS field (required)
|
||||
const statusMatch = trimmedLine.match(RALPH_STATUS_FIELD_PATTERN);
|
||||
if (statusMatch) {
|
||||
const value = statusMatch[1].toUpperCase();
|
||||
if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) {
|
||||
block.status = value as RalphStatusValue;
|
||||
} else {
|
||||
parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`);
|
||||
for (const parser of FIELD_PARSERS) {
|
||||
const match = trimmedLine.match(parser.pattern);
|
||||
if (match) {
|
||||
const rawValue = match[1];
|
||||
if (parser.validate(rawValue)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(block as any)[parser.field] = parser.transform(rawValue);
|
||||
} else {
|
||||
parseErrors.push(parser.errorMsg(rawValue));
|
||||
}
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// TASKS_COMPLETED_THIS_LOOP field
|
||||
const tasksMatch = trimmedLine.match(RALPH_TASKS_COMPLETED_PATTERN);
|
||||
if (tasksMatch) {
|
||||
const value = parseInt(tasksMatch[1], 10);
|
||||
if (!Number.isNaN(value) && value >= 0) {
|
||||
block.tasksCompletedThisLoop = value;
|
||||
} else {
|
||||
parseErrors.push(
|
||||
`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`
|
||||
);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// FILES_MODIFIED field
|
||||
const filesMatch = trimmedLine.match(RALPH_FILES_MODIFIED_PATTERN);
|
||||
if (filesMatch) {
|
||||
const value = parseInt(filesMatch[1], 10);
|
||||
if (!Number.isNaN(value) && value >= 0) {
|
||||
block.filesModified = value;
|
||||
} else {
|
||||
parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// TESTS_STATUS field
|
||||
const testsMatch = trimmedLine.match(RALPH_TESTS_STATUS_PATTERN);
|
||||
if (testsMatch) {
|
||||
const value = testsMatch[1].toUpperCase();
|
||||
if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) {
|
||||
block.testsStatus = value as RalphTestsStatus;
|
||||
} else {
|
||||
parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// WORK_TYPE field
|
||||
const workMatch = trimmedLine.match(RALPH_WORK_TYPE_PATTERN);
|
||||
if (workMatch) {
|
||||
const value = workMatch[1].toUpperCase();
|
||||
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
|
||||
block.workType = value as RalphWorkType;
|
||||
} else {
|
||||
parseErrors.push(
|
||||
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
|
||||
);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// EXIT_SIGNAL field
|
||||
const exitMatch = trimmedLine.match(RALPH_EXIT_SIGNAL_PATTERN);
|
||||
if (exitMatch) {
|
||||
block.exitSignal = exitMatch[1].toLowerCase() === 'true';
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// RECOMMENDATION field
|
||||
const recMatch = trimmedLine.match(RALPH_RECOMMENDATION_PATTERN);
|
||||
if (recMatch) {
|
||||
block.recommendation = recMatch[1].trim();
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// Track unknown fields for debugging (only if looks like a field)
|
||||
@@ -475,38 +472,9 @@ export class RalphStatusParser extends EventEmitter {
|
||||
const prevState = this._circuitBreaker.state;
|
||||
|
||||
if (hasProgress) {
|
||||
// Progress detected - reset counters, possibly close circuit
|
||||
this._circuitBreaker.consecutiveNoProgress = 0;
|
||||
this._circuitBreaker.consecutiveSameError = 0;
|
||||
this._circuitBreaker.lastProgressIteration = this._cycleCount;
|
||||
|
||||
if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
this._circuitBreaker.state = 'CLOSED';
|
||||
this._circuitBreaker.reason = 'Progress detected, circuit closed';
|
||||
this._circuitBreaker.reasonCode = 'progress_detected';
|
||||
}
|
||||
this._handleProgressDetected();
|
||||
} else {
|
||||
// No progress
|
||||
this._circuitBreaker.consecutiveNoProgress++;
|
||||
|
||||
// State transitions based on consecutive no-progress
|
||||
if (this._circuitBreaker.state === 'CLOSED') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
|
||||
this._circuitBreaker.state = 'HALF_OPEN';
|
||||
this._circuitBreaker.reason = 'Warning: no progress detected';
|
||||
this._circuitBreaker.reasonCode = 'no_progress_warning';
|
||||
}
|
||||
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
}
|
||||
}
|
||||
this._handleNoProgress();
|
||||
}
|
||||
|
||||
// Track tests failure
|
||||
@@ -535,6 +503,40 @@ export class RalphStatusParser extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleProgressDetected(): void {
|
||||
this._circuitBreaker.consecutiveNoProgress = 0;
|
||||
this._circuitBreaker.consecutiveSameError = 0;
|
||||
this._circuitBreaker.lastProgressIteration = this._cycleCount;
|
||||
|
||||
if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
this._circuitBreaker.state = 'CLOSED';
|
||||
this._circuitBreaker.reason = 'Progress detected, circuit closed';
|
||||
this._circuitBreaker.reasonCode = 'progress_detected';
|
||||
}
|
||||
}
|
||||
|
||||
private _handleNoProgress(): void {
|
||||
this._circuitBreaker.consecutiveNoProgress++;
|
||||
|
||||
if (this._circuitBreaker.state === 'CLOSED') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
|
||||
this._circuitBreaker.state = 'HALF_OPEN';
|
||||
this._circuitBreaker.reason = 'Warning: no progress detected';
|
||||
this._circuitBreaker.reasonCode = 'no_progress_warning';
|
||||
}
|
||||
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check line for completion indicators (natural language patterns).
|
||||
* Used for dual-condition exit gate.
|
||||
|
||||
@@ -100,6 +100,18 @@ const TODO_CLEANUP_INTERVAL_MS = INACTIVITY_TIMEOUT_MS;
|
||||
*/
|
||||
const TODO_SIMILARITY_THRESHOLD = 0.85;
|
||||
|
||||
/**
|
||||
* Similarity threshold for short todo content (<30 chars).
|
||||
* Higher threshold reduces false positive deduplication of short strings.
|
||||
*/
|
||||
const SIMILARITY_THRESHOLD_SHORT = 0.95;
|
||||
|
||||
/**
|
||||
* Similarity threshold for medium-length todo content (30-60 chars).
|
||||
* Slightly relaxed compared to short strings.
|
||||
*/
|
||||
const SIMILARITY_THRESHOLD_MEDIUM = 0.9;
|
||||
|
||||
/**
|
||||
* Debounce interval for event emissions (milliseconds).
|
||||
* Prevents UI jitter from rapid consecutive updates.
|
||||
@@ -1299,6 +1311,24 @@ export class RalphTracker extends EventEmitter {
|
||||
this.detectTodoItems(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all tracked todos as completed and emit todoUpdate if any changed.
|
||||
* @returns true if any todo was updated
|
||||
*/
|
||||
private completeAllTodos(): boolean {
|
||||
let updated = false;
|
||||
for (const todo of this._todos.values()) {
|
||||
if (todo.status !== 'completed') {
|
||||
todo.status = 'completed';
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
this.emit('todoUpdate', this.todos);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect "all tasks complete" messages.
|
||||
*/
|
||||
@@ -1318,16 +1348,7 @@ export class RalphTracker extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
for (const todo of this._todos.values()) {
|
||||
if (todo.status !== 'completed') {
|
||||
todo.status = 'completed';
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
this.emit('todoUpdate', this.todos);
|
||||
}
|
||||
this.completeAllTodos();
|
||||
|
||||
if (this._loopState.completionPhrase) {
|
||||
this._loopState.active = false;
|
||||
@@ -1425,16 +1446,7 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
if (bareCount > 1) return;
|
||||
|
||||
let updated = false;
|
||||
for (const todo of this._todos.values()) {
|
||||
if (todo.status !== 'completed') {
|
||||
todo.status = 'completed';
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
this.emit('todoUpdate', this.todos);
|
||||
}
|
||||
this.completeAllTodos();
|
||||
|
||||
this._loopState.active = false;
|
||||
this._loopState.lastActivity = Date.now();
|
||||
@@ -1480,16 +1492,7 @@ export class RalphTracker extends EventEmitter {
|
||||
if (canonicalCount >= 2 || this._loopState.active) {
|
||||
this._loopState.active = false;
|
||||
this._loopState.lastActivity = Date.now();
|
||||
let updated = false;
|
||||
for (const todo of this._todos.values()) {
|
||||
if (todo.status !== 'completed') {
|
||||
todo.status = 'completed';
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
this.emit('todoUpdate', this.todos);
|
||||
}
|
||||
this.completeAllTodos();
|
||||
this.emit('completionDetected', matchedPhrase);
|
||||
this.emit('loopUpdate', this.loopState);
|
||||
return;
|
||||
@@ -1497,16 +1500,7 @@ export class RalphTracker extends EventEmitter {
|
||||
}
|
||||
|
||||
if (this._loopState.active || count >= 2) {
|
||||
let updated = false;
|
||||
for (const todo of this._todos.values()) {
|
||||
if (todo.status !== 'completed') {
|
||||
todo.status = 'completed';
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
this.emit('todoUpdate', this.todos);
|
||||
}
|
||||
this.completeAllTodos();
|
||||
|
||||
this._loopState.active = false;
|
||||
this._loopState.lastActivity = Date.now();
|
||||
@@ -1532,41 +1526,39 @@ export class RalphTracker extends EventEmitter {
|
||||
const suggestedPhrase = `${phrase}_${uniqueSuffix}`;
|
||||
|
||||
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
|
||||
console.warn(
|
||||
`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`
|
||||
);
|
||||
this.emit('phraseValidationWarning', {
|
||||
phrase,
|
||||
reason: 'common',
|
||||
suggestedPhrase,
|
||||
});
|
||||
this.emitValidationWarning(phrase, 'common', suggestedPhrase);
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
|
||||
console.warn(
|
||||
`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`
|
||||
);
|
||||
this.emit('phraseValidationWarning', {
|
||||
phrase,
|
||||
reason: 'short',
|
||||
suggestedPhrase,
|
||||
});
|
||||
this.emitValidationWarning(phrase, 'short', suggestedPhrase);
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
console.warn(
|
||||
`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`
|
||||
);
|
||||
this.emit('phraseValidationWarning', {
|
||||
phrase,
|
||||
reason: 'numeric',
|
||||
suggestedPhrase,
|
||||
});
|
||||
this.emitValidationWarning(phrase, 'numeric', suggestedPhrase);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a phrase validation warning with a console message and event.
|
||||
*/
|
||||
private emitValidationWarning(phrase: string, reason: 'common' | 'short' | 'numeric', suggestedPhrase: string): void {
|
||||
const descriptions: Record<'common' | 'short' | 'numeric', string> = {
|
||||
common: 'is very common and may cause false positives',
|
||||
short: `is too short (${phrase.toUpperCase().replace(/[\s_\-.]+/g, '').length} chars)`,
|
||||
numeric: 'is numeric-only and may cause false positives',
|
||||
};
|
||||
console.warn(
|
||||
`[RalphTracker] Warning: Completion phrase "${phrase}" ${descriptions[reason]}. Consider using: "${suggestedPhrase}"`
|
||||
);
|
||||
this.emit('phraseValidationWarning', {
|
||||
phrase,
|
||||
reason,
|
||||
suggestedPhrase,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the loop if not already active.
|
||||
*/
|
||||
@@ -1977,9 +1969,9 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
let threshold: number;
|
||||
if (normalized.length < 30) {
|
||||
threshold = 0.95;
|
||||
threshold = SIMILARITY_THRESHOLD_SHORT;
|
||||
} else if (normalized.length < 60) {
|
||||
threshold = 0.9;
|
||||
threshold = SIMILARITY_THRESHOLD_MEDIUM;
|
||||
} else {
|
||||
threshold = TODO_SIMILARITY_THRESHOLD;
|
||||
}
|
||||
|
||||
@@ -70,12 +70,13 @@ import {
|
||||
AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
} from './config/ai-defaults.js';
|
||||
import type {
|
||||
RespawnCycleMetrics,
|
||||
RespawnAggregateMetrics,
|
||||
RalphLoopHealthScore,
|
||||
TimingHistory,
|
||||
CycleOutcome,
|
||||
import {
|
||||
getErrorMessage,
|
||||
type RespawnCycleMetrics,
|
||||
type RespawnAggregateMetrics,
|
||||
type RalphLoopHealthScore,
|
||||
type TimingHistory,
|
||||
type CycleOutcome,
|
||||
} from './types.js';
|
||||
|
||||
// ========== Constants ==========
|
||||
@@ -551,6 +552,14 @@ export interface RespawnEvents {
|
||||
respawnBlocked: (data: { reason: string; details: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert milliseconds to a non-negative whole number of seconds for countdown display.
|
||||
* Rounds up so that e.g. 1200 ms shows as 2 s (never under-reports remaining time).
|
||||
*/
|
||||
function formatRemainingSeconds(ms: number): number {
|
||||
return Math.max(0, Math.ceil(ms / 1000));
|
||||
}
|
||||
|
||||
/** Default configuration values */
|
||||
const DEFAULT_CONFIG: RespawnConfig = {
|
||||
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
||||
@@ -839,27 +848,42 @@ export class RespawnController extends EventEmitter {
|
||||
private validateConfig(): void {
|
||||
const c = this.config;
|
||||
|
||||
// Ensure timeouts are positive
|
||||
if (c.idleTimeoutMs <= 0) c.idleTimeoutMs = DEFAULT_CONFIG.idleTimeoutMs;
|
||||
if (c.completionConfirmMs <= 0) c.completionConfirmMs = DEFAULT_CONFIG.completionConfirmMs;
|
||||
if (c.noOutputTimeoutMs <= 0) c.noOutputTimeoutMs = DEFAULT_CONFIG.noOutputTimeoutMs;
|
||||
if (c.autoAcceptDelayMs < 0) c.autoAcceptDelayMs = DEFAULT_CONFIG.autoAcceptDelayMs;
|
||||
if (c.interStepDelayMs <= 0) c.interStepDelayMs = DEFAULT_CONFIG.interStepDelayMs;
|
||||
/**
|
||||
* Validate that a timeout value is positive (or non-negative when allowZero is true).
|
||||
* Falls back to the DEFAULT_CONFIG value if invalid.
|
||||
*/
|
||||
const validatePositiveTimeout = (field: keyof RespawnConfig, allowZero = false): void => {
|
||||
const value = c[field] as number;
|
||||
const invalid = allowZero ? value < 0 : value <= 0;
|
||||
if (invalid) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(c as any)[field] = DEFAULT_CONFIG[field];
|
||||
}
|
||||
};
|
||||
|
||||
const REQUIRED_TIMEOUT_FIELDS = [
|
||||
'idleTimeoutMs',
|
||||
'completionConfirmMs',
|
||||
'noOutputTimeoutMs',
|
||||
'interStepDelayMs',
|
||||
'aiIdleCheckTimeoutMs',
|
||||
'aiIdleCheckMaxContext',
|
||||
'aiPlanCheckTimeoutMs',
|
||||
'aiPlanCheckMaxContext',
|
||||
] as const;
|
||||
for (const field of REQUIRED_TIMEOUT_FIELDS) {
|
||||
validatePositiveTimeout(field);
|
||||
}
|
||||
|
||||
const ALLOW_ZERO_FIELDS = ['autoAcceptDelayMs', 'aiIdleCheckCooldownMs', 'aiPlanCheckCooldownMs'] as const;
|
||||
for (const field of ALLOW_ZERO_FIELDS) {
|
||||
validatePositiveTimeout(field, true);
|
||||
}
|
||||
|
||||
// Ensure completion confirm doesn't exceed no-output timeout
|
||||
if (c.completionConfirmMs > c.noOutputTimeoutMs) {
|
||||
c.completionConfirmMs = c.noOutputTimeoutMs;
|
||||
}
|
||||
|
||||
// Ensure AI check timeouts are positive
|
||||
if (c.aiIdleCheckTimeoutMs <= 0) c.aiIdleCheckTimeoutMs = DEFAULT_CONFIG.aiIdleCheckTimeoutMs;
|
||||
if (c.aiIdleCheckCooldownMs < 0) c.aiIdleCheckCooldownMs = DEFAULT_CONFIG.aiIdleCheckCooldownMs;
|
||||
if (c.aiIdleCheckMaxContext <= 0) c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext;
|
||||
|
||||
// Ensure plan check timeouts are positive
|
||||
if (c.aiPlanCheckTimeoutMs <= 0) c.aiPlanCheckTimeoutMs = DEFAULT_CONFIG.aiPlanCheckTimeoutMs;
|
||||
if (c.aiPlanCheckCooldownMs < 0) c.aiPlanCheckCooldownMs = DEFAULT_CONFIG.aiPlanCheckCooldownMs;
|
||||
if (c.aiPlanCheckMaxContext <= 0) c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext;
|
||||
}
|
||||
|
||||
/** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */
|
||||
@@ -987,11 +1011,11 @@ export class RespawnController extends EventEmitter {
|
||||
waitingFor = 'AI verdict (IDLE or WORKING)';
|
||||
} else if (this._state === 'confirming_idle') {
|
||||
statusText = `Confirming idle (${confidence}% confidence)`;
|
||||
waitingFor = `${Math.max(0, Math.ceil((this.config.completionConfirmMs - msSinceLastOutput) / 1000))}s more silence`;
|
||||
waitingFor = `${formatRemainingSeconds(this.config.completionConfirmMs - msSinceLastOutput)}s more silence`;
|
||||
} else if (this._state === 'watching') {
|
||||
const aiState = this.aiChecker.getState();
|
||||
if (aiState.status === 'cooldown') {
|
||||
const remaining = Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000);
|
||||
const remaining = formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs());
|
||||
statusText = `AI Check: WORKING (cooldown ${remaining}s)`;
|
||||
waitingFor = 'Cooldown to expire';
|
||||
} else if (completionMessageDetected) {
|
||||
@@ -1364,98 +1388,13 @@ export class RespawnController extends EventEmitter {
|
||||
this.lastTokenChangeTime = now;
|
||||
}
|
||||
|
||||
// Detect completion message FIRST (Layer 1) - PRIMARY DETECTION
|
||||
// Check this before working patterns because completion message indicates
|
||||
// the work is done, even if working patterns are still in the rolling window
|
||||
if (isCompletionMessage(data)) {
|
||||
// Clear the rolling window - completion marks a transition point
|
||||
this.clearWorkingPatternWindow();
|
||||
this.workingDetected = false;
|
||||
this.completionMessageTime = now;
|
||||
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
|
||||
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
|
||||
// Layer 1: Completion message (PRIMARY) — checked before working patterns
|
||||
if (this._detectCompletionMessage(data, now)) return;
|
||||
|
||||
// In watching state, start completion confirmation timer
|
||||
if (this._state === 'watching') {
|
||||
this.startCompletionConfirmTimer();
|
||||
return;
|
||||
}
|
||||
// Layer 4: Working patterns
|
||||
if (this._detectWorkingPattern(data, now)) return;
|
||||
|
||||
// In waiting states, also use confirmation timer (same detection logic)
|
||||
// This ensures we wait for Claude to finish before proceeding
|
||||
// Note: 'watching' is already handled above and returns early
|
||||
switch (this._state) {
|
||||
case 'waiting_update':
|
||||
this.startStepConfirmTimer('update');
|
||||
break;
|
||||
case 'waiting_clear':
|
||||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||||
break;
|
||||
case 'waiting_init':
|
||||
this.startStepConfirmTimer('init');
|
||||
break;
|
||||
case 'waiting_kickstart':
|
||||
this.startStepConfirmTimer('kickstart');
|
||||
break;
|
||||
// Non-waiting states: completion message is ignored
|
||||
case 'confirming_idle':
|
||||
case 'ai_checking':
|
||||
case 'sending_update':
|
||||
case 'sending_clear':
|
||||
case 'sending_init':
|
||||
case 'monitoring_init':
|
||||
case 'sending_kickstart':
|
||||
case 'stopped':
|
||||
// Completion message during these states is ignored
|
||||
break;
|
||||
default:
|
||||
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect working patterns (Layer 4)
|
||||
const isWorking = this.checkWorkingPattern(data);
|
||||
if (isWorking) {
|
||||
this.workingDetected = true;
|
||||
this.promptDetected = false;
|
||||
this.elicitationDetected = false; // Clear on new work cycle
|
||||
this.resetHookState(); // Clear hook signals on new work
|
||||
this.lastWorkingPatternTime = now;
|
||||
|
||||
// Cancel hook confirmation timer if running
|
||||
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
|
||||
|
||||
// Cancel any pending completion confirmation
|
||||
this.cancelCompletionConfirm();
|
||||
|
||||
// Cancel any pending step confirmation (Claude is still working)
|
||||
this.cancelStepConfirm();
|
||||
|
||||
// If AI check is running, cancel it (Claude is working)
|
||||
if (this._state === 'ai_checking') {
|
||||
this.log('Working patterns detected during AI check, cancelling');
|
||||
this.aiChecker.cancel();
|
||||
this.setState('watching');
|
||||
}
|
||||
|
||||
// Cancel plan check if running (Claude started working)
|
||||
if (this.planChecker.status === 'checking') {
|
||||
this.log('Working patterns detected during plan check, cancelling');
|
||||
this.planChecker.cancel();
|
||||
}
|
||||
|
||||
// If we're monitoring init and work started, go to watching (no kickstart needed)
|
||||
if (this._state === 'monitoring_init') {
|
||||
this.log('/init triggered work, skipping kickstart');
|
||||
this.emit('stepCompleted', 'init');
|
||||
this.completeCycle();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In confirming_idle or ai_checking state, substantial output cancels the flow.
|
||||
// This prevents false triggers when Claude pauses briefly mid-work.
|
||||
// Substantial output during confirming_idle/ai_checking cancels the flow
|
||||
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
|
||||
// Strip ANSI escape codes to check if there's real content
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0;
|
||||
@@ -1476,43 +1415,137 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
|
||||
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
|
||||
if (hasPrompt) {
|
||||
this.promptDetected = true;
|
||||
this.workingDetected = false;
|
||||
// Legacy fallback: prompt detection
|
||||
this._detectPrompt(data);
|
||||
}
|
||||
|
||||
// Handle legacy detection in waiting states - also use confirmation timers
|
||||
switch (this._state) {
|
||||
case 'waiting_update':
|
||||
this.startStepConfirmTimer('update');
|
||||
break;
|
||||
case 'waiting_clear':
|
||||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||||
break;
|
||||
case 'waiting_init':
|
||||
this.startStepConfirmTimer('init');
|
||||
break;
|
||||
case 'monitoring_init':
|
||||
this.checkMonitoringInitIdle();
|
||||
break;
|
||||
case 'waiting_kickstart':
|
||||
this.startStepConfirmTimer('kickstart');
|
||||
break;
|
||||
// Non-waiting states: prompt detection is informational only
|
||||
case 'watching':
|
||||
case 'confirming_idle':
|
||||
case 'ai_checking':
|
||||
case 'sending_update':
|
||||
case 'sending_clear':
|
||||
case 'sending_init':
|
||||
case 'sending_kickstart':
|
||||
case 'stopped':
|
||||
// Prompt detection during these states doesn't trigger action
|
||||
break;
|
||||
default:
|
||||
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
|
||||
}
|
||||
private _detectCompletionMessage(data: string, now: number): boolean {
|
||||
if (!isCompletionMessage(data)) return false;
|
||||
|
||||
// Clear the rolling window - completion marks a transition point
|
||||
this.clearWorkingPatternWindow();
|
||||
this.workingDetected = false;
|
||||
this.completionMessageTime = now;
|
||||
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
|
||||
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
|
||||
|
||||
// In watching state, start completion confirmation timer
|
||||
if (this._state === 'watching') {
|
||||
this.startCompletionConfirmTimer();
|
||||
return true;
|
||||
}
|
||||
|
||||
// In waiting states, also use confirmation timer (same detection logic)
|
||||
// This ensures we wait for Claude to finish before proceeding
|
||||
// Note: 'watching' is already handled above and returns early
|
||||
switch (this._state) {
|
||||
case 'waiting_update':
|
||||
this.startStepConfirmTimer('update');
|
||||
break;
|
||||
case 'waiting_clear':
|
||||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||||
break;
|
||||
case 'waiting_init':
|
||||
this.startStepConfirmTimer('init');
|
||||
break;
|
||||
case 'waiting_kickstart':
|
||||
this.startStepConfirmTimer('kickstart');
|
||||
break;
|
||||
// Non-waiting states: completion message is ignored
|
||||
case 'confirming_idle':
|
||||
case 'ai_checking':
|
||||
case 'sending_update':
|
||||
case 'sending_clear':
|
||||
case 'sending_init':
|
||||
case 'monitoring_init':
|
||||
case 'sending_kickstart':
|
||||
case 'stopped':
|
||||
// Completion message during these states is ignored
|
||||
break;
|
||||
default:
|
||||
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _detectWorkingPattern(data: string, now: number): boolean {
|
||||
const isWorking = this.checkWorkingPattern(data);
|
||||
if (!isWorking) return false;
|
||||
|
||||
this.workingDetected = true;
|
||||
this.promptDetected = false;
|
||||
this.elicitationDetected = false; // Clear on new work cycle
|
||||
this.resetHookState(); // Clear hook signals on new work
|
||||
this.lastWorkingPatternTime = now;
|
||||
|
||||
// Cancel hook confirmation timer if running
|
||||
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
|
||||
|
||||
// Cancel any pending completion confirmation
|
||||
this.cancelCompletionConfirm();
|
||||
|
||||
// Cancel any pending step confirmation (Claude is still working)
|
||||
this.cancelStepConfirm();
|
||||
|
||||
// If AI check is running, cancel it (Claude is working)
|
||||
if (this._state === 'ai_checking') {
|
||||
this.log('Working patterns detected during AI check, cancelling');
|
||||
this.aiChecker.cancel();
|
||||
this.setState('watching');
|
||||
}
|
||||
|
||||
// Cancel plan check if running (Claude started working)
|
||||
if (this.planChecker.status === 'checking') {
|
||||
this.log('Working patterns detected during plan check, cancelling');
|
||||
this.planChecker.cancel();
|
||||
}
|
||||
|
||||
// If we're monitoring init and work started, go to watching (no kickstart needed)
|
||||
if (this._state === 'monitoring_init') {
|
||||
this.log('/init triggered work, skipping kickstart');
|
||||
this.emit('stepCompleted', 'init');
|
||||
this.completeCycle();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _detectPrompt(data: string): void {
|
||||
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
|
||||
if (!hasPrompt) return;
|
||||
|
||||
this.promptDetected = true;
|
||||
this.workingDetected = false;
|
||||
|
||||
// Handle legacy detection in waiting states - also use confirmation timers
|
||||
switch (this._state) {
|
||||
case 'waiting_update':
|
||||
this.startStepConfirmTimer('update');
|
||||
break;
|
||||
case 'waiting_clear':
|
||||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||||
break;
|
||||
case 'waiting_init':
|
||||
this.startStepConfirmTimer('init');
|
||||
break;
|
||||
case 'monitoring_init':
|
||||
this.checkMonitoringInitIdle();
|
||||
break;
|
||||
case 'waiting_kickstart':
|
||||
this.startStepConfirmTimer('kickstart');
|
||||
break;
|
||||
// Non-waiting states: prompt detection is informational only
|
||||
case 'watching':
|
||||
case 'confirming_idle':
|
||||
case 'ai_checking':
|
||||
case 'sending_update':
|
||||
case 'sending_clear':
|
||||
case 'sending_init':
|
||||
case 'sending_kickstart':
|
||||
case 'stopped':
|
||||
// Prompt detection during these states doesn't trigger action
|
||||
break;
|
||||
default:
|
||||
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1798,24 +1831,28 @@ export class RespawnController extends EventEmitter {
|
||||
case 'sending_init':
|
||||
case 'sending_kickstart':
|
||||
// For sending states, retry the send
|
||||
this.log('Recovery: returning to watching state');
|
||||
this.setState('watching');
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
if (this.config.autoAcceptPrompts) {
|
||||
this.startAutoAcceptTimer();
|
||||
}
|
||||
this.recoveryResetToWatching('returning to watching state');
|
||||
break;
|
||||
|
||||
default:
|
||||
// Fallback: reset to watching
|
||||
this.log('Recovery: fallback to watching state');
|
||||
this.setState('watching');
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
if (this.config.autoAcceptPrompts) {
|
||||
this.startAutoAcceptTimer();
|
||||
}
|
||||
this.recoveryResetToWatching('fallback to watching state');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the controller to watching state during stuck-state recovery.
|
||||
* Sets state to watching and restarts all detection timers.
|
||||
*
|
||||
* @param reason - Human-readable reason for the reset (logged)
|
||||
*/
|
||||
private recoveryResetToWatching(reason: string): void {
|
||||
this.log(`Recovery: ${reason}`);
|
||||
this.setState('watching');
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
if (this.config.autoAcceptPrompts) {
|
||||
this.startAutoAcceptTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2041,6 +2078,18 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for active child processes (bash tools, test suites, builds, etc.)
|
||||
// These may produce no terminal output, so restart timers to retry periodically.
|
||||
const activeProcesses = this.session.getActiveChildProcesses();
|
||||
if (activeProcesses.length > 0) {
|
||||
const names = activeProcesses.map((p) => p.command).join(', ');
|
||||
this.log(`Skipping AI check - ${activeProcesses.length} active child process(es): ${names}`);
|
||||
this.logAction('detection', `Skipped AI check: child processes running (${names})`);
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// If AI check is disabled or errored out, fall back to direct idle confirmation
|
||||
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
||||
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
|
||||
@@ -2051,7 +2100,7 @@ export class RespawnController extends EventEmitter {
|
||||
// If on cooldown, don't start check - wait for cooldown to expire
|
||||
if (this.aiChecker.isOnCooldown()) {
|
||||
this.log(
|
||||
`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`
|
||||
`AI check on cooldown (${formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs())}s remaining), waiting...`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2136,7 +2185,7 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
if (this._state === 'stopped') return; // Guard against stopped state
|
||||
if (this._state === 'ai_checking') {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = getErrorMessage(err);
|
||||
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||
this.emit('aiCheckFailed', errorMsg);
|
||||
this.setState('watching');
|
||||
@@ -2198,36 +2247,15 @@ export class RespawnController extends EventEmitter {
|
||||
* @fires planCheckStarted
|
||||
*/
|
||||
private tryAutoAccept(): void {
|
||||
// Only auto-accept in watching state (not during a respawn cycle)
|
||||
if (this._state !== 'watching') return;
|
||||
if (!this.canAutoAccept()) return;
|
||||
|
||||
// Don't auto-accept if a completion message was detected (normal idle handles it)
|
||||
if (this.completionMessageTime !== null) return;
|
||||
|
||||
// Don't auto-accept if disabled
|
||||
if (!this.config.autoAcceptPrompts) return;
|
||||
|
||||
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
|
||||
if (!this.hasReceivedOutput) return;
|
||||
|
||||
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
|
||||
if (this.elicitationDetected) {
|
||||
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 1: Pre-filter — check if buffer looks like plan mode
|
||||
const buffer = this.terminalBuffer.value;
|
||||
if (!this.isPlanModePreFilterMatch(buffer)) {
|
||||
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 2: AI confirmation (if enabled and available)
|
||||
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
|
||||
if (this.planChecker.isOnCooldown()) {
|
||||
this.log(
|
||||
`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`
|
||||
`Skipping auto-accept: plan checker on cooldown (${formatRemainingSeconds(this.planChecker.getCooldownRemainingMs())}s remaining)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2244,6 +2272,40 @@ export class RespawnController extends EventEmitter {
|
||||
this.sendAutoAcceptEnter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether all preconditions for auto-accept are met.
|
||||
* Validates state, config, and pre-filter conditions before attempting auto-accept.
|
||||
*
|
||||
* @returns True if auto-accept should proceed to the AI confirmation stage
|
||||
*/
|
||||
private canAutoAccept(): boolean {
|
||||
// Only auto-accept in watching state (not during a respawn cycle)
|
||||
if (this._state !== 'watching') return false;
|
||||
|
||||
// Don't auto-accept if a completion message was detected (normal idle handles it)
|
||||
if (this.completionMessageTime !== null) return false;
|
||||
|
||||
// Don't auto-accept if disabled
|
||||
if (!this.config.autoAcceptPrompts) return false;
|
||||
|
||||
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
|
||||
if (!this.hasReceivedOutput) return false;
|
||||
|
||||
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
|
||||
if (this.elicitationDetected) {
|
||||
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stage 1: Pre-filter — check if buffer looks like plan mode
|
||||
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
|
||||
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the terminal buffer matches plan mode pre-filter patterns.
|
||||
* Only checks the last 2000 chars (plan mode UI appears at the bottom).
|
||||
@@ -2322,7 +2384,7 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = getErrorMessage(err);
|
||||
this.emit('planCheckFailed', errorMsg);
|
||||
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||
});
|
||||
@@ -2632,6 +2694,18 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety check: if child processes are running (bash tools, test suites, builds, etc.)
|
||||
const activeProcesses = this.session.getActiveChildProcesses();
|
||||
if (activeProcesses.length > 0) {
|
||||
const names = activeProcesses.map((p) => p.command).join(', ');
|
||||
this.log(`Idle confirmation rejected - ${activeProcesses.length} active child process(es): ${names}`);
|
||||
this.logAction('detection', `Rejected: child processes running (${names})`);
|
||||
this.setState('watching');
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`Idle confirmed via: ${reason}`);
|
||||
const status = this.getDetectionStatus();
|
||||
this.log(
|
||||
|
||||
@@ -27,6 +27,57 @@ const COMPACT_COOLDOWN_MS = 10000;
|
||||
/** Cooldown after clear completes before re-enabling (5 seconds) */
|
||||
const CLEAR_COOLDOWN_MS = 5000;
|
||||
|
||||
/**
|
||||
* Executes an action when the session becomes idle, retrying if currently working.
|
||||
*
|
||||
* @param action - The async action to execute once idle
|
||||
* @param isActive - Returns whether this operation is still active (not cancelled)
|
||||
* @param isWorking - Returns whether the session is currently working
|
||||
* @param isStopped - Returns whether the session has been stopped
|
||||
* @param retryMs - Delay between retry attempts when working
|
||||
* @param cooldownMs - Delay after action completes before calling onCooldownDone
|
||||
* @param setTimer - Stores the timer reference for cleanup
|
||||
* @param onCooldownDone - Called after cooldown to reset state
|
||||
*/
|
||||
async function executeWhenIdle(
|
||||
action: () => Promise<void>,
|
||||
isActive: () => boolean,
|
||||
isWorking: () => boolean,
|
||||
isStopped: () => boolean,
|
||||
retryMs: number,
|
||||
cooldownMs: number,
|
||||
setTimer: (timer: NodeJS.Timeout | null) => void,
|
||||
onCooldownDone: () => void
|
||||
): Promise<void> {
|
||||
if (isStopped()) return;
|
||||
if (!isActive()) return;
|
||||
|
||||
if (!isWorking()) {
|
||||
if (isStopped()) return;
|
||||
|
||||
await action();
|
||||
|
||||
if (!isStopped()) {
|
||||
setTimer(
|
||||
setTimeout(() => {
|
||||
if (isStopped()) return;
|
||||
setTimer(null);
|
||||
onCooldownDone();
|
||||
}, cooldownMs)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!isStopped()) {
|
||||
setTimer(
|
||||
setTimeout(
|
||||
() => executeWhenIdle(action, isActive, isWorking, isStopped, retryMs, cooldownMs, setTimer, onCooldownDone),
|
||||
retryMs
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
|
||||
const MIN_AUTO_THRESHOLD = 1000;
|
||||
|
||||
@@ -181,37 +232,35 @@ export class SessionAutoOps extends EventEmitter {
|
||||
`[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`
|
||||
);
|
||||
|
||||
const checkAndCompact = async () => {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
if (!this._isCompacting) return;
|
||||
|
||||
if (!this.callbacks.isWorking()) {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
|
||||
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||
await this.callbacks.writeCommand(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
threshold: this._autoCompactThreshold,
|
||||
prompt: this._autoCompactPrompt || undefined,
|
||||
});
|
||||
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoCompactTimer = setTimeout(() => {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
this._autoCompactTimer = null;
|
||||
this._isCompacting = false;
|
||||
}, COMPACT_COOLDOWN_MS);
|
||||
}
|
||||
} else {
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
const action = async () => {
|
||||
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||
await this.callbacks.writeCommand(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
threshold: this._autoCompactThreshold,
|
||||
prompt: this._autoCompactPrompt || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS);
|
||||
this._autoCompactTimer = setTimeout(
|
||||
() =>
|
||||
executeWhenIdle(
|
||||
action,
|
||||
() => this._isCompacting,
|
||||
() => this.callbacks.isWorking(),
|
||||
() => this.callbacks.isStopped(),
|
||||
AUTO_RETRY_DELAY_MS,
|
||||
COMPACT_COOLDOWN_MS,
|
||||
(timer) => {
|
||||
this._autoCompactTimer = timer;
|
||||
},
|
||||
() => {
|
||||
this._isCompacting = false;
|
||||
}
|
||||
),
|
||||
AUTO_INITIAL_DELAY_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,32 +280,30 @@ export class SessionAutoOps extends EventEmitter {
|
||||
`[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`
|
||||
);
|
||||
|
||||
const checkAndClear = async () => {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
if (!this._isClearing) return;
|
||||
|
||||
if (!this.callbacks.isWorking()) {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
|
||||
await this.callbacks.writeCommand('/clear\r');
|
||||
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
||||
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoClearTimer = setTimeout(() => {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
this._autoClearTimer = null;
|
||||
this._isClearing = false;
|
||||
}, CLEAR_COOLDOWN_MS);
|
||||
}
|
||||
} else {
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
const action = async () => {
|
||||
await this.callbacks.writeCommand('/clear\r');
|
||||
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
||||
};
|
||||
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS);
|
||||
this._autoClearTimer = setTimeout(
|
||||
() =>
|
||||
executeWhenIdle(
|
||||
action,
|
||||
() => this._isClearing,
|
||||
() => this.callbacks.isWorking(),
|
||||
() => this.callbacks.isStopped(),
|
||||
AUTO_RETRY_DELAY_MS,
|
||||
CLEAR_COOLDOWN_MS,
|
||||
(timer) => {
|
||||
this._autoClearTimer = timer;
|
||||
},
|
||||
() => {
|
||||
this._isClearing = false;
|
||||
}
|
||||
),
|
||||
AUTO_INITIAL_DELAY_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
import {
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
ActiveBashTool,
|
||||
NiceConfig,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
getErrorMessage,
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
MAX_MESSAGES,
|
||||
MAX_LINE_BUFFER_SIZE,
|
||||
} from './config/buffer-limits.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import {
|
||||
buildInteractiveArgs,
|
||||
buildPromptArgs,
|
||||
@@ -271,6 +274,7 @@ export class Session extends EventEmitter {
|
||||
private _lastPromptTime: number = 0;
|
||||
private activityTimeout: NodeJS.Timeout | null = null;
|
||||
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
|
||||
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
|
||||
private _taskTracker: TaskTracker;
|
||||
|
||||
// Token tracking for auto-clear
|
||||
@@ -526,6 +530,11 @@ export class Session extends EventEmitter {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
/** The tmux session name, if the session is running inside a mux */
|
||||
get muxName(): string | null {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this._totalCost;
|
||||
}
|
||||
@@ -538,6 +547,49 @@ export class Session extends EventEmitter {
|
||||
return this._isWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||
*
|
||||
* The tmux pane PID is typically "claude" directly (bash exec'd into it). When Claude
|
||||
* runs a bash tool, it spawns child processes: claude → bash → npm/node/python/etc.
|
||||
* We check direct children of the pane PID, filtering out "claude" itself (for the rare
|
||||
* case where bash wraps claude and didn't exec).
|
||||
*
|
||||
* Returns an array of {pid, command} for each child process, or empty array if none.
|
||||
* Returns empty array if no mux session or on error (fail-open to avoid blocking respawn).
|
||||
*/
|
||||
getActiveChildProcesses(): { pid: number; command: string }[] {
|
||||
if (!this._muxSession) return [];
|
||||
|
||||
try {
|
||||
const panePid = this._muxSession.pid;
|
||||
|
||||
// Single call: get direct children with their command names
|
||||
const output = execSync(`ps -o pid=,comm= --ppid ${panePid} 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (!output) return [];
|
||||
|
||||
const activeProcesses: { pid: number; command: string }[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(.+)/);
|
||||
if (!match) continue;
|
||||
const pid = parseInt(match[1], 10);
|
||||
const command = match[2].trim();
|
||||
// Skip the claude process itself (pane_pid may be bash wrapping claude)
|
||||
if (command === 'claude') continue;
|
||||
activeProcesses.push({ pid, command });
|
||||
}
|
||||
|
||||
return activeProcesses;
|
||||
} catch {
|
||||
// ps returns exit code 1 when no matches — normal (no children)
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
get lastPromptTime(): number {
|
||||
return this._lastPromptTime;
|
||||
}
|
||||
@@ -871,18 +923,77 @@ export class Session extends EventEmitter {
|
||||
* session.write('help me with this code\r');
|
||||
* ```
|
||||
*/
|
||||
private async _setupOrAttachMuxSession(options: {
|
||||
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
||||
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
||||
spawnErrLabel: string;
|
||||
}): Promise<{ isRestored: boolean }> {
|
||||
const mux = this._mux!;
|
||||
|
||||
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
||||
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||
this._muxSession = null;
|
||||
}
|
||||
|
||||
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
||||
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
||||
let needsNewSession = false;
|
||||
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await mux.respawnPane(options.respawnPaneOptions);
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
// Wait a moment for the respawned process to fully start
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestored = this._muxSession !== null && !needsNewSession;
|
||||
if (isRestored) {
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new mux session
|
||||
this._muxSession = await mux.createSession(options.createSessionOptions);
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
throw spawnErr;
|
||||
}
|
||||
|
||||
return { isRestored };
|
||||
}
|
||||
|
||||
private _handleTerminalOutput(data: string): void {
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
}
|
||||
|
||||
async startInteractive(): Promise<void> {
|
||||
if (this.ptyProcess) {
|
||||
throw new Error('Session already has a running process');
|
||||
}
|
||||
|
||||
this._status = 'busy';
|
||||
this._terminalBuffer.clear();
|
||||
this._textOutput.clear();
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
this._resetBuffers();
|
||||
|
||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||
console.log(
|
||||
@@ -892,18 +1003,8 @@ export class Session extends EventEmitter {
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
||||
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||
this._muxSession = null;
|
||||
}
|
||||
|
||||
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
||||
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
||||
let needsNewSession = false;
|
||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await this._mux.respawnPane({
|
||||
const { isRestored } = await this._setupOrAttachMuxSession({
|
||||
respawnPaneOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
@@ -913,23 +1014,8 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
});
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
// Wait a moment for the respawned process to fully start
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestoredSession = this._muxSession !== null && !needsNewSession;
|
||||
if (isRestoredSession) {
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new mux session
|
||||
this._muxSession = await this._mux.createSession({
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
@@ -940,36 +1026,16 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
});
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
}
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(
|
||||
this._mux.getAttachCommand(),
|
||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
}
|
||||
);
|
||||
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
throw spawnErr;
|
||||
}
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
|
||||
// For NEW mux sessions: wait for readiness then clean buffer
|
||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||
if (!isRestoredSession) {
|
||||
if (!isRestored) {
|
||||
if (this.mode === 'opencode') {
|
||||
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
|
||||
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
||||
@@ -1056,12 +1122,17 @@ export class Session extends EventEmitter {
|
||||
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
||||
if (!data) return; // Skip if only filtered sequences
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
this._handleTerminalOutput(data);
|
||||
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
// === Auto-accept workspace trust dialog ===
|
||||
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
||||
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
|
||||
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
||||
this._trustDialogAccepted = true;
|
||||
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
||||
// Send Enter to accept the default selection ("Yes, I trust this folder")
|
||||
this.writeViaMux('\r');
|
||||
}
|
||||
|
||||
// === Idle/working detection runs on every chunk (latency-sensitive) ===
|
||||
// Detect if Claude is working or at prompt
|
||||
@@ -1257,13 +1328,7 @@ export class Session extends EventEmitter {
|
||||
throw new Error('Session already has a running process');
|
||||
}
|
||||
|
||||
this._status = 'busy';
|
||||
this._terminalBuffer.clear();
|
||||
this._textOutput.clear();
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
this._resetBuffers();
|
||||
|
||||
// Use user's default shell or bash
|
||||
const shell = process.env.SHELL || '/bin/bash';
|
||||
@@ -1275,69 +1340,26 @@ export class Session extends EventEmitter {
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
// Verify stale mux session — tmux may have been destroyed externally
|
||||
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||
this._muxSession = null;
|
||||
}
|
||||
|
||||
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
||||
let needsNewSession = false;
|
||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await this._mux.respawnPane({
|
||||
const { isRestored } = await this._setupOrAttachMuxSession({
|
||||
respawnPaneOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: 'shell',
|
||||
niceConfig: this._niceConfig,
|
||||
});
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestoredSession = this._muxSession !== null && !needsNewSession;
|
||||
if (isRestoredSession) {
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new mux session
|
||||
this._muxSession = await this._mux.createSession({
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: 'shell',
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
});
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(
|
||||
this._mux.getAttachCommand(),
|
||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
}
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
throw spawnErr;
|
||||
}
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
|
||||
// For NEW sessions: clear by sending 'clear' command to the shell
|
||||
// For RESTORED sessions: don't clear - we want to see the existing output
|
||||
if (!isRestoredSession) {
|
||||
if (!isRestored) {
|
||||
setTimeout(() => {
|
||||
if (this.ptyProcess) {
|
||||
this._terminalBuffer.clear();
|
||||
@@ -1378,12 +1400,7 @@ export class Session extends EventEmitter {
|
||||
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
|
||||
if (!data) return; // Skip if only focus sequences
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
this._handleTerminalOutput(data);
|
||||
});
|
||||
|
||||
this.ptyProcess.onExit(({ exitCode }) => {
|
||||
@@ -1448,13 +1465,7 @@ export class Session extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
this._status = 'busy';
|
||||
this._terminalBuffer.clear();
|
||||
this._textOutput.clear();
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
this._resetBuffers();
|
||||
this._promptResolved = false; // Reset race condition guard
|
||||
|
||||
this.resolvePromise = resolve;
|
||||
@@ -1497,12 +1508,7 @@ export class Session extends EventEmitter {
|
||||
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
|
||||
if (!data) return; // Skip if only focus sequences
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
this._handleTerminalOutput(data);
|
||||
|
||||
// Also try to parse JSON lines for structured data
|
||||
this.processOutput(data);
|
||||
@@ -1534,9 +1540,11 @@ export class Session extends EventEmitter {
|
||||
this._status = 'idle';
|
||||
const cost = resultMsg.total_cost_usd || 0;
|
||||
this._totalCost += cost;
|
||||
this.emit('completion', resultMsg.result || '', cost);
|
||||
// Claude CLI stream-json may return empty result field — fall back to accumulated text output
|
||||
const result = resultMsg.result || this._textOutput.value || '';
|
||||
this.emit('completion', result, cost);
|
||||
if (resolve) {
|
||||
resolve({ result: resultMsg.result || '', cost });
|
||||
resolve({ result, cost });
|
||||
}
|
||||
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
|
||||
this._status = 'error';
|
||||
@@ -1565,6 +1573,117 @@ export class Session extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
private _resetBuffers(): void {
|
||||
this._status = 'busy';
|
||||
this._terminalBuffer.clear();
|
||||
this._textOutput.clear();
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
}
|
||||
|
||||
private _clearAllTimers(): void {
|
||||
// Clear activity timeout to prevent memory leak
|
||||
if (this.activityTimeout) {
|
||||
clearTimeout(this.activityTimeout);
|
||||
this.activityTimeout = null;
|
||||
}
|
||||
|
||||
// Clear line buffer flush timer
|
||||
if (this._lineBufferFlushTimer) {
|
||||
clearTimeout(this._lineBufferFlushTimer);
|
||||
this._lineBufferFlushTimer = null;
|
||||
}
|
||||
|
||||
// Destroy auto-compact/auto-clear automation (clears its timers)
|
||||
this._autoOps.destroy();
|
||||
|
||||
// Clear prompt check timers
|
||||
if (this._promptCheckInterval) {
|
||||
clearInterval(this._promptCheckInterval);
|
||||
this._promptCheckInterval = null;
|
||||
}
|
||||
if (this._promptCheckTimeout) {
|
||||
clearTimeout(this._promptCheckTimeout);
|
||||
this._promptCheckTimeout = null;
|
||||
}
|
||||
|
||||
// Clear shell idle timer
|
||||
if (this._shellIdleTimer) {
|
||||
clearTimeout(this._shellIdleTimer);
|
||||
this._shellIdleTimer = null;
|
||||
}
|
||||
|
||||
// Clear expensive processing timer
|
||||
if (this._expensiveProcessTimer) {
|
||||
clearTimeout(this._expensiveProcessTimer);
|
||||
this._expensiveProcessTimer = null;
|
||||
}
|
||||
this._pendingCleanData = '';
|
||||
}
|
||||
|
||||
private _handleJsonMessage(cleanLine: string, rawLine: string): void {
|
||||
try {
|
||||
const msg = JSON.parse(cleanLine) as ClaudeMessage;
|
||||
this._messages.push(msg);
|
||||
this.emit('message', msg);
|
||||
|
||||
// Trim messages array for long-running sessions
|
||||
if (this._messages.length > MAX_MESSAGES) {
|
||||
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
|
||||
}
|
||||
|
||||
// Extract Claude session ID from messages (can be in any message type)
|
||||
// Support both sessionId (camelCase) and session_id (snake_case)
|
||||
const msgSessionId =
|
||||
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
|
||||
if (msgSessionId && !this._claudeSessionId) {
|
||||
this._claudeSessionId = msgSessionId;
|
||||
}
|
||||
|
||||
// Process message for task tracking
|
||||
this._taskTracker.processMessage(msg);
|
||||
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
this._textOutput.append(block.text);
|
||||
}
|
||||
}
|
||||
// Track tokens from usage (with validation)
|
||||
if (msg.message.usage) {
|
||||
const inputDelta = msg.message.usage.input_tokens || 0;
|
||||
const outputDelta = msg.message.usage.output_tokens || 0;
|
||||
|
||||
// Sanity check: max 100k tokens per message (generous limit)
|
||||
const MAX_TOKENS_PER_MESSAGE = 100_000;
|
||||
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
|
||||
this._totalInputTokens += inputDelta;
|
||||
}
|
||||
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
|
||||
this._totalOutputTokens += outputDelta;
|
||||
}
|
||||
|
||||
// Check if we should auto-compact or auto-clear
|
||||
this._autoOps.checkAutoCompact();
|
||||
this._autoOps.checkAutoClear();
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'result' && msg.total_cost_usd) {
|
||||
this._totalCost = msg.total_cost_usd;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// Not JSON, just regular output - this is expected for non-JSON lines
|
||||
console.debug(
|
||||
'[Session] Line not JSON (expected for text output):',
|
||||
parseErr instanceof Error ? parseErr.message : parseErr
|
||||
);
|
||||
this._textOutput.append(rawLine + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
private processOutput(data: string): void {
|
||||
// Early return if session is stopped to prevent any processing or timer creation
|
||||
if (this._isStopped) return;
|
||||
@@ -1606,64 +1725,7 @@ export class Session extends EventEmitter {
|
||||
const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
||||
|
||||
if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) {
|
||||
try {
|
||||
const msg = JSON.parse(cleanLine) as ClaudeMessage;
|
||||
this._messages.push(msg);
|
||||
this.emit('message', msg);
|
||||
|
||||
// Trim messages array for long-running sessions
|
||||
if (this._messages.length > MAX_MESSAGES) {
|
||||
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
|
||||
}
|
||||
|
||||
// Extract Claude session ID from messages (can be in any message type)
|
||||
// Support both sessionId (camelCase) and session_id (snake_case)
|
||||
const msgSessionId =
|
||||
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
|
||||
if (msgSessionId && !this._claudeSessionId) {
|
||||
this._claudeSessionId = msgSessionId;
|
||||
}
|
||||
|
||||
// Process message for task tracking
|
||||
this._taskTracker.processMessage(msg);
|
||||
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
this._textOutput.append(block.text);
|
||||
}
|
||||
}
|
||||
// Track tokens from usage (with validation)
|
||||
if (msg.message.usage) {
|
||||
const inputDelta = msg.message.usage.input_tokens || 0;
|
||||
const outputDelta = msg.message.usage.output_tokens || 0;
|
||||
|
||||
// Sanity check: max 100k tokens per message (generous limit)
|
||||
const MAX_TOKENS_PER_MESSAGE = 100_000;
|
||||
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
|
||||
this._totalInputTokens += inputDelta;
|
||||
}
|
||||
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
|
||||
this._totalOutputTokens += outputDelta;
|
||||
}
|
||||
|
||||
// Check if we should auto-compact or auto-clear
|
||||
this._autoOps.checkAutoCompact();
|
||||
this._autoOps.checkAutoClear();
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'result' && msg.total_cost_usd) {
|
||||
this._totalCost = msg.total_cost_usd;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// Not JSON, just regular output - this is expected for non-JSON lines
|
||||
console.debug(
|
||||
'[Session] Line not JSON (expected for text output):',
|
||||
parseErr instanceof Error ? parseErr.message : parseErr
|
||||
);
|
||||
this._textOutput.append(line + '\n');
|
||||
}
|
||||
this._handleJsonMessage(cleanLine, line);
|
||||
} else if (trimmed) {
|
||||
this._textOutput.append(line + '\n');
|
||||
}
|
||||
@@ -1955,7 +2017,7 @@ export class Session extends EventEmitter {
|
||||
this._status = 'busy';
|
||||
this._lastActivityAt = Date.now();
|
||||
this.runPrompt(input).catch((err) => {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = getErrorMessage(err);
|
||||
// Clean up task state so the task queue doesn't get stuck
|
||||
if (this._currentTaskId) {
|
||||
const taskId = this._currentTaskId;
|
||||
@@ -2030,43 +2092,7 @@ export class Session extends EventEmitter {
|
||||
// Set stopped flag first to prevent new timers from being created
|
||||
this._isStopped = true;
|
||||
|
||||
// Clear activity timeout to prevent memory leak
|
||||
if (this.activityTimeout) {
|
||||
clearTimeout(this.activityTimeout);
|
||||
this.activityTimeout = null;
|
||||
}
|
||||
|
||||
// Clear line buffer flush timer
|
||||
if (this._lineBufferFlushTimer) {
|
||||
clearTimeout(this._lineBufferFlushTimer);
|
||||
this._lineBufferFlushTimer = null;
|
||||
}
|
||||
|
||||
// Destroy auto-compact/auto-clear automation (clears its timers)
|
||||
this._autoOps.destroy();
|
||||
|
||||
// Clear prompt check timers
|
||||
if (this._promptCheckInterval) {
|
||||
clearInterval(this._promptCheckInterval);
|
||||
this._promptCheckInterval = null;
|
||||
}
|
||||
if (this._promptCheckTimeout) {
|
||||
clearTimeout(this._promptCheckTimeout);
|
||||
this._promptCheckTimeout = null;
|
||||
}
|
||||
|
||||
// Clear shell idle timer
|
||||
if (this._shellIdleTimer) {
|
||||
clearTimeout(this._shellIdleTimer);
|
||||
this._shellIdleTimer = null;
|
||||
}
|
||||
|
||||
// Clear expensive processing timer
|
||||
if (this._expensiveProcessTimer) {
|
||||
clearTimeout(this._expensiveProcessTimer);
|
||||
this._expensiveProcessTimer = null;
|
||||
}
|
||||
this._pendingCleanData = '';
|
||||
this._clearAllTimers();
|
||||
|
||||
// Immediately cleanup Promise callbacks to prevent orphaned references
|
||||
// during the rest of stop() processing (e.g., if mux kill times out)
|
||||
|
||||
@@ -116,6 +116,26 @@ export class StateStore {
|
||||
this.loadRalphStates();
|
||||
}
|
||||
|
||||
private _mergeWithInitialState(parsed: Partial<AppState>): AppState {
|
||||
const initial = createInitialState();
|
||||
return {
|
||||
...initial,
|
||||
...parsed,
|
||||
sessions: { ...parsed.sessions },
|
||||
tasks: { ...parsed.tasks },
|
||||
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
|
||||
config: { ...initial.config, ...parsed.config },
|
||||
};
|
||||
}
|
||||
|
||||
private _resetCircuitBreaker(): void {
|
||||
this.consecutiveSaveFailures = 0;
|
||||
if (this.circuitBreakerOpen) {
|
||||
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
|
||||
this.circuitBreakerOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDir(): void {
|
||||
const dir = dirname(this.filePath);
|
||||
if (!existsSync(dir)) {
|
||||
@@ -132,15 +152,7 @@ export class StateStore {
|
||||
if (existsSync(path)) {
|
||||
const data = readFileSync(path, 'utf-8');
|
||||
const parsed = JSON.parse(data) as Partial<AppState>;
|
||||
const initial = createInitialState();
|
||||
const result = {
|
||||
...initial,
|
||||
...parsed,
|
||||
sessions: { ...parsed.sessions },
|
||||
tasks: { ...parsed.tasks },
|
||||
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
|
||||
config: { ...initial.config, ...parsed.config },
|
||||
};
|
||||
const result = this._mergeWithInitialState(parsed);
|
||||
if (path !== this.filePath) {
|
||||
console.warn(`[StateStore] Recovered state from backup: ${path}`);
|
||||
}
|
||||
@@ -195,16 +207,7 @@ export class StateStore {
|
||||
* Only dirty sessions are re-serialized; clean sessions use cached JSON fragments.
|
||||
*/
|
||||
private assembleStateJson(): string {
|
||||
// Re-serialize dirty sessions and update cache
|
||||
for (const id of this.dirtySessions) {
|
||||
const session = this.state.sessions[id];
|
||||
if (session) {
|
||||
this.cachedSessionJsons.set(id, JSON.stringify(session));
|
||||
} else {
|
||||
this.cachedSessionJsons.delete(id);
|
||||
}
|
||||
}
|
||||
this.dirtySessions.clear();
|
||||
this.updateDirtySessionCache();
|
||||
|
||||
// Build sessions object from cached fragments
|
||||
const sessionParts: string[] = [];
|
||||
@@ -218,6 +221,25 @@ export class StateStore {
|
||||
sessionParts.push(`${JSON.stringify(id)}:${json}`);
|
||||
}
|
||||
|
||||
this.pruneStaleCacheEntries();
|
||||
|
||||
return this.buildPartialJson(sessionParts);
|
||||
}
|
||||
|
||||
private updateDirtySessionCache(): void {
|
||||
// Re-serialize dirty sessions and update cache
|
||||
for (const id of this.dirtySessions) {
|
||||
const session = this.state.sessions[id];
|
||||
if (session) {
|
||||
this.cachedSessionJsons.set(id, JSON.stringify(session));
|
||||
} else {
|
||||
this.cachedSessionJsons.delete(id);
|
||||
}
|
||||
}
|
||||
this.dirtySessions.clear();
|
||||
}
|
||||
|
||||
private pruneStaleCacheEntries(): void {
|
||||
// Prune stale cache entries (sessions removed via direct state mutation)
|
||||
if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) {
|
||||
for (const cachedId of this.cachedSessionJsons.keys()) {
|
||||
@@ -226,7 +248,9 @@ export class StateStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildPartialJson(sessionParts: string[]): string {
|
||||
// Build final JSON: sessions from cache, everything else re-serialized (tiny)
|
||||
const sessionsJson = `{${sessionParts.join(',')}}`;
|
||||
|
||||
@@ -249,6 +273,28 @@ export class StateStore {
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
|
||||
private serializeState(): string | null {
|
||||
try {
|
||||
return this.assembleStateJson();
|
||||
} catch (assembleErr) {
|
||||
// Fallback to full serialization if incremental assembly fails
|
||||
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
|
||||
this.cachedSessionJsons.clear();
|
||||
this.dirtySessions.clear();
|
||||
try {
|
||||
return JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _doSaveAsync(): Promise<void> {
|
||||
this.saveDeb.cancel();
|
||||
if (!this.dirty) {
|
||||
@@ -263,30 +309,12 @@ export class StateStore {
|
||||
|
||||
this.ensureDir();
|
||||
|
||||
const tempPath = this.filePath + '.tmp';
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||
const backupPath = this.filePath + '.bak';
|
||||
let json: string;
|
||||
|
||||
// Step 1: Serialize state (validates it's JSON-safe)
|
||||
try {
|
||||
json = this.assembleStateJson();
|
||||
} catch (assembleErr) {
|
||||
// Fallback to full serialization if incremental assembly fails
|
||||
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
|
||||
this.cachedSessionJsons.clear();
|
||||
this.dirtySessions.clear();
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const json = this.serializeState();
|
||||
if (json === null) return;
|
||||
|
||||
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
|
||||
// The state snapshot is already captured in `json` above.
|
||||
@@ -305,11 +333,7 @@ export class StateStore {
|
||||
await writeFile(tempPath, json, 'utf-8');
|
||||
await rename(tempPath, this.filePath);
|
||||
|
||||
this.consecutiveSaveFailures = 0;
|
||||
if (this.circuitBreakerOpen) {
|
||||
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
|
||||
this.circuitBreakerOpen = false;
|
||||
}
|
||||
this._resetCircuitBreaker();
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to write state file:', err);
|
||||
// Re-mark dirty so the data is retried on the next save cycle
|
||||
@@ -349,29 +373,11 @@ export class StateStore {
|
||||
|
||||
this.ensureDir();
|
||||
|
||||
const tempPath = this.filePath + '.tmp';
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||
const backupPath = this.filePath + '.bak';
|
||||
let json: string;
|
||||
|
||||
try {
|
||||
json = this.assembleStateJson();
|
||||
} catch (assembleErr) {
|
||||
// Fallback to full serialization if incremental assembly fails
|
||||
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
|
||||
this.cachedSessionJsons.clear();
|
||||
this.dirtySessions.clear();
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const json = this.serializeState();
|
||||
if (json === null) return;
|
||||
|
||||
// Backup via atomic copy (avoids reading entire file into memory)
|
||||
try {
|
||||
@@ -387,11 +393,7 @@ export class StateStore {
|
||||
renameSync(tempPath, this.filePath);
|
||||
// Clear dirty flag only AFTER successful write
|
||||
this.dirty = false;
|
||||
this.consecutiveSaveFailures = 0;
|
||||
if (this.circuitBreakerOpen) {
|
||||
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
|
||||
this.circuitBreakerOpen = false;
|
||||
}
|
||||
this._resetCircuitBreaker();
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to write state file:', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
@@ -417,15 +419,7 @@ export class StateStore {
|
||||
if (existsSync(backupPath)) {
|
||||
const backupContent = readFileSync(backupPath, 'utf-8');
|
||||
const parsed = JSON.parse(backupContent) as Partial<AppState>;
|
||||
const initial = createInitialState();
|
||||
this.state = {
|
||||
...initial,
|
||||
...parsed,
|
||||
sessions: { ...parsed.sessions },
|
||||
tasks: { ...parsed.tasks },
|
||||
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
|
||||
config: { ...initial.config, ...parsed.config },
|
||||
};
|
||||
this.state = this._mergeWithInitialState(parsed);
|
||||
console.log('[StateStore] Successfully recovered state from backup');
|
||||
// Reset circuit breaker after successful recovery
|
||||
this.circuitBreakerOpen = false;
|
||||
@@ -547,6 +541,30 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
// ========== Orchestrator Loop State Methods ==========
|
||||
|
||||
/** Returns the orchestrator loop state, or null if never initialized. */
|
||||
getOrchestratorState() {
|
||||
return this.state.orchestrator ?? null;
|
||||
}
|
||||
|
||||
/** Updates orchestrator loop state (partial merge) and triggers a debounced save. */
|
||||
setOrchestratorState(orchestrator: Partial<NonNullable<AppState['orchestrator']>>) {
|
||||
if (this.state.orchestrator) {
|
||||
this.state.orchestrator = { ...this.state.orchestrator, ...orchestrator };
|
||||
} else {
|
||||
// First initialization — caller must provide full state
|
||||
this.state.orchestrator = orchestrator as NonNullable<AppState['orchestrator']>;
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Clears orchestrator state and triggers a debounced save. */
|
||||
clearOrchestratorState() {
|
||||
this.state.orchestrator = undefined;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns the application configuration. */
|
||||
getConfig() {
|
||||
return this.state.config;
|
||||
|
||||
@@ -222,6 +222,83 @@ export class SubagentWatcher extends EventEmitter {
|
||||
return INTERNAL_AGENT_PATTERNS.some((pattern) => pattern.test(description));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a subagent as completed: clear PID, set status, clean up pending tool calls, emit event.
|
||||
*/
|
||||
private markSubagentAsCompleted(info: SubagentInfo): void {
|
||||
info.pid = undefined;
|
||||
info.status = 'completed';
|
||||
this.pendingToolCalls.delete(info.agentId);
|
||||
this.emit('subagent:completed', info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from message content, handling both string and array formats.
|
||||
* For array content, returns the text from the first 'text' block.
|
||||
*/
|
||||
private extractFirstTextContent(
|
||||
content: string | Array<{ type: string; text?: string }> | undefined
|
||||
): string | undefined {
|
||||
if (!content) return undefined;
|
||||
if (typeof content === 'string') {
|
||||
const trimmed = content.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const firstContent = content[0];
|
||||
if (firstContent?.type === 'text' && firstContent.text) {
|
||||
const trimmed = firstContent.text.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a tool_result content block: look up pending tool call, emit tool_result event.
|
||||
*/
|
||||
private emitToolResult(
|
||||
content: { tool_use_id: string; content?: string | Array<{ type: string; text?: string }>; is_error?: boolean },
|
||||
agentId: string,
|
||||
sessionId: string,
|
||||
timestamp: string
|
||||
): void {
|
||||
const resultContent = this.extractToolResultContent(content.content);
|
||||
const agentPendingCalls = this.pendingToolCalls.get(agentId);
|
||||
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
|
||||
const toolName = pendingCall?.toolName;
|
||||
// Delete after lookup to prevent memory leak
|
||||
agentPendingCalls?.delete(content.tool_use_id);
|
||||
|
||||
const toolResult: SubagentToolResult = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
toolUseId: content.tool_use_id,
|
||||
tool: toolName,
|
||||
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
|
||||
contentLength: resultContent.length,
|
||||
isError: content.is_error || false,
|
||||
};
|
||||
this.emit('subagent:tool_result', toolResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the oldest inactive (non-active) agent for LRU eviction.
|
||||
* Returns the agent ID of the oldest inactive agent, or null if all are active.
|
||||
*/
|
||||
private findOldestInactiveAgent(): string | null {
|
||||
let oldestId: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [id, existing] of this.agentInfo) {
|
||||
if (existing.status !== 'active' && existing.lastActivityAt < oldestTime) {
|
||||
oldestTime = existing.lastActivityAt;
|
||||
oldestId = id;
|
||||
}
|
||||
}
|
||||
return oldestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract short model identifier from full model name
|
||||
*/
|
||||
@@ -307,10 +384,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
|
||||
if (!alive) {
|
||||
info.pid = undefined;
|
||||
info.status = 'completed';
|
||||
this.pendingToolCalls.delete(info.agentId);
|
||||
this.emit('subagent:completed', info);
|
||||
this.markSubagentAsCompleted(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,10 +751,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
const pid = await this.findSubagentProcess(info.sessionId);
|
||||
if (pid) {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
info.pid = undefined;
|
||||
info.status = 'completed';
|
||||
this.pendingToolCalls.delete(info.agentId);
|
||||
this.emit('subagent:completed', info);
|
||||
this.markSubagentAsCompleted(info);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
@@ -688,10 +759,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
// Mark as completed even if we couldn't find the process
|
||||
info.pid = undefined;
|
||||
info.status = 'completed';
|
||||
this.pendingToolCalls.delete(info.agentId);
|
||||
this.emit('subagent:completed', info);
|
||||
this.markSubagentAsCompleted(info);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -843,19 +911,9 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
} else if (entry.type === 'user' && entry.message?.content) {
|
||||
// Handle both string and array content formats
|
||||
if (typeof entry.message.content === 'string') {
|
||||
const text = entry.message.content.trim();
|
||||
if (text.length < 100 && !text.includes('{')) {
|
||||
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
|
||||
}
|
||||
} else {
|
||||
const firstContent = entry.message.content[0];
|
||||
if (firstContent?.type === 'text' && firstContent.text) {
|
||||
const text = firstContent.text.trim();
|
||||
if (text.length < 100 && !text.includes('{')) {
|
||||
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
|
||||
}
|
||||
}
|
||||
const text = this.extractFirstTextContent(entry.message.content);
|
||||
if (text && text.length < 100 && !text.includes('{')) {
|
||||
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -930,6 +988,24 @@ export class SubagentWatcher extends EventEmitter {
|
||||
return truncated.replace(/[.!?,:\s]+$/, '');
|
||||
}
|
||||
|
||||
private async _resolveDescription(
|
||||
projectHash: string,
|
||||
sessionId: string,
|
||||
agentId: string,
|
||||
filePath: string,
|
||||
fallbackText?: string
|
||||
): Promise<string | undefined> {
|
||||
// First try parent transcript (most reliable)
|
||||
const fromParent = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
|
||||
if (fromParent) return fromParent;
|
||||
|
||||
// Fallback: inline text (from processEntry) or file extraction
|
||||
if (fallbackText) {
|
||||
return this.extractSmartTitle(fallbackText);
|
||||
}
|
||||
return this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the short description from the parent session's transcript.
|
||||
* This is the most reliable method because it reads the actual Task tool result
|
||||
@@ -1027,15 +1103,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
let text: string | undefined;
|
||||
if (typeof entry.message.content === 'string') {
|
||||
text = entry.message.content.trim();
|
||||
} else if (Array.isArray(entry.message.content)) {
|
||||
const firstContent = entry.message.content[0];
|
||||
if (firstContent?.type === 'text' && firstContent.text) {
|
||||
text = firstContent.text.trim();
|
||||
}
|
||||
}
|
||||
const text = this.extractFirstTextContent(entry.message.content);
|
||||
if (text) {
|
||||
resolved = true;
|
||||
rl.close();
|
||||
@@ -1195,18 +1263,13 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
// Retry description extraction if missing (race condition fix)
|
||||
if (!existingInfo.description) {
|
||||
// First try parent transcript (most reliable)
|
||||
let extractedDescription = await this.extractDescriptionFromParentTranscript(
|
||||
const extractedDescription = await this._resolveDescription(
|
||||
existingInfo.projectHash,
|
||||
existingInfo.sessionId,
|
||||
agentId
|
||||
agentId,
|
||||
filePath
|
||||
);
|
||||
// Fallback to subagent file
|
||||
if (!extractedDescription) {
|
||||
extractedDescription = await this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
if (extractedDescription) {
|
||||
// Check if this is an internal agent - if so, remove it
|
||||
if (this.isInternalAgent(extractedDescription)) {
|
||||
this.removeAgent(agentId);
|
||||
return;
|
||||
@@ -1257,13 +1320,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
// Extract description - prefer reading from parent transcript (most reliable)
|
||||
// The parent transcript has the exact Task tool call with description parameter
|
||||
let description = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
|
||||
|
||||
// Fallback: extract a smart title from the subagent's prompt if parent lookup failed
|
||||
if (!description) {
|
||||
description = await this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
const description = await this._resolveDescription(projectHash, sessionId, agentId, filePath);
|
||||
|
||||
// Skip internal Claude Code agents (e.g., suggestion mode) - not real subagents
|
||||
if (this.isInternalAgent(description)) {
|
||||
@@ -1286,14 +1343,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
// Enforce MAX_TRACKED_AGENTS during insertion — evict oldest inactive agent
|
||||
if (this.agentInfo.size >= MAX_TRACKED_AGENTS) {
|
||||
let oldestId: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [id, existing] of this.agentInfo) {
|
||||
if (existing.status !== 'active' && existing.lastActivityAt < oldestTime) {
|
||||
oldestTime = existing.lastActivityAt;
|
||||
oldestId = id;
|
||||
}
|
||||
}
|
||||
const oldestId = this.findOldestInactiveAgent();
|
||||
if (oldestId) {
|
||||
this.removeAgent(oldestId);
|
||||
}
|
||||
@@ -1363,51 +1413,11 @@ export class SubagentWatcher extends EventEmitter {
|
||||
private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise<void> {
|
||||
const info = this.agentInfo.get(agentId);
|
||||
|
||||
// Extract model from assistant messages (first one sets the model)
|
||||
if (info && entry.type === 'assistant' && entry.message?.model && !info.model) {
|
||||
info.model = entry.message.model;
|
||||
info.modelShort = this.extractModelShort(entry.message.model);
|
||||
this.emit('subagent:updated', info);
|
||||
}
|
||||
if (info) {
|
||||
this._processModelInfo(entry, info);
|
||||
this._processTokenInfo(entry, info);
|
||||
|
||||
// Aggregate token usage from messages
|
||||
if (info && entry.message?.usage) {
|
||||
if (entry.message.usage.input_tokens) {
|
||||
info.totalInputTokens = (info.totalInputTokens || 0) + entry.message.usage.input_tokens;
|
||||
}
|
||||
if (entry.message.usage.output_tokens) {
|
||||
info.totalOutputTokens = (info.totalOutputTokens || 0) + entry.message.usage.output_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is first user message and description is missing
|
||||
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
||||
// First try parent transcript (most reliable)
|
||||
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
|
||||
// Fallback: extract smart title from the prompt content
|
||||
if (!description) {
|
||||
let text: string | undefined;
|
||||
if (typeof entry.message.content === 'string') {
|
||||
text = entry.message.content.trim();
|
||||
} else if (Array.isArray(entry.message.content)) {
|
||||
const firstContent = entry.message.content[0];
|
||||
if (firstContent?.type === 'text' && firstContent.text) {
|
||||
text = firstContent.text.trim();
|
||||
}
|
||||
}
|
||||
if (text) {
|
||||
description = this.extractSmartTitle(text);
|
||||
}
|
||||
}
|
||||
if (description) {
|
||||
// Check if this is an internal agent - if so, remove it
|
||||
if (this.isInternalAgent(description)) {
|
||||
this.removeAgent(agentId);
|
||||
return;
|
||||
}
|
||||
info.description = description;
|
||||
this.emit('subagent:updated', info);
|
||||
}
|
||||
if (await this._processDescription(entry, agentId, info)) return;
|
||||
}
|
||||
|
||||
if (entry.type === 'progress' && entry.data) {
|
||||
@@ -1418,7 +1428,6 @@ export class SubagentWatcher extends EventEmitter {
|
||||
progressType: entry.data.type,
|
||||
query: entry.data.query,
|
||||
resultCount: entry.data.resultCount,
|
||||
// Extract hook event info if present
|
||||
hookEvent: entry.data.hookEvent,
|
||||
hookName:
|
||||
entry.data.hookName ||
|
||||
@@ -1428,139 +1437,166 @@ export class SubagentWatcher extends EventEmitter {
|
||||
};
|
||||
this.emit('subagent:progress', progress);
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
// Handle both string and array content formats
|
||||
if (typeof entry.message.content === 'string') {
|
||||
const text = entry.message.content.trim();
|
||||
if (text.length > 0) {
|
||||
const message: SubagentMessage = {
|
||||
this._processAssistantContent(entry, agentId, sessionId);
|
||||
} else if (entry.type === 'user' && entry.message?.content) {
|
||||
this._processUserContent(entry, agentId, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private _processModelInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
|
||||
if (entry.type === 'assistant' && entry.message?.model && !agent.model) {
|
||||
agent.model = entry.message.model;
|
||||
agent.modelShort = this.extractModelShort(entry.message.model);
|
||||
this.emit('subagent:updated', agent);
|
||||
}
|
||||
}
|
||||
|
||||
private _processTokenInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
|
||||
if (!entry.message?.usage) return;
|
||||
if (entry.message.usage.input_tokens) {
|
||||
agent.totalInputTokens = (agent.totalInputTokens || 0) + entry.message.usage.input_tokens;
|
||||
}
|
||||
if (entry.message.usage.output_tokens) {
|
||||
agent.totalOutputTokens = (agent.totalOutputTokens || 0) + entry.message.usage.output_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
private async _processDescription(
|
||||
entry: SubagentTranscriptEntry,
|
||||
agentId: string,
|
||||
agent: SubagentInfo
|
||||
): Promise<boolean> {
|
||||
if (agent.description || entry.type !== 'user' || !entry.message?.content) return false;
|
||||
|
||||
const fallbackText = this.extractFirstTextContent(entry.message.content);
|
||||
const description = await this._resolveDescription(
|
||||
agent.projectHash,
|
||||
agent.sessionId,
|
||||
agentId,
|
||||
agent.filePath,
|
||||
fallbackText
|
||||
);
|
||||
if (description) {
|
||||
if (this.isInternalAgent(description)) {
|
||||
this.removeAgent(agentId);
|
||||
return true;
|
||||
}
|
||||
agent.description = description;
|
||||
this.emit('subagent:updated', agent);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _processAssistantContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
|
||||
const messageContent = entry.message!.content;
|
||||
if (typeof messageContent === 'string') {
|
||||
const text = messageContent.trim();
|
||||
if (text.length > 0) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'assistant',
|
||||
text: text.substring(0, MESSAGE_TEXT_LIMIT),
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
} else {
|
||||
for (const content of messageContent) {
|
||||
if (content.type === 'tool_use' && content.name) {
|
||||
// Store toolUseId for linking to results, with timestamp for TTL cleanup
|
||||
if (content.id) {
|
||||
if (!this.pendingToolCalls.has(agentId)) {
|
||||
this.pendingToolCalls.set(agentId, new Map());
|
||||
}
|
||||
const agentCalls = this.pendingToolCalls.get(agentId)!;
|
||||
// Enforce size limit to prevent memory leak from rapid tool calls
|
||||
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
|
||||
// FIFO eviction: delete first (oldest) entry using Map insertion order
|
||||
const firstKey = agentCalls.keys().next().value;
|
||||
if (firstKey !== undefined) agentCalls.delete(firstKey);
|
||||
}
|
||||
agentCalls.set(content.id, {
|
||||
toolName: content.name,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const toolCall: SubagentToolCall = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'assistant',
|
||||
text: text.substring(0, MESSAGE_TEXT_LIMIT),
|
||||
tool: content.name,
|
||||
input: this.getTruncatedInput(content.name, content.input || {}),
|
||||
toolUseId: content.id,
|
||||
fullInput: content.input || {},
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
} else {
|
||||
for (const content of entry.message.content) {
|
||||
if (content.type === 'tool_use' && content.name) {
|
||||
// Store toolUseId for linking to results, with timestamp for TTL cleanup
|
||||
if (content.id) {
|
||||
if (!this.pendingToolCalls.has(agentId)) {
|
||||
this.pendingToolCalls.set(agentId, new Map());
|
||||
}
|
||||
const agentCalls = this.pendingToolCalls.get(agentId)!;
|
||||
// Enforce size limit to prevent memory leak from rapid tool calls
|
||||
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
|
||||
// FIFO eviction: delete first (oldest) entry using Map insertion order
|
||||
const firstKey = agentCalls.keys().next().value;
|
||||
if (firstKey !== undefined) agentCalls.delete(firstKey);
|
||||
}
|
||||
agentCalls.set(content.id, {
|
||||
toolName: content.name,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
this.emit('subagent:tool_call', toolCall);
|
||||
|
||||
const toolCall: SubagentToolCall = {
|
||||
// Update tool call count
|
||||
const agentInfo = this.agentInfo.get(agentId);
|
||||
if (agentInfo) {
|
||||
agentInfo.toolCallCount++;
|
||||
}
|
||||
} else if (content.type === 'tool_result' && content.tool_use_id) {
|
||||
this.emitToolResult(
|
||||
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
|
||||
agentId,
|
||||
sessionId,
|
||||
entry.timestamp
|
||||
);
|
||||
} else if (content.type === 'text' && content.text) {
|
||||
const text = content.text.trim();
|
||||
if (text.length > 0) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
tool: content.name,
|
||||
input: this.getTruncatedInput(content.name, content.input || {}),
|
||||
toolUseId: content.id,
|
||||
fullInput: content.input || {},
|
||||
role: 'assistant',
|
||||
text: text.substring(0, MESSAGE_TEXT_LIMIT),
|
||||
};
|
||||
this.emit('subagent:tool_call', toolCall);
|
||||
|
||||
// Update tool call count
|
||||
const agentInfo = this.agentInfo.get(agentId);
|
||||
if (agentInfo) {
|
||||
agentInfo.toolCallCount++;
|
||||
}
|
||||
} else if (content.type === 'tool_result' && content.tool_use_id) {
|
||||
// Extract tool result
|
||||
const resultContent = this.extractToolResultContent(content.content);
|
||||
const agentPendingCalls = this.pendingToolCalls.get(agentId);
|
||||
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
|
||||
const toolName = pendingCall?.toolName;
|
||||
// Delete after lookup to prevent memory leak
|
||||
agentPendingCalls?.delete(content.tool_use_id);
|
||||
|
||||
const toolResult: SubagentToolResult = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
toolUseId: content.tool_use_id,
|
||||
tool: toolName,
|
||||
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
|
||||
contentLength: resultContent.length,
|
||||
isError: content.is_error || false,
|
||||
};
|
||||
this.emit('subagent:tool_result', toolResult);
|
||||
} else if (content.type === 'text' && content.text) {
|
||||
const text = content.text.trim();
|
||||
if (text.length > 0) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'assistant',
|
||||
text: text.substring(0, MESSAGE_TEXT_LIMIT), // Limit text length
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (entry.type === 'user' && entry.message?.content) {
|
||||
// Handle both string and array content formats - also check for tool_result in user messages
|
||||
if (typeof entry.message.content === 'string') {
|
||||
const userText = entry.message.content.trim();
|
||||
if (userText.length > 0 && userText.length < 500) {
|
||||
const message: SubagentMessage = {
|
||||
}
|
||||
}
|
||||
|
||||
private _processUserContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
|
||||
const messageContent = entry.message!.content;
|
||||
if (typeof messageContent === 'string') {
|
||||
const userText = messageContent.trim();
|
||||
if (userText.length > 0 && userText.length < 500) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'user',
|
||||
text: userText,
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
} else {
|
||||
// Check for tool_result blocks in user messages (common pattern)
|
||||
for (const content of messageContent) {
|
||||
if (content.type === 'tool_result' && content.tool_use_id) {
|
||||
this.emitToolResult(
|
||||
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'user',
|
||||
text: userText,
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
} else {
|
||||
// Check for tool_result blocks in user messages (common pattern)
|
||||
for (const content of entry.message.content) {
|
||||
if (content.type === 'tool_result' && content.tool_use_id) {
|
||||
const resultContent = this.extractToolResultContent(content.content);
|
||||
const agentPendingCalls = this.pendingToolCalls.get(agentId);
|
||||
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
|
||||
const toolName = pendingCall?.toolName;
|
||||
// Delete after lookup to prevent memory leak
|
||||
agentPendingCalls?.delete(content.tool_use_id);
|
||||
|
||||
const toolResult: SubagentToolResult = {
|
||||
entry.timestamp
|
||||
);
|
||||
} else if (content.type === 'text' && content.text) {
|
||||
const userText = content.text.trim();
|
||||
if (userText.length > 0 && userText.length < 500) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
toolUseId: content.tool_use_id,
|
||||
tool: toolName,
|
||||
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
|
||||
contentLength: resultContent.length,
|
||||
isError: content.is_error || false,
|
||||
role: 'user',
|
||||
text: userText,
|
||||
};
|
||||
this.emit('subagent:tool_result', toolResult);
|
||||
} else if (content.type === 'text' && content.text) {
|
||||
const userText = content.text.trim();
|
||||
if (userText.length > 0 && userText.length < 500) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'user',
|
||||
text: userText,
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
|
||||
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
|
||||
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
|
||||
|
||||
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
|
||||
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
|
||||
|
||||
/**
|
||||
* Validates that a session name contains only safe characters.
|
||||
* Prevents command injection via malformed session IDs.
|
||||
@@ -111,23 +114,7 @@ function isValidMuxName(name: string): boolean {
|
||||
* Prevents command injection via malformed paths.
|
||||
*/
|
||||
function isValidPath(path: string): boolean {
|
||||
if (
|
||||
path.includes(';') ||
|
||||
path.includes('&') ||
|
||||
path.includes('|') ||
|
||||
path.includes('$') ||
|
||||
path.includes('`') ||
|
||||
path.includes('(') ||
|
||||
path.includes(')') ||
|
||||
path.includes('{') ||
|
||||
path.includes('}') ||
|
||||
path.includes('<') ||
|
||||
path.includes('>') ||
|
||||
path.includes("'") ||
|
||||
path.includes('"') ||
|
||||
path.includes('\n') ||
|
||||
path.includes('\r')
|
||||
) {
|
||||
if (UNSAFE_PATH_CHARS.test(path)) {
|
||||
return false;
|
||||
}
|
||||
if (path.includes('..')) {
|
||||
@@ -198,8 +185,8 @@ function buildSpawnCommand(options: {
|
||||
}): string {
|
||||
if (options.mode === 'claude') {
|
||||
// Validate model to prevent command injection
|
||||
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
|
||||
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
||||
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
|
||||
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
|
||||
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
|
||||
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
|
||||
// fall back to a new session with --session-id so the pane doesn't die.
|
||||
@@ -371,6 +358,52 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the array of environment export commands shared by createSession() and respawnPane().
|
||||
* Includes locale, mux markers, session identity, and API URL.
|
||||
*/
|
||||
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
|
||||
const exports = [
|
||||
'export LANG=en_US.UTF-8',
|
||||
'export LC_ALL=en_US.UTF-8',
|
||||
'unset COLORTERM',
|
||||
'export CODEMAN_MUX=1',
|
||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
||||
];
|
||||
// Only unset CLAUDECODE for Claude sessions
|
||||
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
|
||||
return exports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CLI binary directory and return the PATH export prefix string.
|
||||
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
|
||||
* In createSession(), a missing binary dir throws — the caller handles that separately.
|
||||
*/
|
||||
private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } {
|
||||
if (mode === 'claude') {
|
||||
const dir = findClaudeDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
if (mode === 'opencode') {
|
||||
const dir = resolveOpenCodeDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
return { pathExport: '', dir: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OpenCode-specific environment on a tmux session.
|
||||
* Sets sensitive API keys and config content via tmux setenv
|
||||
* (not visible in ps output or tmux history, inherited by panes).
|
||||
*/
|
||||
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
|
||||
setOpenCodeEnvVars(muxName);
|
||||
setOpenCodeConfigContent(muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||
* In test mode: creates an in-memory session only (no real tmux session).
|
||||
@@ -415,33 +448,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
// Resolve CLI binary directory based on mode
|
||||
let pathExport = '';
|
||||
if (mode === 'claude') {
|
||||
const claudeDir = findClaudeDir();
|
||||
if (!claudeDir) {
|
||||
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
||||
}
|
||||
pathExport = `export PATH="${claudeDir}:$PATH" && `;
|
||||
} else if (mode === 'opencode') {
|
||||
const openCodeDir = resolveOpenCodeDir();
|
||||
if (!openCodeDir) {
|
||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||
}
|
||||
pathExport = `export PATH="${openCodeDir}:$PATH" && `;
|
||||
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
|
||||
if (mode === 'claude' && !cliDir) {
|
||||
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
||||
}
|
||||
if (mode === 'opencode' && !cliDir) {
|
||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||
}
|
||||
|
||||
const envExports = [
|
||||
'export LANG=en_US.UTF-8',
|
||||
'export LC_ALL=en_US.UTF-8',
|
||||
'unset COLORTERM',
|
||||
'export CODEMAN_MUX=1',
|
||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
||||
];
|
||||
// Only unset CLAUDECODE for Claude sessions
|
||||
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
||||
const envExportsStr = envExports.join(' && ');
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
const baseCmd = buildSpawnCommand({
|
||||
mode,
|
||||
@@ -489,8 +504,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// For OpenCode: set sensitive env vars and config via tmux setenv
|
||||
// (not visible in ps output or tmux history, inherited by panes)
|
||||
if (mode === 'opencode') {
|
||||
setOpenCodeEnvVars(muxName);
|
||||
setOpenCodeConfigContent(muxName, openCodeConfig);
|
||||
this._configureOpenCode(muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
// Replace the shell with the actual command (no echo in terminal)
|
||||
@@ -641,26 +655,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
||||
|
||||
// Resolve CLI binary directory based on mode
|
||||
let pathExport = '';
|
||||
if (mode === 'claude') {
|
||||
const claudeDir = findClaudeDir();
|
||||
pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
|
||||
} else if (mode === 'opencode') {
|
||||
const openCodeDir = resolveOpenCodeDir();
|
||||
pathExport = openCodeDir ? `export PATH="${openCodeDir}:$PATH" && ` : '';
|
||||
}
|
||||
const { pathExport } = this.buildPathExport(mode);
|
||||
|
||||
const envExports = [
|
||||
'export LANG=en_US.UTF-8',
|
||||
'export LC_ALL=en_US.UTF-8',
|
||||
'unset COLORTERM',
|
||||
'export CODEMAN_MUX=1',
|
||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
||||
];
|
||||
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
||||
const envExportsStr = envExports.join(' && ');
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
const baseCmd = buildSpawnCommand({
|
||||
mode,
|
||||
@@ -678,8 +675,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
try {
|
||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||
if (mode === 'opencode') {
|
||||
setOpenCodeEnvVars(muxName);
|
||||
setOpenCodeConfigContent(muxName, openCodeConfig);
|
||||
this._configureOpenCode(muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
RESTART_DELAY_MS,
|
||||
FORCE_KILL_MS,
|
||||
} from './config/tunnel-config.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -164,7 +165,7 @@ export class TunnelManager extends EventEmitter {
|
||||
detached: false,
|
||||
});
|
||||
} catch (err) {
|
||||
this.emit('error', `Failed to spawn cloudflared: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.emit('error', `Failed to spawn cloudflared: ${getErrorMessage(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ export interface AppState {
|
||||
globalStats?: GlobalStats;
|
||||
/** Daily token usage statistics */
|
||||
tokenStats?: TokenStats;
|
||||
/** Orchestrator Loop state (phased plan execution) */
|
||||
orchestrator?: import('./orchestrator.js').OrchestratorPersistState;
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
|
||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
||||
*
|
||||
* ## Cross-domain relationship map
|
||||
*
|
||||
@@ -64,3 +65,4 @@ export * from './tools.js';
|
||||
export * from './teams.js';
|
||||
export * from './push.js';
|
||||
export * from './plan.js';
|
||||
export * from './orchestrator.js';
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator Loop type definitions.
|
||||
*
|
||||
* Types for the phased plan execution system: state machine, plan structure,
|
||||
* phase grouping, team strategies, verification, configuration, and persistence.
|
||||
*
|
||||
* Key exports:
|
||||
* - OrchestratorState — state machine states (idle → planning → approval → executing → verifying → ...)
|
||||
* - OrchestratorPlan / OrchestratorPhase / OrchestratorTask — hierarchical plan structure
|
||||
* - TeamStrategy — how agents are coordinated per phase (single, parallel, team)
|
||||
* - VerificationResult / VerificationCheck — phase verification output
|
||||
* - OrchestratorConfig — user-configurable options
|
||||
* - OrchestratorPersistState / OrchestratorStats — persistence and metrics
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - OrchestratorTask.queueTaskId links to TaskState.id (task domain)
|
||||
* - OrchestratorTask.assignedSessionId links to SessionState.id (session domain)
|
||||
* - OrchestratorPersistState is embedded in AppState.orchestrator (app-state domain)
|
||||
*
|
||||
* Served at `GET /api/orchestrator/status` and `GET /api/orchestrator/plan`.
|
||||
* No dependencies on other domain modules.
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State Machine
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Orchestrator loop states */
|
||||
export type OrchestratorState =
|
||||
| 'idle'
|
||||
| 'planning'
|
||||
| 'approval'
|
||||
| 'executing'
|
||||
| 'verifying'
|
||||
| 'replanning'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'paused';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Structure
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Top-level orchestrator plan generated from a user goal */
|
||||
export interface OrchestratorPlan {
|
||||
/** Unique plan identifier */
|
||||
id: string;
|
||||
/** Original user goal/task description */
|
||||
goal: string;
|
||||
/** When the plan was generated */
|
||||
createdAt: number;
|
||||
/** Ordered list of execution phases */
|
||||
phases: OrchestratorPhase[];
|
||||
/** Plan generation metadata */
|
||||
metadata: OrchestratorPlanMetadata;
|
||||
}
|
||||
|
||||
/** Metadata from plan generation */
|
||||
export interface OrchestratorPlanMetadata {
|
||||
/** Total tasks across all phases */
|
||||
totalTasks: number;
|
||||
/** Estimated overall complexity */
|
||||
estimatedComplexity: 'low' | 'medium' | 'high';
|
||||
/** Model used for plan generation */
|
||||
modelUsed: string;
|
||||
/** Time taken to generate the plan */
|
||||
planDurationMs: number;
|
||||
}
|
||||
|
||||
/** A sequential execution phase containing parallel tasks */
|
||||
export interface OrchestratorPhase {
|
||||
/** Phase identifier (e.g., "phase-1") */
|
||||
id: string;
|
||||
/** Human-readable phase name */
|
||||
name: string;
|
||||
/** Detailed description of what this phase accomplishes */
|
||||
description: string;
|
||||
/** Execution order (0-based) */
|
||||
order: number;
|
||||
/** Current phase status */
|
||||
status: PhaseStatus;
|
||||
/** Tasks within this phase */
|
||||
tasks: OrchestratorTask[];
|
||||
/** Criteria to verify after phase completion */
|
||||
verificationCriteria: string[];
|
||||
/** Shell commands to run for verification */
|
||||
testCommands: string[];
|
||||
/** Maximum retry attempts for this phase */
|
||||
maxAttempts: number;
|
||||
/** Current attempt count */
|
||||
attempts: number;
|
||||
/** When execution started */
|
||||
startedAt: number | null;
|
||||
/** When phase completed (passed or failed) */
|
||||
completedAt: number | null;
|
||||
/** Total execution duration */
|
||||
durationMs: number | null;
|
||||
/** How to coordinate agents for this phase */
|
||||
teamStrategy: TeamStrategy;
|
||||
}
|
||||
|
||||
/** Phase execution status */
|
||||
export type PhaseStatus = 'pending' | 'executing' | 'verifying' | 'passed' | 'failed' | 'skipped';
|
||||
|
||||
/** A single executable task within a phase */
|
||||
export interface OrchestratorTask {
|
||||
/** Task identifier (e.g., "phase-1-task-1") */
|
||||
id: string;
|
||||
/** Parent phase identifier */
|
||||
phaseId: string;
|
||||
/** Single-line prompt to send to Claude */
|
||||
prompt: string;
|
||||
/** Current task status */
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
/** Session running this task */
|
||||
assignedSessionId: string | null;
|
||||
/** Links to TaskQueue task ID (for completion tracking) */
|
||||
queueTaskId: string | null;
|
||||
/** Whether this task can run in parallel with siblings */
|
||||
parallel: boolean;
|
||||
/** Unique phrase for completion detection */
|
||||
completionPhrase: string;
|
||||
/** Timeout in milliseconds */
|
||||
timeoutMs: number;
|
||||
/** When task started executing */
|
||||
startedAt: number | null;
|
||||
/** When task completed */
|
||||
completedAt: number | null;
|
||||
/** Error message if task failed */
|
||||
error: string | null;
|
||||
/** Number of retry attempts */
|
||||
retries: number;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Team Strategy
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** How agents are coordinated for a phase */
|
||||
export type TeamStrategy =
|
||||
| { type: 'single' }
|
||||
| { type: 'parallel'; maxSessions: number }
|
||||
| { type: 'team'; config: TeamSetup };
|
||||
|
||||
/** Configuration for team-based phase execution */
|
||||
export interface TeamSetup {
|
||||
/** Prompt to send to the team lead */
|
||||
leadPrompt: string;
|
||||
/** Suggested teammate role descriptions */
|
||||
suggestedTeammates: string[];
|
||||
/** Maximum number of teammates to create */
|
||||
maxTeammates: number;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Verification
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Result of phase verification */
|
||||
export interface VerificationResult {
|
||||
/** Whether all checks passed */
|
||||
passed: boolean;
|
||||
/** Individual verification checks */
|
||||
checks: VerificationCheck[];
|
||||
/** Human-readable summary */
|
||||
summary: string;
|
||||
/** Suggestions for replanning if verification failed */
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
/** A single verification check result */
|
||||
export interface VerificationCheck {
|
||||
/** Type of check performed */
|
||||
type: 'test_command' | 'ai_review' | 'file_check';
|
||||
/** What was checked */
|
||||
description: string;
|
||||
/** Whether this check passed */
|
||||
passed: boolean;
|
||||
/** Command output or review text */
|
||||
output?: string;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Configuration
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** User-configurable orchestrator options */
|
||||
export interface OrchestratorConfig {
|
||||
/** Model to use for plan generation (default: 'opus') */
|
||||
plannerModel: string;
|
||||
/** Whether to run research agent before planning (default: true) */
|
||||
researchEnabled: boolean;
|
||||
/** Auto-approve generated plans without user review (default: false) */
|
||||
autoApprove: boolean;
|
||||
/** Maximum retry attempts per phase (default: 3) */
|
||||
maxPhaseRetries: number;
|
||||
/** Phase execution timeout in ms (default: 1800000 = 30min) */
|
||||
phaseTimeoutMs: number;
|
||||
/** Enable Claude Code agent teams for parallel phases (default: true) */
|
||||
enableTeamAgents: boolean;
|
||||
/** Maximum parallel sessions for task execution (default: 3) */
|
||||
maxParallelSessions: number;
|
||||
/** Verification strictness (default: 'moderate') */
|
||||
verificationMode: 'strict' | 'moderate' | 'lenient';
|
||||
/** Run /compact between phases to manage context (default: true) */
|
||||
compactBetweenPhases: boolean;
|
||||
}
|
||||
|
||||
/** Default orchestrator configuration */
|
||||
export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
|
||||
plannerModel: 'opus',
|
||||
researchEnabled: true,
|
||||
autoApprove: false,
|
||||
maxPhaseRetries: 3,
|
||||
phaseTimeoutMs: 30 * 60 * 1000, // 30 minutes
|
||||
enableTeamAgents: true,
|
||||
maxParallelSessions: 3,
|
||||
verificationMode: 'moderate',
|
||||
compactBetweenPhases: true,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Persistence
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Orchestrator state persisted to ~/.codeman/state.json */
|
||||
export interface OrchestratorPersistState {
|
||||
/** Current state machine state */
|
||||
state: OrchestratorState;
|
||||
/** Generated plan (null before planning) */
|
||||
plan: OrchestratorPlan | null;
|
||||
/** Index of currently executing phase */
|
||||
currentPhaseIndex: number;
|
||||
/** When orchestration started */
|
||||
startedAt: number | null;
|
||||
/** When orchestration completed */
|
||||
completedAt: number | null;
|
||||
/** User configuration */
|
||||
config: OrchestratorConfig;
|
||||
/** Execution statistics */
|
||||
stats: OrchestratorStats;
|
||||
}
|
||||
|
||||
/** Orchestrator execution statistics */
|
||||
export interface OrchestratorStats {
|
||||
/** Number of phases completed successfully */
|
||||
phasesCompleted: number;
|
||||
/** Number of phases that failed (after all retries) */
|
||||
phasesFailed: number;
|
||||
/** Total individual tasks completed */
|
||||
totalTasksCompleted: number;
|
||||
/** Total individual tasks failed */
|
||||
totalTasksFailed: number;
|
||||
/** Total time spent executing (ms) */
|
||||
totalDurationMs: number;
|
||||
/** Number of times replanning was triggered */
|
||||
replanCount: number;
|
||||
}
|
||||
|
||||
/** Factory function for initial orchestrator stats */
|
||||
export function createInitialOrchestratorStats(): OrchestratorStats {
|
||||
return {
|
||||
phasesCompleted: 0,
|
||||
phasesFailed: 0,
|
||||
totalTasksCompleted: 0,
|
||||
totalTasksFailed: 0,
|
||||
totalDurationMs: 0,
|
||||
replanCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Factory function for initial orchestrator persist state */
|
||||
export function createInitialOrchestratorPersistState(
|
||||
config: OrchestratorConfig = DEFAULT_ORCHESTRATOR_CONFIG
|
||||
): OrchestratorPersistState {
|
||||
return {
|
||||
state: 'idle',
|
||||
plan: null,
|
||||
currentPhaseIndex: 0,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
config,
|
||||
stats: createInitialOrchestratorStats(),
|
||||
};
|
||||
}
|
||||
@@ -12,3 +12,4 @@ export type { RespawnPort } from './respawn-port.js';
|
||||
export type { ConfigPort } from './config-port.js';
|
||||
export type { InfraPort, ScheduledRun } from './infra-port.js';
|
||||
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator port — capabilities for orchestrator loop management.
|
||||
* Route modules that interact with the orchestrator depend on this port.
|
||||
*/
|
||||
|
||||
import type { OrchestratorLoop } from '../../orchestrator-loop.js';
|
||||
|
||||
export interface OrchestratorPort {
|
||||
readonly orchestratorLoop: OrchestratorLoop | null;
|
||||
initOrchestratorLoop(): OrchestratorLoop;
|
||||
}
|
||||
@@ -235,8 +235,39 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.PLAN_STARTED, '_onPlanStarted'],
|
||||
[SSE_EVENTS.PLAN_CANCELLED, '_onPlanCancelled'],
|
||||
[SSE_EVENTS.PLAN_COMPLETED, '_onPlanCompleted'],
|
||||
|
||||
// Orchestrator loop
|
||||
[SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED, '_onOrchestratorStateChanged'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_PLAN_PROGRESS, '_onOrchestratorPlanProgress'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_PLAN_READY, '_onOrchestratorPlanReady'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_PHASE_STARTED, '_onOrchestratorPhaseStarted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_PHASE_COMPLETED, '_onOrchestratorPhaseCompleted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_PHASE_FAILED, '_onOrchestratorPhaseFailed'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_VERIFICATION, '_onOrchestratorVerification'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_TASK_ASSIGNED, '_onOrchestratorTaskAssigned'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_TASK_COMPLETED, '_onOrchestratorTaskCompleted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
|
||||
];
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Name Prefix Parser
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Parses w<N>-<caseName> or s<N>-<caseName> prefix from session names.
|
||||
// Returns { prefix, suffix } or null if name does not match the pattern.
|
||||
function parseSessionPrefix(name) {
|
||||
if (!name) return null;
|
||||
const m = name.match(/^(w\d+-[a-zA-Z0-9_-]+|s\d+-[a-zA-Z0-9_-]+)/);
|
||||
if (!m) return null;
|
||||
const prefix = m[1];
|
||||
const rest = name.slice(prefix.length);
|
||||
if (rest === "") return { prefix, suffix: "" };
|
||||
if (rest.startsWith(": ")) return { prefix, suffix: rest.slice(2) };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CodemanApp Class — constructor and global state
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -317,6 +348,10 @@ class CodemanApp {
|
||||
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
|
||||
this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE;
|
||||
this.projectInsightsPanelVisible = false;
|
||||
|
||||
// Orchestrator loop state
|
||||
this.orchestratorState = null; // { state, plan, currentPhaseIndex, stats }
|
||||
this.orchestratorPanelVisible = false;
|
||||
this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization
|
||||
|
||||
// Image popup windows (auto-open for detected screenshots/images)
|
||||
@@ -362,11 +397,8 @@ class CodemanApp {
|
||||
this.flickerFilterActive = false;
|
||||
this.flickerFilterTimeout = null;
|
||||
|
||||
// Render debouncing
|
||||
this.renderSessionTabsTimeout = null;
|
||||
this.renderRalphStatePanelTimeout = null;
|
||||
this.renderTaskPanelTimeout = null;
|
||||
this.renderMuxSessionsTimeout = null;
|
||||
// Render debounce timers (managed by _debouncedCall)
|
||||
this._debounceTimers = Object.create(null);
|
||||
|
||||
// System stats polling
|
||||
this.systemStatsInterval = null;
|
||||
@@ -417,6 +449,25 @@ class CodemanApp {
|
||||
return this._elemCache[id];
|
||||
}
|
||||
|
||||
// Clear a named timeout property: if (this[name]) { clearTimeout(this[name]); this[name] = null; }
|
||||
_clearTimer(timerName) {
|
||||
if (this[timerName]) {
|
||||
clearTimeout(this[timerName]);
|
||||
this[timerName] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a selectSession generation is stale (a newer tab switch has started).
|
||||
// If stale, cleans up buffer-loading state and returns true.
|
||||
_isStaleSelect(selectGen) {
|
||||
if (selectGen !== this._selectGeneration) {
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad();
|
||||
this._restoringFlushedState = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500
|
||||
formatTokens(count) {
|
||||
if (count >= 1000000) {
|
||||
@@ -489,6 +540,9 @@ class CodemanApp {
|
||||
SwipeHandler.init();
|
||||
VoiceInput.init();
|
||||
KeyboardAccessoryBar.init();
|
||||
// Apply keyboard bar mode from settings
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
@@ -573,73 +627,51 @@ class CodemanApp {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
setupEventListeners() {
|
||||
// Keyboard shortcut lookup table — data-driven to avoid 12 separate if-blocks.
|
||||
// Each entry: { key, altKey? (alternative key match), ctrl? (require Ctrl/Cmd),
|
||||
// shift? (require Shift), action }.
|
||||
const SHORTCUTS = [
|
||||
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
||||
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
||||
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
||||
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
||||
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
||||
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
||||
];
|
||||
|
||||
// Use capture to handle before terminal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Don't intercept keys during CJK IME composition
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
|
||||
// Escape - close panels and modals
|
||||
// Escape - close panels and modals (different logic: no preventDefault, no return)
|
||||
if (e.key === 'Escape') {
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + ? - help
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '?' || e.key === '/')) {
|
||||
e.preventDefault();
|
||||
this.showHelp();
|
||||
// Alt+1-9: switch to Codeman session by index
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (idx < this.sessionOrder.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + Enter - quick start
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.quickStart();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + W - close active session
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||||
e.preventDefault();
|
||||
this.killActiveSession();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + Tab - next session
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
this.nextSession();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + K - kill all
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
this.killAllSessions();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + L - clear terminal
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
e.preventDefault();
|
||||
this.clearTerminal();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + Shift + R - restore terminal size (after mobile squeeze)
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'R') {
|
||||
e.preventDefault();
|
||||
this.restoreTerminalSize();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + +/- - font size
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
|
||||
e.preventDefault();
|
||||
this.increaseFontSize();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
|
||||
e.preventDefault();
|
||||
this.decreaseFontSize();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + Shift + V - toggle voice input
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'V') {
|
||||
e.preventDefault();
|
||||
VoiceInput.toggle();
|
||||
// Match against shortcut table
|
||||
for (const s of SHORTCUTS) {
|
||||
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
||||
const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true;
|
||||
const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
|
||||
if (keyMatch && ctrlMatch && shiftMatch) {
|
||||
e.preventDefault();
|
||||
s.action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, true); // Use capture phase to handle before terminal
|
||||
|
||||
@@ -667,10 +699,7 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
// Clear any pending reconnect timeout to prevent duplicate connections
|
||||
if (this.sseReconnectTimeout) {
|
||||
clearTimeout(this.sseReconnectTimeout);
|
||||
this.sseReconnectTimeout = null;
|
||||
}
|
||||
this._clearTimer('sseReconnectTimeout');
|
||||
|
||||
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
|
||||
if (this._sseListenerCleanup) {
|
||||
@@ -727,9 +756,7 @@ class CodemanApp {
|
||||
this.eventSource = null;
|
||||
}
|
||||
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
|
||||
if (this.sseReconnectTimeout) {
|
||||
clearTimeout(this.sseReconnectTimeout);
|
||||
}
|
||||
this._clearTimer('sseReconnectTimeout');
|
||||
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
|
||||
// Fast first retry (200ms) for server-restart case (COM deploy),
|
||||
// then ramp up for real network issues.
|
||||
@@ -867,10 +894,159 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Response Viewer — native-scroll panel for reading full Claude responses
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
|
||||
_sanitizeHtml(html) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const frag = tpl.content;
|
||||
// Remove dangerous elements
|
||||
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
||||
el.remove();
|
||||
}
|
||||
// Strip dangerous attributes from all elements
|
||||
for (const el of frag.querySelectorAll('*')) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
|
||||
const val = attr.value.replace(/\s/g, '').toLowerCase();
|
||||
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Serialize back via a container
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(frag);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||
_renderMarkdown(text) {
|
||||
if (typeof marked !== 'undefined' && marked.parse) {
|
||||
try {
|
||||
return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true }));
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
// Fallback: escape HTML and preserve whitespace
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
const viewer = document.getElementById('responseViewer');
|
||||
const backdrop = document.getElementById('responseViewerBackdrop');
|
||||
if (!viewer) return;
|
||||
|
||||
const isOpen = viewer.classList.contains('visible');
|
||||
if (isOpen) {
|
||||
viewer.classList.remove('visible');
|
||||
backdrop.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeSessionId) return;
|
||||
try {
|
||||
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
|
||||
const data = await res.json();
|
||||
let lastResponse = data.text || '';
|
||||
|
||||
// Source 2: Terminal buffer fallback (strip ANSI codes)
|
||||
if (!lastResponse) {
|
||||
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
||||
const termData = await termRes.json();
|
||||
if (termData.terminalBuffer) {
|
||||
lastResponse = termData.terminalBuffer
|
||||
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b[()][A-Z0-9]/g, '')
|
||||
.replace(/\x1b[>=<]/g, '')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
body.innerHTML = this._renderMarkdown(lastResponse);
|
||||
|
||||
// Reset state for fresh open
|
||||
const title = document.getElementById('responseViewerTitle');
|
||||
const moreBtn = document.getElementById('responseViewerMore');
|
||||
if (title) title.textContent = 'Last Response';
|
||||
if (moreBtn) { moreBtn.style.display = ''; moreBtn.textContent = 'More'; }
|
||||
|
||||
viewer.classList.add('visible');
|
||||
backdrop.classList.add('visible');
|
||||
body.scrollTop = 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to load response:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async loadFullContext() {
|
||||
if (!this.activeSessionId) return;
|
||||
const moreBtn = document.getElementById('responseViewerMore');
|
||||
if (moreBtn) moreBtn.textContent = '...';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`);
|
||||
const data = await res.json();
|
||||
const messages = data.messages || [];
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
const title = document.getElementById('responseViewerTitle');
|
||||
if (!body) return;
|
||||
|
||||
if (messages.length === 0) {
|
||||
body.textContent = 'No conversation history available';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render conversation thread
|
||||
body.innerHTML = '';
|
||||
for (const msg of messages) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'rv-message';
|
||||
|
||||
const role = document.createElement('div');
|
||||
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
|
||||
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
|
||||
div.appendChild(role);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'rv-text';
|
||||
text.innerHTML = this._renderMarkdown(msg.text);
|
||||
div.appendChild(text);
|
||||
|
||||
body.appendChild(div);
|
||||
}
|
||||
|
||||
if (title) title.textContent = `Conversation (${messages.length} messages)`;
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
// Scroll to bottom (latest message)
|
||||
body.scrollTop = body.scrollHeight;
|
||||
} catch (err) {
|
||||
console.error('Failed to load context:', err);
|
||||
} finally {
|
||||
if (moreBtn) moreBtn.textContent = 'More';
|
||||
}
|
||||
}
|
||||
|
||||
async _onSessionNeedsRefresh() {
|
||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||
// so reload the buffer to recover from any display corruption.
|
||||
if (!this.activeSessionId || !this.terminal) return;
|
||||
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
|
||||
if (this._isLoadingBuffer) return;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
const data = await res.json();
|
||||
@@ -894,6 +1070,12 @@ class CodemanApp {
|
||||
|
||||
async _onSessionClearTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
// Skip if selectSession is already loading the buffer — clearTerminal arriving
|
||||
// during buffer load would clear the terminal mid-write, causing visible flicker
|
||||
// and a race between two concurrent chunkedTerminalWrite calls (especially on mobile
|
||||
// where rAF is slower). selectSession will handle the final buffer state.
|
||||
if (this._isLoadingBuffer) return;
|
||||
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||||
@@ -932,15 +1114,7 @@ class CodemanApp {
|
||||
if (data.id === this.activeSessionId) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
|
||||
}
|
||||
const session = this.sessions.get(data.id);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'session-error',
|
||||
sessionId: data.id,
|
||||
sessionName: session?.name || this.getShortId(data.id),
|
||||
title: 'Session Error',
|
||||
message: data.error || 'Unknown error',
|
||||
});
|
||||
this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error');
|
||||
}
|
||||
|
||||
_onSessionExit(data) {
|
||||
@@ -953,14 +1127,7 @@ class CodemanApp {
|
||||
}
|
||||
// Notify on unexpected exit (non-zero code)
|
||||
if (data.code && data.code !== 0) {
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'session-crash',
|
||||
sessionId: data.id,
|
||||
sessionName: session?.name || this.getShortId(data.id),
|
||||
title: 'Session Crashed',
|
||||
message: `Exited with code ${data.code}`,
|
||||
});
|
||||
this._notifySession(data.id, 'critical', 'session-crash', 'Session Crashed', `Exited with code ${data.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,15 +1144,7 @@ class CodemanApp {
|
||||
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
|
||||
clearTimeout(this.idleTimers.get(data.id));
|
||||
this.idleTimers.set(data.id, setTimeout(() => {
|
||||
const s = this.sessions.get(data.id);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'session-stuck',
|
||||
sessionId: data.id,
|
||||
sessionName: s?.name || this.getShortId(data.id),
|
||||
title: 'Session Idle',
|
||||
message: `Idle for ${Math.round(threshold / 60000)}+ minutes`,
|
||||
});
|
||||
this._notifySession(data.id, 'warning', 'session-stuck', 'Session Idle', `Idle for ${Math.round(threshold / 60000)}+ minutes`);
|
||||
this.idleTimers.delete(data.id);
|
||||
}, threshold));
|
||||
}
|
||||
@@ -1016,15 +1175,7 @@ class CodemanApp {
|
||||
this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info');
|
||||
this.updateRespawnTokens(0);
|
||||
}
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'auto-clear',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Auto-Cleared',
|
||||
message: `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
|
||||
}
|
||||
|
||||
_onSessionCliInfo(data) {
|
||||
@@ -1145,10 +1296,7 @@ class CodemanApp {
|
||||
|
||||
/** Close the active WebSocket connection (if any). */
|
||||
_disconnectWs() {
|
||||
if (this._wsReconnectTimer) {
|
||||
clearTimeout(this._wsReconnectTimer);
|
||||
this._wsReconnectTimer = null;
|
||||
}
|
||||
this._clearTimer('_wsReconnectTimer');
|
||||
this._wsReconnectAttempts = 0;
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // Prevent re-entrant cleanup
|
||||
@@ -1284,38 +1432,22 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
handleInit(data) {
|
||||
// Clear the init fallback timer since we got data
|
||||
if (this._initFallbackTimer) {
|
||||
clearTimeout(this._initFallbackTimer);
|
||||
this._initFallbackTimer = null;
|
||||
}
|
||||
const gen = ++this._initGeneration;
|
||||
|
||||
// CJK input form: show/hide based on server env INPUT_CJK_FORM=ON
|
||||
/** Show/hide the CJK input textarea based on user setting or server override */
|
||||
_updateCjkInputState() {
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (cjkEl) {
|
||||
cjkEl.style.display = data.inputCjkForm ? 'block' : 'none';
|
||||
if (!data.inputCjkForm) window.cjkActive = false;
|
||||
}
|
||||
|
||||
// Update version displays (header and toolbar)
|
||||
if (data.version) {
|
||||
const versionEl = this.$('versionDisplay');
|
||||
const headerVersionEl = this.$('headerVersion');
|
||||
if (versionEl) {
|
||||
versionEl.textContent = `v${data.version}`;
|
||||
versionEl.title = `Codeman v${data.version}`;
|
||||
}
|
||||
if (headerVersionEl) {
|
||||
headerVersionEl.textContent = `v${data.version}`;
|
||||
headerVersionEl.title = `Codeman v${data.version}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any active voice recording on reconnect
|
||||
VoiceInput.cleanup();
|
||||
if (!cjkEl) return;
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const showCjk = this._serverCjkOverride || settings.cjkInputEnabled || false;
|
||||
cjkEl.style.display = showCjk ? 'block' : 'none';
|
||||
if (!showCjk) window.cjkActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all app state maps, timers, and handlers to a clean baseline.
|
||||
* Called by handleInit() on SSE reconnect / page reload to prevent
|
||||
* memory leaks and stale data.
|
||||
*/
|
||||
_resetAllAppState() {
|
||||
this.sessions.clear();
|
||||
this.ralphStates.clear();
|
||||
this.terminalBuffers.clear();
|
||||
@@ -1329,21 +1461,17 @@ class CodemanApp {
|
||||
}
|
||||
this.idleTimers.clear();
|
||||
// Clear flicker filter state
|
||||
if (this.flickerFilterTimeout) {
|
||||
clearTimeout(this.flickerFilterTimeout);
|
||||
this.flickerFilterTimeout = null;
|
||||
}
|
||||
this._clearTimer('flickerFilterTimeout');
|
||||
this.flickerFilterBuffer = '';
|
||||
this.flickerFilterActive = false;
|
||||
// Clear pending terminal writes
|
||||
if (this.syncWaitTimeout) {
|
||||
clearTimeout(this.syncWaitTimeout);
|
||||
this.syncWaitTimeout = null;
|
||||
}
|
||||
this._clearTimer('syncWaitTimeout');
|
||||
this.pendingWrites = [];
|
||||
this.writeFrameScheduled = false;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
|
||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||
// Preserve local echo overlay text across SSE reconnect — just hide until
|
||||
// terminal buffer reloads and prompt is visible again. _render() re-scans
|
||||
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
|
||||
@@ -1396,6 +1524,36 @@ class CodemanApp {
|
||||
clearInterval(this.runSummaryAutoRefreshTimer);
|
||||
this.runSummaryAutoRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleInit(data) {
|
||||
// Clear the init fallback timer since we got data
|
||||
this._clearTimer('_initFallbackTimer');
|
||||
const gen = ++this._initGeneration;
|
||||
|
||||
// CJK input form: controlled by user setting (with server env as override)
|
||||
this._serverCjkOverride = data.inputCjkForm || false;
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Update version displays (header and toolbar)
|
||||
if (data.version) {
|
||||
const versionEl = this.$('versionDisplay');
|
||||
const headerVersionEl = this.$('headerVersion');
|
||||
if (versionEl) {
|
||||
versionEl.textContent = `v${data.version}`;
|
||||
versionEl.title = `Codeman v${data.version}`;
|
||||
}
|
||||
if (headerVersionEl) {
|
||||
headerVersionEl.textContent = `v${data.version}`;
|
||||
headerVersionEl.title = `Codeman v${data.version}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any active voice recording on reconnect
|
||||
VoiceInput.cleanup();
|
||||
|
||||
this._resetAllAppState();
|
||||
|
||||
data.sessions.forEach(s => {
|
||||
this.sessions.set(s.id, s);
|
||||
// Load ralph state from session data (only if not explicitly closed by user)
|
||||
@@ -1523,18 +1681,27 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Debounce Utility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Debounce a method call using a named timer key. */
|
||||
_debouncedCall(timerKey, fn, delayMs = 100) {
|
||||
if (this._debounceTimers[timerKey]) {
|
||||
clearTimeout(this._debounceTimers[timerKey]);
|
||||
}
|
||||
this._debounceTimers[timerKey] = setTimeout(() => {
|
||||
this._debounceTimers[timerKey] = null;
|
||||
fn.call(this);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Tabs
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderSessionTabs() {
|
||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||||
if (this.renderSessionTabsTimeout) {
|
||||
clearTimeout(this.renderSessionTabsTimeout);
|
||||
}
|
||||
this.renderSessionTabsTimeout = setTimeout(() => {
|
||||
this._renderSessionTabsImmediate();
|
||||
}, 100);
|
||||
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
||||
}
|
||||
|
||||
/** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */
|
||||
@@ -1599,7 +1766,12 @@ class CodemanApp {
|
||||
// Update name if changed
|
||||
const nameEl = tab.querySelector('.tab-name');
|
||||
if (nameEl && nameEl.textContent !== name) {
|
||||
nameEl.textContent = name;
|
||||
const _p = parseSessionPrefix(name);
|
||||
if (_p && _p.suffix) {
|
||||
nameEl.innerHTML = '<span class="tab-prefix">' + escapeHtml(_p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(_p.suffix) + '</span>';
|
||||
} else {
|
||||
nameEl.textContent = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Update task badge
|
||||
@@ -1707,7 +1879,7 @@ class CodemanApp {
|
||||
<span class="tab-info">
|
||||
<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>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${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>
|
||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||
</span>
|
||||
@@ -1960,17 +2132,25 @@ class CodemanApp {
|
||||
return this.getShortId(session.id);
|
||||
}
|
||||
|
||||
async selectSession(sessionId) {
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
const _selStart = performance.now();
|
||||
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
||||
_crashDiag.log(`SELECT: ${_selName}`);
|
||||
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
|
||||
|
||||
const selectGen = ++this._selectGeneration;
|
||||
|
||||
if (selectGen !== this._selectGeneration) return; // newer tab switch won
|
||||
_notifySession(sessionId, urgency, category, title, message) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency,
|
||||
category,
|
||||
sessionId,
|
||||
sessionName: session?.name || this.getShortId(sessionId),
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up state from the previous session before switching tabs.
|
||||
* Handles: WebSocket teardown, CJK clear, flicker filter, tab completion,
|
||||
* terminal write queue, IME composition, and local echo flush.
|
||||
* @param {string} newSessionId - The session being switched TO.
|
||||
*/
|
||||
_cleanupPreviousSession(newSessionId) {
|
||||
// Close WebSocket for previous session (new one opens after buffer load)
|
||||
this._disconnectWs();
|
||||
|
||||
@@ -1979,10 +2159,7 @@ class CodemanApp {
|
||||
if (cjkEl) cjkEl.value = '';
|
||||
|
||||
// Clean up flicker filter state when switching sessions
|
||||
if (this.flickerFilterTimeout) {
|
||||
clearTimeout(this.flickerFilterTimeout);
|
||||
this.flickerFilterTimeout = null;
|
||||
}
|
||||
this._clearTimer('flickerFilterTimeout');
|
||||
this.flickerFilterBuffer = '';
|
||||
this.flickerFilterActive = false;
|
||||
|
||||
@@ -1990,18 +2167,19 @@ class CodemanApp {
|
||||
this._tabCompletionSessionId = null;
|
||||
this._tabCompletionRetries = 0;
|
||||
this._tabCompletionBaseText = null;
|
||||
if (this._tabCompletionFallback) { clearTimeout(this._tabCompletionFallback); this._tabCompletionFallback = null; }
|
||||
if (this._clientDropRecoveryTimer) { clearTimeout(this._clientDropRecoveryTimer); this._clientDropRecoveryTimer = null; }
|
||||
this._clearTimer('_tabCompletionFallback');
|
||||
this._clearTimer('_clientDropRecoveryTimer');
|
||||
|
||||
// Clean up pending terminal writes to prevent old session data from appearing in new session
|
||||
if (this.syncWaitTimeout) {
|
||||
clearTimeout(this.syncWaitTimeout);
|
||||
this.syncWaitTimeout = null;
|
||||
}
|
||||
this._clearTimer('syncWaitTimeout');
|
||||
this.pendingWrites = [];
|
||||
this.writeFrameScheduled = false;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
// Abort any in-flight chunkedTerminalWrite from the previous session.
|
||||
// Without this, old rAF-scheduled chunks continue writing stale data
|
||||
// into the terminal, interleaving with the new session's buffer.
|
||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||
// End any in-flight IME composition.
|
||||
// iOS Safari keeps autocorrect composing; switching tabs without ending it
|
||||
// leaves xterm's _compositionHelper._isComposing stuck true, which blocks
|
||||
@@ -2043,9 +2221,29 @@ class CodemanApp {
|
||||
// Only sessions with prior flushed text (from tab-switch-away) need detection.
|
||||
// After the user's first Enter, clear() resets _bufferDetectDone = false,
|
||||
// re-enabling detection for tab completion and other legitimate cases.
|
||||
if (this._localEchoOverlay && !this._flushedOffsets?.has(sessionId)) {
|
||||
if (this._localEchoOverlay && !this._flushedOffsets?.has(newSessionId)) {
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
}
|
||||
}
|
||||
|
||||
async selectSession(sessionId) {
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||
// After the first await the gesture context is lost and focus() is silently
|
||||
// ignored, leaving the keyboard unable to send input to the terminal.
|
||||
if (this.terminal) this.terminal.focus();
|
||||
|
||||
const _selStart = performance.now();
|
||||
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
||||
_crashDiag.log(`SELECT: ${_selName}`);
|
||||
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
|
||||
|
||||
const selectGen = ++this._selectGeneration;
|
||||
|
||||
if (selectGen !== this._selectGeneration) return; // newer tab switch won
|
||||
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||
this.hideWelcome();
|
||||
@@ -2087,8 +2285,9 @@ class CodemanApp {
|
||||
|
||||
// Track working directory for path normalization in Project Insights
|
||||
this.currentSessionWorkingDir = session?.workingDir || null;
|
||||
if (session && session.pid === null && session.status === 'idle') {
|
||||
// This is a restored session - attach to the existing screen/shell
|
||||
if (session && session.pid === null && !session._ended) {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
try {
|
||||
const endpoint = session.mode === 'shell'
|
||||
? `/api/sessions/${sessionId}/shell`
|
||||
@@ -2118,23 +2317,36 @@ class CodemanApp {
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
try {
|
||||
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
|
||||
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
|
||||
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
|
||||
// Fit terminal to container BEFORE writing any buffer data.
|
||||
// If the browser was resized while viewing another session, the terminal
|
||||
// canvas may be at stale dimensions — content would render at wrong width.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
|
||||
// Instant cache restore for IDLE sessions only.
|
||||
// For busy sessions, the cache is always stale — writing it first causes a
|
||||
// jarring double-render: stale content appears, then the terminal flashes
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
if (cachedBuffer) {
|
||||
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
||||
if (cachedBuffer && !sessionIsBusy) {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
await this.chunkedTerminalWrite(cachedBuffer);
|
||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
||||
if (this._isStaleSelect(selectGen)) return;
|
||||
this.terminal.scrollToBottom();
|
||||
_crashDiag.log('CACHE_DONE');
|
||||
} else if (sessionIsBusy) {
|
||||
// Clear stale content immediately — fresh buffer is being fetched
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||
}
|
||||
|
||||
_crashDiag.log('FETCH_START');
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
||||
if (this._isStaleSelect(selectGen)) return;
|
||||
const data = await res.json();
|
||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||
|
||||
@@ -2153,7 +2365,7 @@ class CodemanApp {
|
||||
}
|
||||
// Use chunked write for large buffers to avoid UI jank
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
||||
if (this._isStaleSelect(selectGen)) return;
|
||||
// Ensure terminal is scrolled to bottom after buffer load
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
@@ -2171,9 +2383,9 @@ class CodemanApp {
|
||||
this.terminal.reset();
|
||||
}
|
||||
|
||||
// Buffer load complete — unblock live SSE writes and flush any queued events.
|
||||
// chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped
|
||||
// the chunked write (small buffer, cache hit, or empty), we must call it here.
|
||||
// Buffer load complete — unblock live SSE writes (queued events are discarded
|
||||
// to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
|
||||
// internally, but if we skipped the write (cache hit or empty), call it here.
|
||||
if (this._isLoadingBuffer) {
|
||||
this._finishBufferLoad();
|
||||
}
|
||||
@@ -2197,9 +2409,19 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget resize — don't await to avoid blocking UI.
|
||||
// The resize triggers an Ink redraw in Claude which streams back via SSE.
|
||||
this.sendResize(sessionId);
|
||||
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
|
||||
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
|
||||
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
|
||||
// triggers a full Ink redraw which overwrites all stale frame content.
|
||||
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
|
||||
this.sendResize(sessionId).then(() => {
|
||||
if (selectGen !== this._selectGeneration) return;
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// Defer secondary panel updates so they don't block the main thread
|
||||
// after terminal content is already visible.
|
||||
@@ -2550,9 +2772,7 @@ class CodemanApp {
|
||||
updateTokens() {
|
||||
// Debounce at 200ms — token display is non-critical and shouldn't
|
||||
// compete with input handling on the main thread
|
||||
if (this._updateTokensTimeout) {
|
||||
clearTimeout(this._updateTokensTimeout);
|
||||
}
|
||||
this._clearTimer('_updateTokensTimeout');
|
||||
this._updateTokensTimeout = setTimeout(() => {
|
||||
this._updateTokensTimeout = null;
|
||||
this._updateTokensImmediate();
|
||||
|
||||
@@ -277,6 +277,26 @@ const SSE_EVENTS = {
|
||||
PLAN_STARTED: 'plan:started',
|
||||
PLAN_CANCELLED: 'plan:cancelled',
|
||||
PLAN_COMPLETED: 'plan:completed',
|
||||
|
||||
// Orchestrator Loop
|
||||
ORCHESTRATOR_STATE_CHANGED: 'orchestrator:stateChanged',
|
||||
ORCHESTRATOR_PLAN_PROGRESS: 'orchestrator:planProgress',
|
||||
ORCHESTRATOR_PLAN_READY: 'orchestrator:planReady',
|
||||
ORCHESTRATOR_PHASE_STARTED: 'orchestrator:phaseStarted',
|
||||
ORCHESTRATOR_PHASE_COMPLETED: 'orchestrator:phaseCompleted',
|
||||
ORCHESTRATOR_PHASE_FAILED: 'orchestrator:phaseFailed',
|
||||
ORCHESTRATOR_VERIFICATION: 'orchestrator:verification',
|
||||
ORCHESTRATOR_TASK_ASSIGNED: 'orchestrator:taskAssigned',
|
||||
ORCHESTRATOR_TASK_COMPLETED: 'orchestrator:taskCompleted',
|
||||
ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed',
|
||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||
|
||||
// Cases
|
||||
CASE_CREATED: 'case:created',
|
||||
CASE_LINKED: 'case:linked',
|
||||
CASE_DELETED: 'case:deleted',
|
||||
CASE_ORDER_CHANGED: 'case:order-changed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -6,6 +6,9 @@
|
||||
<meta name="description" content="Claude Code session manager with web interface">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="google" content="notranslate">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="apple-touch-icon" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<title>Codeman</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
|
||||
@@ -26,17 +29,18 @@
|
||||
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||
<script defer src="vendor/marked.min.js"></script>
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#0a0a0a}
|
||||
.skeleton-header{height:40px;background:#111;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b}
|
||||
.skeleton-header{height:40px;background:rgba(19,19,22,0.85);border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
|
||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||
.skeleton-tab{width:80px;height:24px;background:#1a1a2e;border-radius:4px}
|
||||
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
|
||||
.skeleton-terminal{flex:1;background:#0d0d0d}
|
||||
.skeleton-toolbar{height:32px;background:#111;border-top:1px solid #1a1a2e}
|
||||
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)}
|
||||
.app-loaded .loading-skeleton{display:none}
|
||||
</style>
|
||||
</head>
|
||||
@@ -92,6 +96,7 @@
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header" 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-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
|
||||
<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
@@ -137,6 +142,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orchestrator Loop Panel -->
|
||||
<div class="orchestrator-panel" id="orchestratorPanel" style="display: none;">
|
||||
<div class="orchestrator-header">
|
||||
<span class="orchestrator-title">Orchestrator</span>
|
||||
<span class="orchestrator-state-badge" id="orchestratorStateBadge">idle</span>
|
||||
<div class="orchestrator-actions" id="orchestratorActions"></div>
|
||||
<button class="orchestrator-close-btn" onclick="app.closeOrchestratorPanel()" title="Close">×</button>
|
||||
</div>
|
||||
<div class="orchestrator-body" id="orchestratorBody"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ralph / Todo Tracker Panel -->
|
||||
<div class="ralph-panel collapsed" id="ralphStatePanel" style="display: none;">
|
||||
<!-- Collapsed Summary Bar -->
|
||||
@@ -320,6 +336,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
|
||||
<div class="response-viewer" id="responseViewer">
|
||||
<div class="response-viewer-header">
|
||||
<span id="responseViewerTitle">Last Response</span>
|
||||
<div class="response-viewer-actions">
|
||||
<button class="response-viewer-more" id="responseViewerMore" onclick="app.loadFullContext()">More</button>
|
||||
<button class="response-viewer-close" onclick="app.toggleResponseViewer()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response-viewer-body" id="responseViewerBody"></div>
|
||||
</div>
|
||||
<div class="response-viewer-backdrop" id="responseViewerBackdrop" onclick="app.toggleResponseViewer()"></div>
|
||||
|
||||
<!-- Bottom Toolbar -->
|
||||
<footer class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
@@ -373,6 +402,11 @@
|
||||
<span>Agent Teams</span>
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for new sessions in this case</span>
|
||||
<label class="checkbox-inline" style="margin-top: 6px;">
|
||||
<input type="checkbox" id="caseOpusContext1m" onchange="app.onCaseSettingChanged()">
|
||||
<span>1M Opus Context</span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window for new sessions</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button + gear -->
|
||||
@@ -397,6 +431,11 @@
|
||||
<span>Agent Teams</span>
|
||||
</label>
|
||||
<span class="form-hint">Enable Agent Teams for new sessions</span>
|
||||
<label class="checkbox-inline" style="margin-top: 6px;">
|
||||
<input type="checkbox" id="caseOpusContext1mMobile" onchange="app.onCaseSettingChangedMobile()">
|
||||
<span>1M Opus Context</span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -422,6 +461,8 @@
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -436,10 +477,9 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Run Claude</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>K</kbd></div><div>Kill All Sessions + Tmux</div>
|
||||
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
@@ -636,7 +676,10 @@
|
||||
<div class="form-section-header">Appearance</div>
|
||||
<div class="form-row">
|
||||
<label>Session Name</label>
|
||||
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span id="modalSessionPrefix" style="color: var(--text-muted); white-space: nowrap; display: none;"></span>
|
||||
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()" style="flex: 1;">
|
||||
</div>
|
||||
<span class="form-hint">Custom name shown in the tab (right-click tab to rename inline)</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -858,6 +901,27 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" title="Show a dedicated input field below the terminal for CJK (Chinese/Japanese/Korean) IME composition. Recommended for mobile devices with Chinese input methods where xterm's native input handling may drop characters.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">CJK Input</span>
|
||||
<span class="settings-item-desc">Dedicated IME input field for CJK languages</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsCjkInput">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-item settings-item-multiline" title="Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Extended Keyboard Bar</span>
|
||||
<span class="settings-item-desc">Extra keys: Tab, Esc, arrows, Ctrl+O</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsExtendedKeyboardBar">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Displays Section -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
@@ -1023,6 +1087,14 @@
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>1M Opus Context</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsOpusContext1m">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
|
||||
</div>
|
||||
<!-- Nice Priority Section -->
|
||||
<div class="form-section-header">Nice Priority</div>
|
||||
<div class="form-row form-row-switch">
|
||||
@@ -1044,6 +1116,7 @@
|
||||
<div class="form-row">
|
||||
<label>Default Model</label>
|
||||
<select id="appSettingsDefaultModel" class="form-select">
|
||||
<option value="">Default (CLI default)</option>
|
||||
<option value="opus">Opus (Most capable)</option>
|
||||
<option value="sonnet">Sonnet (Balanced)</option>
|
||||
<option value="haiku">Haiku (Fast & cheap)</option>
|
||||
@@ -1318,6 +1391,7 @@
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
||||
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Create New Tab -->
|
||||
@@ -1345,6 +1419,13 @@
|
||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Manage Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-manage">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-toolbar" onclick="app.closeCreateCaseModal()">Cancel</button>
|
||||
@@ -1704,6 +1785,7 @@
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
<script defer src="settings-ui.js"></script>
|
||||
<script defer src="panels-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
|
||||
@@ -3,10 +3,36 @@
|
||||
*
|
||||
* Always-visible textarea below the terminal (in index.html).
|
||||
* The browser handles IME composition natively — we just read
|
||||
* textarea.value on Enter and send it to PTY.
|
||||
* textarea.value and send it to PTY.
|
||||
* While this textarea has focus, window.cjkActive = true blocks xterm's onData.
|
||||
* Arrow keys and function keys are forwarded to PTY directly.
|
||||
*
|
||||
* ## Android IME challenge
|
||||
*
|
||||
* Android virtual keyboards (WeChat, Sogou, Gboard in Chinese mode) use
|
||||
* composition for EVERYTHING — including English prediction and punctuation.
|
||||
* This means compositionstart fires even for English text, and compositionend
|
||||
* may not fire until the user explicitly confirms (space, candidate tap).
|
||||
*
|
||||
* We use InputEvent.inputType to distinguish:
|
||||
* - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin)
|
||||
* - `insertText`: final committed text (confirmed word, punctuation, space)
|
||||
*
|
||||
* During composition, `insertText` events are flushed immediately (punctuation,
|
||||
* English words confirmed by IME). `insertCompositionText` waits for
|
||||
* compositionend (CJK candidate selection).
|
||||
*
|
||||
* ## Phantom character for Android backspace
|
||||
*
|
||||
* Android virtual keyboards don't generate key-repeat keydown events for held
|
||||
* keys. When the textarea is empty, backspace produces no `input` event either
|
||||
* (nothing to delete). We keep a zero-width space (U+200B) "phantom" in the
|
||||
* textarea at all times. Backspace deletes the phantom → `input` fires with
|
||||
* `deleteContentBackward` → we send \x7f to PTY and restore the phantom.
|
||||
* Long-press backspace generates rapid deleteContentBackward events, each
|
||||
* handled the same way — giving continuous deletion at the keyboard's native
|
||||
* repeat rate.
|
||||
*
|
||||
* @dependency index.html (#cjkInput textarea)
|
||||
* @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData
|
||||
* @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js
|
||||
@@ -17,10 +43,12 @@ const CjkInput = (() => {
|
||||
let _textarea = null;
|
||||
let _send = null;
|
||||
let _initialized = false;
|
||||
let _onMousedown = null;
|
||||
let _onFocus = null;
|
||||
let _onBlur = null;
|
||||
let _onKeydown = null;
|
||||
let _composing = false;
|
||||
const _listeners = {};
|
||||
|
||||
// Zero-width space: always present in textarea so Android backspace has
|
||||
// something to delete, triggering the `input` event we need to detect it.
|
||||
const PHANTOM = '\u200B';
|
||||
|
||||
const PASSTHROUGH_KEYS = {
|
||||
ArrowUp: '\x1b[A',
|
||||
@@ -36,66 +64,176 @@ const CjkInput = (() => {
|
||||
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
||||
};
|
||||
|
||||
/** Strip phantom characters from a string */
|
||||
function _strip(str) {
|
||||
return str.replace(/\u200B/g, '');
|
||||
}
|
||||
|
||||
/** Reset textarea to phantom-only state with cursor at end */
|
||||
function _resetToPhantom() {
|
||||
_textarea.value = PHANTOM;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
|
||||
/** Check if textarea contains only phantom(s) or is empty — no real user text */
|
||||
function _isEffectivelyEmpty() {
|
||||
return !_strip(_textarea.value);
|
||||
}
|
||||
|
||||
/** Flush textarea: send real text to PTY and reset to phantom */
|
||||
function _flush() {
|
||||
const val = _strip(_textarea.value);
|
||||
if (val) {
|
||||
_send(val);
|
||||
}
|
||||
_resetToPhantom();
|
||||
}
|
||||
|
||||
return {
|
||||
init({ send }) {
|
||||
// Guard against double-init: remove previous listeners
|
||||
if (_initialized) this.destroy();
|
||||
|
||||
_send = send;
|
||||
_composing = false;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
|
||||
_onMousedown = (e) => { e.stopPropagation(); };
|
||||
_onFocus = () => { window.cjkActive = true; };
|
||||
_onBlur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _onMousedown);
|
||||
_textarea.addEventListener('focus', _onFocus);
|
||||
_textarea.addEventListener('blur', _onBlur);
|
||||
// Seed the phantom character
|
||||
_resetToPhantom();
|
||||
|
||||
_onKeydown = (e) => {
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
_listeners.focus = () => {
|
||||
window.cjkActive = true;
|
||||
// Restore phantom if textarea was emptied while blurred
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||
_textarea.addEventListener('focus', _listeners.focus);
|
||||
_textarea.addEventListener('blur', _listeners.blur);
|
||||
|
||||
// Enter: send accumulated text (or bare Enter if empty)
|
||||
// ── Composition tracking ──
|
||||
_listeners.compositionstart = () => {
|
||||
_composing = true;
|
||||
// Clear phantom so IME sees a clean textarea — some IMEs include
|
||||
// existing text in the composition region which would corrupt input.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
}
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_composing = false;
|
||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||
setTimeout(_flush, 0);
|
||||
};
|
||||
_textarea.addEventListener('compositionstart', _listeners.compositionstart);
|
||||
_textarea.addEventListener('compositionend', _listeners.compositionend);
|
||||
|
||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||
_listeners.keydown = (e) => {
|
||||
// Enter: flush accumulated text (or bare Enter if empty).
|
||||
// No isComposing guard — Android IMEs set isComposing=true for English
|
||||
// prediction, but Enter should ALWAYS send. We preventDefault to stop
|
||||
// the IME from also handling Enter (which could double-send or do nothing).
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (_textarea.value) {
|
||||
_send(_textarea.value + '\r');
|
||||
_textarea.value = '';
|
||||
_composing = false;
|
||||
const val = _strip(_textarea.value);
|
||||
if (val) {
|
||||
_send(val + '\r');
|
||||
} else {
|
||||
_send('\r');
|
||||
}
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear textarea
|
||||
// Escape: clear textarea (always works)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_textarea.value = '';
|
||||
_composing = false;
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl combos: forward to PTY
|
||||
// Ctrl combos: forward to PTY (always works)
|
||||
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
||||
e.preventDefault();
|
||||
_send(CTRL_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace: delete from textarea if has text, else forward to PTY
|
||||
if (e.key === 'Backspace' && !_textarea.value) {
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME)
|
||||
if (_composing) return;
|
||||
|
||||
// Backspace: forward to PTY when no real text in textarea
|
||||
// (Desktop path — Android uses the input event + phantom approach)
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow/function keys: forward to PTY when textarea is empty
|
||||
if (PASSTHROUGH_KEYS[e.key] && !_textarea.value) {
|
||||
// Arrow/function keys: forward to PTY when no real text
|
||||
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(PASSTHROUGH_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single printable character: send immediately to PTY
|
||||
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
return;
|
||||
}
|
||||
};
|
||||
_textarea.addEventListener('keydown', _onKeydown);
|
||||
_textarea.addEventListener('keydown', _listeners.keydown);
|
||||
|
||||
// ── Input event: the primary path for Android virtual keyboards ──
|
||||
// Android sends keyCode 229 + key "Unidentified" for virtual key presses,
|
||||
// making keydown unreliable. input fires AFTER character insertion and
|
||||
// carries inputType which tells us whether the text is final or tentative.
|
||||
_listeners.input = (e) => {
|
||||
// ── Backspace / delete detection ──
|
||||
// Android long-press backspace generates rapid deleteContentBackward events.
|
||||
// The phantom character ensures the textarea is never truly empty, so each
|
||||
// press/repeat fires an input event that we can catch here.
|
||||
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
|
||||
if (_isEffectivelyEmpty()) {
|
||||
// No real text left — forward backspace to PTY
|
||||
_send('\x7f');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
// User is editing their own text in the textarea — let it be.
|
||||
// Ensure phantom is still present for the NEXT backspace.
|
||||
if (!_textarea.value.startsWith(PHANTOM)) {
|
||||
_textarea.value = PHANTOM + _textarea.value;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_composing) {
|
||||
// insertText during composition = IME committed final text
|
||||
// (e.g., punctuation key inserts 。directly, or IME confirms a word).
|
||||
// Flush immediately — this text won't change.
|
||||
if (e.inputType === 'insertText') {
|
||||
_flush();
|
||||
return;
|
||||
}
|
||||
// insertCompositionText = IME is still working (pinyin, candidates,
|
||||
// English prediction). Wait for compositionend to flush.
|
||||
return;
|
||||
}
|
||||
// Outside composition: send immediately
|
||||
_flush();
|
||||
};
|
||||
_textarea.addEventListener('input', _listeners.input);
|
||||
|
||||
_initialized = true;
|
||||
return this;
|
||||
@@ -103,13 +241,13 @@ const CjkInput = (() => {
|
||||
|
||||
destroy() {
|
||||
if (_textarea) {
|
||||
if (_onMousedown) _textarea.removeEventListener('mousedown', _onMousedown);
|
||||
if (_onFocus) _textarea.removeEventListener('focus', _onFocus);
|
||||
if (_onBlur) _textarea.removeEventListener('blur', _onBlur);
|
||||
if (_onKeydown) _textarea.removeEventListener('keydown', _onKeydown);
|
||||
for (const [event, handler] of Object.entries(_listeners)) {
|
||||
if (handler) _textarea.removeEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_onMousedown = _onFocus = _onBlur = _onKeydown = null;
|
||||
_composing = false;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
|
||||
@@ -33,16 +33,10 @@
|
||||
*/
|
||||
const KeyboardAccessoryBar = {
|
||||
element: null,
|
||||
_mode: 'simple', // 'simple' or 'extended'
|
||||
|
||||
/** Create and inject the accessory bar */
|
||||
init() {
|
||||
// Only on mobile
|
||||
if (!MobileDetection.isTouchDevice()) return;
|
||||
|
||||
// Create accessory bar element
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'keyboard-accessory-bar';
|
||||
this.element.innerHTML = `
|
||||
/** HTML for simple mode: arrows, commands, paste, dismiss */
|
||||
_simpleButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
@@ -66,8 +60,59 @@ const KeyboardAccessoryBar = {
|
||||
<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"/>
|
||||
</svg>
|
||||
</button>`,
|
||||
|
||||
/** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
|
||||
_extendedButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-right" title="Arrow right">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
|
||||
<button class="accessory-btn" data-action="opt-enter" title="Option+Enter (newline)">⌥Enter</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="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">
|
||||
<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"/>
|
||||
</svg>
|
||||
</button>`,
|
||||
|
||||
/** Create and inject the accessory bar */
|
||||
init() {
|
||||
// Only on mobile
|
||||
if (!MobileDetection.isTouchDevice()) return;
|
||||
|
||||
// Create accessory bar element
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'keyboard-accessory-bar';
|
||||
this.element.innerHTML = this._simpleButtons;
|
||||
|
||||
// Add click handlers — preventDefault stops event from reaching terminal
|
||||
this.element.addEventListener('click', (e) => {
|
||||
@@ -80,7 +125,8 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
if ((action === 'scroll-up' || action === 'scroll-down') ||
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
@@ -95,6 +141,14 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Switch between 'simple' and 'extended' button layouts */
|
||||
setMode(mode) {
|
||||
if (mode === this._mode || !this.element) return;
|
||||
this._mode = mode;
|
||||
this.clearConfirm();
|
||||
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
},
|
||||
|
||||
_confirmTimer: null,
|
||||
_confirmAction: null,
|
||||
|
||||
@@ -109,6 +163,27 @@ const KeyboardAccessoryBar = {
|
||||
case 'scroll-down':
|
||||
this.sendKey('\x1b[B');
|
||||
break;
|
||||
case 'arrow-left':
|
||||
this.sendKey('\x1b[D');
|
||||
break;
|
||||
case 'arrow-right':
|
||||
this.sendKey('\x1b[C');
|
||||
break;
|
||||
case 'esc':
|
||||
this.sendKey('\x1b');
|
||||
break;
|
||||
case 'opt-enter':
|
||||
this.sendKey('\x1b\r');
|
||||
break;
|
||||
case 'tab':
|
||||
this.sendKey('\t');
|
||||
break;
|
||||
case 'shift-tab':
|
||||
this.sendKey('\x1b[Z');
|
||||
break;
|
||||
case 'ctrl-o':
|
||||
this.sendKey('\x0f');
|
||||
break;
|
||||
case 'init':
|
||||
this.sendCommand('/init');
|
||||
break;
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
{
|
||||
"name": "Codeman",
|
||||
"short_name": "Codeman",
|
||||
"description": "Claude Code session manager",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a"
|
||||
"theme_color": "#0a0a0a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,15 +36,19 @@
|
||||
const MobileDetection = {
|
||||
/** Check if device supports touch input */
|
||||
isTouchDevice() {
|
||||
return 'ontouchstart' in window ||
|
||||
return (
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
|
||||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
|
||||
);
|
||||
},
|
||||
|
||||
/** Check if device is iOS (iPhone, iPad, iPod) */
|
||||
isIOS() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
return (
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
);
|
||||
},
|
||||
|
||||
/** Check if browser is Safari */
|
||||
@@ -77,7 +81,14 @@ const MobileDetection = {
|
||||
const isTouch = this.isTouchDevice();
|
||||
|
||||
// Remove existing device classes
|
||||
body.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser');
|
||||
body.classList.remove(
|
||||
'device-mobile',
|
||||
'device-tablet',
|
||||
'device-desktop',
|
||||
'touch-device',
|
||||
'ios-device',
|
||||
'safari-browser'
|
||||
);
|
||||
|
||||
// Add current device class
|
||||
body.classList.add(`device-${deviceType}`);
|
||||
@@ -100,8 +111,12 @@ const MobileDetection = {
|
||||
|
||||
/** Set --app-height CSS variable from visual viewport.
|
||||
* On iPad Safari with tabs, 100vh extends behind the tab bar.
|
||||
* visualViewport.height reflects the actual visible area. */
|
||||
* visualViewport.height reflects the actual visible area.
|
||||
* Skips when virtual keyboard is open — KeyboardHandler manages
|
||||
* layout via translateY + paddingBottom; shrinking --app-height
|
||||
* would double-count and leave zero space for the terminal. */
|
||||
updateAppHeight() {
|
||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
|
||||
const vh = window.visualViewport?.height || window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-height', `${vh}px`);
|
||||
},
|
||||
@@ -149,7 +164,7 @@ const MobileDetection = {
|
||||
this._gestureStartHandler = null;
|
||||
this._gestureChangeHandler = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -198,6 +213,17 @@ const KeyboardHandler = {
|
||||
// Also handle scroll (iOS scrolls viewport when keyboard appears)
|
||||
window.visualViewport.addEventListener('scroll', this._viewportScrollHandler);
|
||||
}
|
||||
|
||||
// Prevent page-level scroll when keyboard is visible.
|
||||
// iOS Safari scrolls the document to bring xterm's hidden textarea into
|
||||
// view when the user types, pushing the entire UI off-screen. The CSS
|
||||
// position:fixed on .app prevents most cases, but reset as a safety net.
|
||||
this._windowScrollHandler = () => {
|
||||
if (this.keyboardVisible) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', this._windowScrollHandler);
|
||||
},
|
||||
|
||||
/** Remove event listeners */
|
||||
@@ -214,6 +240,10 @@ const KeyboardHandler = {
|
||||
window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler);
|
||||
this._viewportScrollHandler = null;
|
||||
}
|
||||
if (this._windowScrollHandler) {
|
||||
window.removeEventListener('scroll', this._windowScrollHandler);
|
||||
this._windowScrollHandler = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Handle viewport resize (keyboard show/hide) */
|
||||
@@ -225,6 +255,10 @@ const KeyboardHandler = {
|
||||
if (heightDiff > 150 && !this.keyboardVisible) {
|
||||
this.keyboardVisible = true;
|
||||
document.body.classList.add('keyboard-visible');
|
||||
// Restore --app-height: MobileDetection's resize listener fires before ours
|
||||
// and may have already shrunk it for the keyboard viewport change.
|
||||
// Use initialViewportHeight (captured before keyboard opened).
|
||||
document.documentElement.style.setProperty('--app-height', `${this.initialViewportHeight}px`);
|
||||
this.onKeyboardShow();
|
||||
}
|
||||
// Keyboard hidden (viewport grew back close to initial)
|
||||
@@ -234,6 +268,9 @@ const KeyboardHandler = {
|
||||
this.keyboardVisible = false;
|
||||
document.body.classList.remove('keyboard-visible');
|
||||
this.onKeyboardHide();
|
||||
// Re-sync --app-height now that keyboard is gone (MobileDetection skipped
|
||||
// updates while keyboardVisible was true)
|
||||
MobileDetection.updateAppHeight();
|
||||
}
|
||||
|
||||
// Update baseline when keyboard is not visible — adapts to address bar
|
||||
@@ -261,35 +298,32 @@ const KeyboardHandler = {
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
if (this.keyboardVisible) {
|
||||
// Calculate keyboard offset
|
||||
// Calculate how far the toolbar (position:fixed, bottom:0) needs to
|
||||
// translate up so it sits at the bottom of the visual viewport.
|
||||
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
|
||||
// when the user types in xterm's hidden textarea.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = layoutHeight - visualBottom;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
// Safety: if keyboard is supposedly visible but offset is 0 or negative,
|
||||
// the keyboard is actually gone — force dismiss. This catches cases where
|
||||
// visualViewport.resize fires late or with intermediate values on iOS.
|
||||
if (keyboardOffset <= 0) {
|
||||
this.keyboardVisible = false;
|
||||
document.body.classList.remove('keyboard-visible');
|
||||
this.onKeyboardHide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Move toolbar up above keyboard
|
||||
// Move toolbar and accessory bar above keyboard.
|
||||
// When keyboardOffset is 0 (viewport scrolled to layout bottom),
|
||||
// the bars are naturally positioned via their CSS bottom values —
|
||||
// just clear the transforms. Never dismiss keyboard state here;
|
||||
// that's handleViewportResize's job.
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = `translateY(${-keyboardOffset}px)`;
|
||||
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
|
||||
// Move accessory bar up (it sits above toolbar)
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = `translateY(${-keyboardOffset}px)`;
|
||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
|
||||
// Shrink main content area so terminal doesn't extend behind keyboard
|
||||
// Account for keyboard height + toolbar height (40px) + accessory bar (44px)
|
||||
if (main) {
|
||||
main.style.paddingBottom = `${keyboardOffset + 94}px`;
|
||||
// Shrink main content area so terminal doesn't extend behind keyboard.
|
||||
// Use stable keyboard height (not scroll-dependent) for padding.
|
||||
// 84px = toolbar (40px) + accessory bar (44px).
|
||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||
if (main && keyboardHeight > 0) {
|
||||
main.style.paddingBottom = `${keyboardHeight + 84}px`;
|
||||
}
|
||||
} else {
|
||||
this.resetLayout();
|
||||
@@ -320,6 +354,10 @@ const KeyboardHandler = {
|
||||
KeyboardAccessoryBar.show();
|
||||
}
|
||||
|
||||
// Reset any page scroll that occurred during keyboard open.
|
||||
// iOS Safari may scroll the document to reveal xterm's hidden textarea.
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Refit terminal locally AND send resize to server so Claude Code (Ink)
|
||||
// knows the actual terminal dimensions. Without this, Ink redraws at the
|
||||
// old (larger) row count when the user types, causing content to scroll
|
||||
@@ -328,11 +366,21 @@ const KeyboardHandler = {
|
||||
// while keyboard is up — this one-shot resize on open/close is sufficient.
|
||||
setTimeout(() => {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
if (app.fitAddon) try { app.fitAddon.fit(); } catch {}
|
||||
if (app.fitAddon)
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
// Eliminate terminal row quantization gap: xterm can only show whole
|
||||
// rows, so leftover pixels create dead space below the last row.
|
||||
// Shrink .main's paddingBottom by the gap so the terminal fills flush
|
||||
// to the accessory bar.
|
||||
this._shrinkPaddingToFit();
|
||||
app.terminal.scrollToBottom();
|
||||
// Send resize to server so PTY dimensions match xterm
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
// Reset again after fit/resize in case layout changes triggered scroll
|
||||
window.scrollTo(0, 0);
|
||||
}, 150);
|
||||
|
||||
// Reposition subagent windows to stack from bottom (above keyboard)
|
||||
@@ -351,7 +399,9 @@ const KeyboardHandler = {
|
||||
// Refit terminal, scroll to bottom, and send resize to restore original dimensions
|
||||
setTimeout(() => {
|
||||
if (typeof app !== 'undefined' && app.fitAddon) {
|
||||
try { app.fitAddon.fit(); } catch {}
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
if (app.terminal) app.terminal.scrollToBottom();
|
||||
// Send resize to server to restore full terminal size
|
||||
this._sendTerminalResize();
|
||||
@@ -374,12 +424,37 @@ const KeyboardHandler = {
|
||||
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows })
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
/**
|
||||
* Shrink .main paddingBottom to eliminate the terminal row quantization gap.
|
||||
* xterm can only render whole rows, so fractional-row pixels create dead
|
||||
* space below the last row. After fitAddon.fit(), measure the gap and
|
||||
* reduce padding by that amount so the terminal sits flush against the bars.
|
||||
*/
|
||||
_shrinkPaddingToFit() {
|
||||
try {
|
||||
const container = document.getElementById('terminalContainer');
|
||||
const main = document.querySelector('.main');
|
||||
if (!container || !main || typeof app === 'undefined' || !app.terminal) return;
|
||||
const cellH = app.terminal._core?._renderService?.dimensions?.css?.cell?.height;
|
||||
if (!cellH) return;
|
||||
const gap = container.clientHeight - app.terminal.rows * cellH;
|
||||
if (gap > 0 && gap < cellH) {
|
||||
const currentPadding = parseInt(main.style.paddingBottom) || 0;
|
||||
main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px';
|
||||
if (app.fitAddon)
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
/** Check if element is an input that triggers keyboard (excludes terminal) */
|
||||
isInputElement(el) {
|
||||
if (!el) return false;
|
||||
@@ -397,11 +472,7 @@ const KeyboardHandler = {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return (
|
||||
tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
el.isContentEditable
|
||||
);
|
||||
return tagName === 'input' || tagName === 'textarea' || el.isContentEditable;
|
||||
},
|
||||
|
||||
/** Scroll input into view above the keyboard */
|
||||
@@ -427,7 +498,7 @@ const KeyboardHandler = {
|
||||
// For page-level - use scrollIntoView
|
||||
input.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -442,8 +513,8 @@ const SwipeHandler = {
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startTime: 0,
|
||||
minSwipeDistance: 80, // Minimum pixels for a valid swipe
|
||||
maxSwipeTime: 300, // Maximum ms for a swipe gesture
|
||||
minSwipeDistance: 80, // Minimum pixels for a valid swipe
|
||||
maxSwipeTime: 300, // Maximum ms for a swipe gesture
|
||||
maxVerticalDrift: 100, // Max vertical movement allowed
|
||||
|
||||
_touchStartHandler: null,
|
||||
@@ -494,9 +565,9 @@ const SwipeHandler = {
|
||||
const deltaX = endX - this.startX;
|
||||
const deltaY = Math.abs(endY - this.startY);
|
||||
|
||||
if (elapsed > this.maxSwipeTime) return; // Too slow
|
||||
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
|
||||
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
|
||||
if (elapsed > this.maxSwipeTime) return; // Too slow
|
||||
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
|
||||
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
|
||||
|
||||
// Valid swipe detected
|
||||
if (deltaX > 0) {
|
||||
@@ -506,5 +577,5 @@ const SwipeHandler = {
|
||||
// Swipe left -> next session
|
||||
if (typeof app !== 'undefined') app.nextSession();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -65,13 +65,14 @@ html.mobile-init .file-browser-panel {
|
||||
max-height: calc(48px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
/* Add top margin to main content to account for fixed header */
|
||||
.main {
|
||||
margin-top: 54px;
|
||||
/* Push ALL content below fixed header (not just .main) so banners
|
||||
(respawn, timer, orchestrator) between header and main are visible */
|
||||
.app {
|
||||
padding-top: 54px;
|
||||
}
|
||||
|
||||
.ios-device .main {
|
||||
margin-top: calc(54px + var(--safe-area-top));
|
||||
.ios-device .app {
|
||||
padding-top: calc(54px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
/* Font controls - smaller on tablet, visibility controlled by JS */
|
||||
@@ -235,6 +236,37 @@ html.mobile-init .file-browser-panel {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ---- Respawn Banner: Tablet Optimizations ---- */
|
||||
|
||||
.respawn-banner {
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.respawn-compact-layout {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.respawn-status-row1 {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.respawn-action-log {
|
||||
max-height: 2.6em;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.respawn-countdown-timer {
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
/* Respawn timer bar — slimmer on tablet */
|
||||
.respawn-timer-bar {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
/* Show desktop voice button on tablet (hidden by max-width:1023px in styles.css,
|
||||
mobile .btn-voice-mobile only shows at <430px) */
|
||||
.toolbar-center .btn-toolbar.btn-voice {
|
||||
@@ -269,9 +301,15 @@ html.mobile-init .file-browser-panel {
|
||||
Phone Breakpoint (<430px)
|
||||
============================================================================ */
|
||||
@media (max-width: 430px) {
|
||||
/* Hide header brand on phones */
|
||||
/* Compact header brand on phones — acts as home button */
|
||||
.header-brand {
|
||||
display: none;
|
||||
padding-right: 0.25rem;
|
||||
margin-right: 0.2rem;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.header-brand .logo {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Font controls - compact on phones, visibility controlled by JS */
|
||||
@@ -327,13 +365,29 @@ html.mobile-init .file-browser-panel {
|
||||
max-height: calc(36px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
/* Add top margin to main content to account for fixed header */
|
||||
.main {
|
||||
margin-top: 42px;
|
||||
/* Push ALL content below fixed header (not just .main) so banners
|
||||
(respawn, timer, orchestrator) between header and main are visible.
|
||||
Bottom padding for the fixed toolbar (40px) so terminal content
|
||||
doesn't extend behind it. JS overrides paddingBottom on .main
|
||||
when keyboard is visible, and resetLayout() clears the inline
|
||||
style to re-expose this CSS value. */
|
||||
.app {
|
||||
padding-top: 42px;
|
||||
}
|
||||
|
||||
.ios-device .main {
|
||||
margin-top: calc(42px + var(--safe-area-top));
|
||||
.ios-device .app {
|
||||
padding-top: calc(42px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
|
||||
browser's bottom bar. Match that offset in main's padding so the terminal
|
||||
doesn't extend behind the toolbar. */
|
||||
.ios-device.safari-browser .main {
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -428,6 +482,18 @@ html.mobile-init .file-browser-panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* When keyboard is visible, pin the app container to the viewport.
|
||||
iOS Safari scrolls the page to bring the focused input (xterm's hidden
|
||||
textarea) into view, even with overflow:hidden. position:fixed prevents
|
||||
the browser from scrolling the document under the app. */
|
||||
.keyboard-visible .app {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Ultra-compact session tabs — .tabs-two-rows override needed to match
|
||||
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
|
||||
.session-tabs,
|
||||
@@ -534,6 +600,17 @@ html.mobile-init .file-browser-panel {
|
||||
bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
|
||||
}
|
||||
|
||||
/* When keyboard is visible the JS translateY already accounts for the full
|
||||
distance from the visual-viewport bottom to the layout-viewport bottom
|
||||
(keyboard + Safari bar). Remove the CSS Safari-bar offset to avoid
|
||||
double-counting, which otherwise creates a visible gap above the keyboard. */
|
||||
.keyboard-visible.ios-device.safari-browser .toolbar {
|
||||
bottom: var(--safe-area-bottom);
|
||||
}
|
||||
.keyboard-visible.ios-device.safari-browser .keyboard-accessory-bar {
|
||||
bottom: calc(var(--safe-area-bottom) + 40px);
|
||||
}
|
||||
|
||||
/* Show case selector in center */
|
||||
.toolbar-center {
|
||||
display: flex !important;
|
||||
@@ -815,7 +892,8 @@ html.mobile-init .file-browser-panel {
|
||||
padding-right: calc(8px + var(--safe-area-right));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
transition: transform 0.15s ease-out;
|
||||
will-change: transform;
|
||||
@@ -825,10 +903,16 @@ html.mobile-init .file-browser-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留滑动能力 */
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
@@ -859,31 +943,33 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #1e3a5f;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #93c5fd;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
padding: 8px 14px;
|
||||
background: #2a2a2a;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
max-width: 100px;
|
||||
padding: 10px 8px;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3a3a3a;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Voice preview — positioned above accessory bar on mobile */
|
||||
@@ -915,9 +1001,9 @@ html.mobile-init .file-browser-panel {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.paste-dialog {
|
||||
@@ -927,13 +1013,12 @@ html.mobile-init .file-browser-panel {
|
||||
padding: 12px;
|
||||
width: calc(100% - 24px);
|
||||
max-width: 400px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.paste-textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
max-height: 120px;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
background: var(--bg-primary, #0d0d14);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
@@ -1093,40 +1178,40 @@ html.mobile-init .file-browser-panel {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile terminal - native touch scrolling via xterm-viewport */
|
||||
/* Mobile terminal — JS touch handler (terminal.scrollLines()) drives
|
||||
scrollback because xterm.js DOM renderer doesn't populate xterm-viewport's
|
||||
scroll area. touch-action:none lets our JS handler own the gesture. */
|
||||
.terminal-container {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible; /* Must not be hidden — blocks touch scroll on viewport */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.terminal-container .xterm {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
/* xterm-viewport is the scrollable element inside xterm.js.
|
||||
It must sit above xterm-screen to receive touch events.
|
||||
See: https://github.com/xtermjs/xterm.js/issues/5377 */
|
||||
.terminal-container .xterm-viewport {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 10 !important;
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-y: scroll !important;
|
||||
overflow: visible;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.terminal-container .xterm,
|
||||
.terminal-container .xterm-viewport,
|
||||
.terminal-container .xterm-screen {
|
||||
touch-action: pan-y;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Response viewer — show eye icon in header on mobile */
|
||||
.btn-response-viewer-header {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
padding-bottom: var(--safe-area-bottom, 0px);
|
||||
}
|
||||
|
||||
.response-viewer-body {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Compact welcome overlay for mobile */
|
||||
.welcome-content {
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
@@ -1192,31 +1277,70 @@ html.mobile-init .file-browser-panel {
|
||||
/* ---- Respawn Banner: Mobile Optimizations ---- */
|
||||
|
||||
.respawn-banner {
|
||||
padding: 0.4rem 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Stack banner vertically on mobile — status on top, action log below */
|
||||
.respawn-compact-layout {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
/* Let status column shrink to fit */
|
||||
.respawn-status-col {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
/* Wrap status row items — tokens/timer may go to second line */
|
||||
/* Row1: wrap allowed, key info prioritized via order */
|
||||
.respawn-status-row1 {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
gap: 0.25rem;
|
||||
row-gap: 0.1rem;
|
||||
}
|
||||
|
||||
/* Stop button — 44px touch target */
|
||||
/* State label — smaller on phone, stays first */
|
||||
.respawn-state {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
|
||||
/* Confidence badge — compact */
|
||||
.respawn-banner .detection-confidence {
|
||||
font-size: 0.5rem;
|
||||
padding: 0.02rem 0.2rem;
|
||||
}
|
||||
|
||||
/* Cycles — compact */
|
||||
.respawn-cycles {
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
/* Run timer — compact */
|
||||
.respawn-timer {
|
||||
font-size: 0.55rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Tokens — compact, truncate if long */
|
||||
.respawn-tokens {
|
||||
font-size: 0.55rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* Spinner icon — smaller on phone */
|
||||
.respawn-indicator {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Stop button — 36px touch target, pushed right */
|
||||
.respawn-banner .btn-icon-only {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
@@ -1226,26 +1350,50 @@ html.mobile-init .file-browser-panel {
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Action log — horizontal strip below status, border on top instead of left */
|
||||
.respawn-action-log {
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(34, 197, 94, 0.2);
|
||||
padding-left: 0;
|
||||
padding-top: 0.25rem;
|
||||
max-height: 2em;
|
||||
font-size: 0.6rem;
|
||||
/* Row2: tighter spacing, smaller text */
|
||||
.respawn-status-row2 {
|
||||
font-size: 0.55rem;
|
||||
gap: 0.3rem;
|
||||
min-height: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Countdown timers — tighter on mobile */
|
||||
/* Detection badges — smaller on phone */
|
||||
.respawn-banner .detection-hook,
|
||||
.respawn-banner .detection-ai-check,
|
||||
.respawn-banner .detection-status {
|
||||
font-size: 0.55rem;
|
||||
padding: 0.02rem 0.2rem;
|
||||
}
|
||||
|
||||
/* Countdown timers — inline, no progress bars */
|
||||
.respawn-countdown-timers {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.respawn-countdown-timer {
|
||||
font-size: 0.5rem;
|
||||
padding: 0.02rem 0.2rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
/* Hide progress bars on phone — values are enough */
|
||||
.respawn-countdown-timer .respawn-timer-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Action log — single-line strip below status, show latest entry only */
|
||||
.respawn-action-log {
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(34, 197, 94, 0.15);
|
||||
padding-left: 0;
|
||||
padding-top: 0.15rem;
|
||||
max-height: 1.4em;
|
||||
font-size: 0.55rem;
|
||||
padding: 0.05rem 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Session Options Modal: Respawn Tab Mobile Optimizations ---- */
|
||||
@@ -1271,28 +1419,27 @@ html.mobile-init .file-browser-panel {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Duration preset buttons — single row, all 7 items */
|
||||
/* Duration preset buttons — grid layout, 4 columns for even spacing */
|
||||
.duration-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.duration-preset-btn {
|
||||
min-height: 28px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
min-height: 32px;
|
||||
padding: 0.2rem 0.25rem;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Custom duration — inline with other presets */
|
||||
/* Custom duration — spans full row below */
|
||||
.duration-custom {
|
||||
flex-wrap: wrap;
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex: 1 1 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duration-custom .duration-preset-btn {
|
||||
@@ -1310,24 +1457,23 @@ html.mobile-init .file-browser-panel {
|
||||
font-size: 16px; /* Prevents iOS zoom */
|
||||
}
|
||||
|
||||
/* Preset selector row — compact inline */
|
||||
/* Preset selector row — full-width dropdown, buttons below */
|
||||
.preset-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.preset-selector select {
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
grid-column: 1 / -1;
|
||||
min-height: 32px;
|
||||
font-size: 16px; /* Prevents iOS zoom */
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.preset-selector .btn {
|
||||
flex: 1;
|
||||
min-height: 28px;
|
||||
min-height: 32px;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
@@ -1563,11 +1709,9 @@ html.mobile-init .file-browser-panel {
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
|
||||
/* When keyboard is visible, adjust content to account for moved toolbar */
|
||||
.keyboard-visible .terminal-container {
|
||||
/* Reduce terminal height when keyboard is up so it doesn't overlap toolbar */
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
/* When keyboard is visible, the JS paddingBottom on .main already reserves
|
||||
space for toolbar + accessory bar. No extra padding needed here — it was
|
||||
double-counting and creating dead space between terminal and the bars. */
|
||||
|
||||
/* Ensure modals scroll properly when keyboard is visible */
|
||||
.keyboard-visible .modal-body {
|
||||
@@ -1952,7 +2096,8 @@ html.mobile-init .file-browser-panel {
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
@@ -1960,10 +2105,15 @@ html.mobile-init .file-browser-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
@@ -1993,31 +2143,33 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #1e3a5f;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #93c5fd;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
padding: 8px 14px;
|
||||
background: #2a2a2a;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
max-width: 80px;
|
||||
padding: 10px 8px;
|
||||
background: #334d6e;
|
||||
border-color: rgba(100, 150, 200, 0.4);
|
||||
color: #c0d4e8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
stroke-width: 3;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3a3a3a;
|
||||
background: #3d5f85;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
@@ -2028,14 +2180,8 @@ html.mobile-init .file-browser-panel {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.ios-device.safari-browser .terminal-container {
|
||||
/* Keep momentum scrolling but prevent page bounce */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ios-device.safari-browser .terminal-container .xterm-viewport {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* JS touch handler now drives terminal scrollback — no native scroll needed.
|
||||
-webkit-overflow-scrolling and overflow-y:scroll on xterm-viewport removed. */
|
||||
|
||||
/* ============================================================================
|
||||
Hover State Fallbacks for Touch
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator loop panel — plan-based autonomous execution UI.
|
||||
* Shows orchestrator state, plan phases, task progress, and verification results.
|
||||
* Provides controls for start, approve, reject, pause, resume, stop, skip, retry.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, this.orchestratorState)
|
||||
* @dependency constants.js (SSE_EVENTS, escapeHtml)
|
||||
* @loadorder 9.5 of 16 — loaded after ralph-panel.js, before settings-ui.js
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State color/label mappings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const ORCH_STATE_COLORS = {
|
||||
idle: '#6b7280',
|
||||
planning: '#f59e0b',
|
||||
approval: '#8b5cf6',
|
||||
executing: '#3b82f6',
|
||||
verifying: '#06b6d4',
|
||||
replanning: '#f97316',
|
||||
completed: '#22c55e',
|
||||
failed: '#ef4444',
|
||||
paused: '#9ca3af',
|
||||
};
|
||||
|
||||
const ORCH_PHASE_STATUS_ICONS = {
|
||||
pending: '\u25cb', // ○
|
||||
executing: '\u25d4', // ◔
|
||||
passed: '\u2713', // ✓
|
||||
failed: '\u2717', // ✗
|
||||
skipped: '\u2192', // →
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SSE Event Handlers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_onOrchestratorStateChanged(data) {
|
||||
if (!this.orchestratorState) this.orchestratorState = {};
|
||||
this.orchestratorState.state = data.state;
|
||||
if (data.state === 'planning') {
|
||||
this.orchestratorState.planProgress = [];
|
||||
this.showOrchestratorPanel();
|
||||
}
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorPlanProgress(data) {
|
||||
if (!this.orchestratorState) this.orchestratorState = {};
|
||||
if (!this.orchestratorState.planProgress) this.orchestratorState.planProgress = [];
|
||||
this.orchestratorState.planProgress.push({ phase: data.phase, detail: data.detail, time: Date.now() });
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorPlanReady(data) {
|
||||
if (!this.orchestratorState) this.orchestratorState = {};
|
||||
this.orchestratorState.plan = data.plan;
|
||||
this.orchestratorState.state = 'approval';
|
||||
this.showOrchestratorPanel();
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorPhaseStarted(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorPhase(data.phase);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorPhaseCompleted(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorPhase(data.phase);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorPhaseFailed(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorPhase(data.phase);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorVerification(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
// Store verification result on the phase
|
||||
if (this.orchestratorState.plan) {
|
||||
const phase = this.orchestratorState.plan.phases.find(p => p.id === data.phaseId);
|
||||
if (phase) phase._lastVerification = data.result;
|
||||
}
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorTaskAssigned(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorTask(data.task);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorTaskCompleted(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorTask(data.task);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorTaskFailed(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorTask(data.task);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorCompleted(data) {
|
||||
if (!this.orchestratorState) this.orchestratorState = {};
|
||||
this.orchestratorState.state = 'completed';
|
||||
this.orchestratorState.stats = data.stats;
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorError(data) {
|
||||
if (!this.orchestratorState) this.orchestratorState = {};
|
||||
this.orchestratorState.state = 'failed';
|
||||
this.orchestratorState.lastError = data.error;
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal helpers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_updateOrchestratorPhase(updatedPhase) {
|
||||
if (!this.orchestratorState?.plan) return;
|
||||
const idx = this.orchestratorState.plan.phases.findIndex(p => p.id === updatedPhase.id);
|
||||
if (idx >= 0) this.orchestratorState.plan.phases[idx] = updatedPhase;
|
||||
},
|
||||
|
||||
_updateOrchestratorTask(updatedTask) {
|
||||
if (!this.orchestratorState?.plan) return;
|
||||
for (const phase of this.orchestratorState.plan.phases) {
|
||||
const idx = phase.tasks.findIndex(t => t.id === updatedTask.id);
|
||||
if (idx >= 0) {
|
||||
phase.tasks[idx] = updatedTask;
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Panel visibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
showOrchestratorPanel() {
|
||||
this.orchestratorPanelVisible = true;
|
||||
const panel = document.getElementById('orchestratorPanel');
|
||||
if (panel) panel.style.display = '';
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
closeOrchestratorPanel() {
|
||||
this.orchestratorPanelVisible = false;
|
||||
const panel = document.getElementById('orchestratorPanel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
},
|
||||
|
||||
toggleOrchestratorPanel() {
|
||||
if (this.orchestratorPanelVisible) {
|
||||
this.closeOrchestratorPanel();
|
||||
} else {
|
||||
this.showOrchestratorPanel();
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// API calls
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async orchestratorStart(goal, config) {
|
||||
try {
|
||||
const res = await fetch('/api/orchestrator/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ goal, config }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
this.orchestratorState = { state: 'planning', plan: null };
|
||||
this.showOrchestratorPanel();
|
||||
this.renderOrchestratorPanel();
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Start failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorApprove() {
|
||||
try {
|
||||
await fetch('/api/orchestrator/approve', { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Approve failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorReject(feedback) {
|
||||
try {
|
||||
await fetch('/api/orchestrator/reject', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ feedback }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Reject failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorPause() {
|
||||
try {
|
||||
await fetch('/api/orchestrator/pause', { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Pause failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorResume() {
|
||||
try {
|
||||
await fetch('/api/orchestrator/resume', { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Resume failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorStop() {
|
||||
try {
|
||||
await fetch('/api/orchestrator/stop', { method: 'POST' });
|
||||
this.orchestratorState = { state: 'idle' };
|
||||
this.renderOrchestratorPanel();
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Stop failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorSkipPhase(phaseId) {
|
||||
try {
|
||||
await fetch(`/api/orchestrator/phase/${phaseId}/skip`, { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Skip failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorRetryPhase(phaseId) {
|
||||
try {
|
||||
await fetch(`/api/orchestrator/phase/${phaseId}/retry`, { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Retry failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async refreshOrchestratorStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/orchestrator/status');
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
this.orchestratorState = data;
|
||||
this.renderOrchestratorPanel();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Status fetch failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Rendering
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderOrchestratorPanel() {
|
||||
const panel = document.getElementById('orchestratorPanel');
|
||||
if (!panel) return;
|
||||
|
||||
const state = this.orchestratorState?.state || 'idle';
|
||||
const plan = this.orchestratorState?.plan;
|
||||
|
||||
// Update badge
|
||||
const badge = document.getElementById('orchestratorStateBadge');
|
||||
if (badge) {
|
||||
badge.textContent = state;
|
||||
badge.style.background = ORCH_STATE_COLORS[state] || '#6b7280';
|
||||
}
|
||||
|
||||
// Update action buttons
|
||||
const actions = document.getElementById('orchestratorActions');
|
||||
if (actions) {
|
||||
actions.innerHTML = this._renderOrchestratorActions(state);
|
||||
}
|
||||
|
||||
// Update body
|
||||
const body = document.getElementById('orchestratorBody');
|
||||
if (body) {
|
||||
body.innerHTML = this._renderOrchestratorBody(state, plan);
|
||||
}
|
||||
},
|
||||
|
||||
_renderOrchestratorActions(state) {
|
||||
const btn = (label, onclick, cls = '') =>
|
||||
`<button class="orch-btn ${cls}" onclick="${onclick}">${label}</button>`;
|
||||
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
case 'completed':
|
||||
case 'failed':
|
||||
return btn('New Goal', 'app.promptOrchestratorGoal()', 'orch-btn-primary');
|
||||
case 'planning':
|
||||
return btn('Cancel', 'app.orchestratorStop()', 'orch-btn-danger');
|
||||
case 'approval':
|
||||
return [
|
||||
btn('Approve', 'app.orchestratorApprove()', 'orch-btn-primary'),
|
||||
btn('Reject', 'app.promptOrchestratorReject()', 'orch-btn-warn'),
|
||||
btn('Cancel', 'app.orchestratorStop()', 'orch-btn-danger'),
|
||||
].join('');
|
||||
case 'executing':
|
||||
case 'verifying':
|
||||
case 'replanning':
|
||||
return [
|
||||
btn('Pause', 'app.orchestratorPause()'),
|
||||
btn('Stop', 'app.orchestratorStop()', 'orch-btn-danger'),
|
||||
].join('');
|
||||
case 'paused':
|
||||
return [
|
||||
btn('Resume', 'app.orchestratorResume()', 'orch-btn-primary'),
|
||||
btn('Stop', 'app.orchestratorStop()', 'orch-btn-danger'),
|
||||
].join('');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
_renderOrchestratorBody(state, plan) {
|
||||
if (state === 'idle' && !plan) {
|
||||
return '<div class="orch-empty">No orchestration active. Click "New Goal" to start.</div>';
|
||||
}
|
||||
|
||||
if (state === 'planning') {
|
||||
const progress = this.orchestratorState?.planProgress || [];
|
||||
let progressHtml = '';
|
||||
if (progress.length > 0) {
|
||||
const items = progress.map(p =>
|
||||
`<div class="orch-progress-item"><span class="orch-progress-phase">${escapeHtml(p.phase)}</span> ${escapeHtml(p.detail)}</div>`
|
||||
).join('');
|
||||
progressHtml = `<div class="orch-progress-log">${items}</div>`;
|
||||
}
|
||||
return `<div class="orch-planning"><div class="orch-spinner"></div>Generating plan...${progressHtml}</div>`;
|
||||
}
|
||||
|
||||
if (!plan) return '';
|
||||
|
||||
const parts = [];
|
||||
|
||||
// Goal
|
||||
parts.push(`<div class="orch-goal"><strong>Goal:</strong> ${escapeHtml(plan.goal.slice(0, 200))}</div>`);
|
||||
|
||||
// Progress summary
|
||||
const completed = plan.phases.filter(p => p.status === 'passed' || p.status === 'skipped').length;
|
||||
const total = plan.phases.length;
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
parts.push(`<div class="orch-progress-bar"><div class="orch-progress-fill" style="width:${pct}%"></div><span>${completed}/${total} phases</span></div>`);
|
||||
|
||||
// Phase list
|
||||
parts.push('<div class="orch-phases">');
|
||||
for (const phase of plan.phases) {
|
||||
parts.push(this._renderOrchestratorPhase(phase, state));
|
||||
}
|
||||
parts.push('</div>');
|
||||
|
||||
// Stats (if completed/failed)
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
const stats = this.orchestratorState?.stats;
|
||||
if (stats) {
|
||||
parts.push(this._renderOrchestratorStats(stats));
|
||||
}
|
||||
if (this.orchestratorState?.lastError) {
|
||||
parts.push(`<div class="orch-error">Error: ${escapeHtml(this.orchestratorState.lastError)}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
_renderOrchestratorPhase(phase, orchState) {
|
||||
const icon = ORCH_PHASE_STATUS_ICONS[phase.status] || '\u25cb';
|
||||
const isActive = phase.status === 'executing';
|
||||
const cls = `orch-phase ${isActive ? 'orch-phase-active' : ''} orch-phase-${phase.status}`;
|
||||
|
||||
let actions = '';
|
||||
if (orchState === 'executing' || orchState === 'failed') {
|
||||
if (phase.status === 'pending') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
|
||||
}
|
||||
if (phase.status === 'failed') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Task summary
|
||||
const tasksDone = phase.tasks.filter(t => t.status === 'completed').length;
|
||||
const tasksFailed = phase.tasks.filter(t => t.status === 'failed').length;
|
||||
const tasksTotal = phase.tasks.length;
|
||||
const taskSummary = `${tasksDone}/${tasksTotal}${tasksFailed > 0 ? ` (${tasksFailed} failed)` : ''}`;
|
||||
|
||||
// Duration
|
||||
let duration = '';
|
||||
if (phase.durationMs) {
|
||||
const secs = Math.round(phase.durationMs / 1000);
|
||||
duration = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
||||
}
|
||||
|
||||
let html = `<div class="${cls}">`;
|
||||
html += `<div class="orch-phase-header">`;
|
||||
html += `<span class="orch-phase-icon">${icon}</span>`;
|
||||
html += `<span class="orch-phase-name">${escapeHtml(phase.name)}</span>`;
|
||||
html += `<span class="orch-phase-tasks">${taskSummary}</span>`;
|
||||
if (duration) html += `<span class="orch-phase-duration">${duration}</span>`;
|
||||
if (actions) html += `<span class="orch-phase-actions">${actions}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
// Expanded task list for active phase
|
||||
if (isActive || phase.status === 'failed') {
|
||||
html += '<div class="orch-phase-tasks-list">';
|
||||
for (const task of phase.tasks) {
|
||||
const taskIcon = ORCH_PHASE_STATUS_ICONS[task.status] || '\u25cb';
|
||||
const taskCls = `orch-task orch-task-${task.status}`;
|
||||
html += `<div class="${taskCls}"><span class="orch-task-icon">${taskIcon}</span>`;
|
||||
html += `<span class="orch-task-prompt">${escapeHtml(task.prompt.slice(0, 100))}</span>`;
|
||||
if (task.error) html += `<span class="orch-task-error">${escapeHtml(task.error.slice(0, 80))}</span>`;
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Verification result
|
||||
if (phase._lastVerification) {
|
||||
const v = phase._lastVerification;
|
||||
const vCls = v.passed ? 'orch-verify-pass' : 'orch-verify-fail';
|
||||
html += `<div class="${vCls}">${v.passed ? 'Verified' : 'Failed'}: ${escapeHtml(v.summary || '')}</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
_renderOrchestratorStats(stats) {
|
||||
return `<div class="orch-stats">
|
||||
<span>Phases: ${stats.phasesCompleted} done, ${stats.phasesFailed} failed</span>
|
||||
<span>Tasks: ${stats.totalTasksCompleted} done, ${stats.totalTasksFailed} failed</span>
|
||||
${stats.replanCount > 0 ? `<span>Replans: ${stats.replanCount}</span>` : ''}
|
||||
${stats.totalDurationMs ? `<span>Duration: ${Math.round(stats.totalDurationMs / 60000)}m</span>` : ''}
|
||||
</div>`;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// User prompts
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
promptOrchestratorGoal() {
|
||||
const goal = prompt('Enter your goal for the orchestrator:');
|
||||
if (goal && goal.trim()) {
|
||||
this.orchestratorStart(goal.trim());
|
||||
}
|
||||
},
|
||||
|
||||
promptOrchestratorReject() {
|
||||
const feedback = prompt('Feedback on the plan (what should change?):');
|
||||
if (feedback && feedback.trim()) {
|
||||
this.orchestratorReject(feedback.trim());
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -14,6 +14,13 @@
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
_addActivityEntry(agentId, entry, maxSize = 50) {
|
||||
const activity = this.subagentActivity.get(agentId) || [];
|
||||
activity.push(entry);
|
||||
if (activity.length > maxSize) activity.shift();
|
||||
this.subagentActivity.set(agentId, activity);
|
||||
},
|
||||
|
||||
// Tasks
|
||||
_onTaskCreated(data) {
|
||||
this.renderSessionTabs();
|
||||
@@ -96,16 +103,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Find which Codeman session owns this subagent (direct claudeSessionId match only)
|
||||
this.findParentSessionForSubagent(data.agentId);
|
||||
|
||||
// Auto-open window for new active agents — but ONLY if they belong to a Codeman session tab.
|
||||
// Agents from external Claude sessions (not managed by Codeman) should not pop up.
|
||||
if (data.status === 'active') {
|
||||
const agentForCheck = this.subagents.get(data.agentId);
|
||||
const hasMatchingTab = agentForCheck?.sessionId &&
|
||||
Array.from(this.sessions.values()).some(s => s.claudeSessionId === agentForCheck.sessionId);
|
||||
if (hasMatchingTab) {
|
||||
this.openSubagentWindow(data.agentId);
|
||||
}
|
||||
}
|
||||
// Subagent windows are no longer auto-opened on discovery.
|
||||
// Users can open them manually from the monitor panel or via openAllActiveSubagentWindows().
|
||||
|
||||
// Ensure connection lines are updated after window is created and DOM settles
|
||||
requestAnimationFrame(() => {
|
||||
@@ -114,15 +113,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Notify about new subagent discovery
|
||||
const parentId = this.subagentParentMap.get(data.agentId);
|
||||
const parentSession = parentId ? this.sessions.get(parentId) : null;
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'subagent-spawn',
|
||||
sessionId: parentId || data.sessionId,
|
||||
sessionName: parentSession?.name || parentId || data.sessionId,
|
||||
title: 'Subagent Spawned',
|
||||
message: data.description || 'New background agent started',
|
||||
});
|
||||
this._notifySession(parentId || data.sessionId, 'info', 'subagent-spawn', 'Subagent Spawned', data.description || 'New background agent started');
|
||||
},
|
||||
|
||||
_onSubagentUpdated(data) {
|
||||
@@ -143,10 +134,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onSubagentToolCall(data) {
|
||||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||||
activity.push({ type: 'tool', ...data });
|
||||
if (activity.length > 50) activity.shift(); // Keep last 50 entries
|
||||
this.subagentActivity.set(data.agentId, activity);
|
||||
this._addActivityEntry(data.agentId, { type: 'tool', ...data });
|
||||
if (this.activeSubagentId === data.agentId) {
|
||||
this.renderSubagentDetail();
|
||||
}
|
||||
@@ -158,10 +146,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onSubagentProgress(data) {
|
||||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||||
activity.push({ type: 'progress', ...data });
|
||||
if (activity.length > 50) activity.shift();
|
||||
this.subagentActivity.set(data.agentId, activity);
|
||||
this._addActivityEntry(data.agentId, { type: 'progress', ...data });
|
||||
if (this.activeSubagentId === data.agentId) {
|
||||
this.renderSubagentDetail();
|
||||
}
|
||||
@@ -172,10 +157,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onSubagentMessage(data) {
|
||||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||||
activity.push({ type: 'message', ...data });
|
||||
if (activity.length > 50) activity.shift();
|
||||
this.subagentActivity.set(data.agentId, activity);
|
||||
this._addActivityEntry(data.agentId, { type: 'message', ...data });
|
||||
if (this.activeSubagentId === data.agentId) {
|
||||
this.renderSubagentDetail();
|
||||
}
|
||||
@@ -198,10 +180,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Add to activity stream
|
||||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||||
activity.push({ type: 'tool_result', ...data });
|
||||
if (activity.length > 50) activity.shift();
|
||||
this.subagentActivity.set(data.agentId, activity);
|
||||
this._addActivityEntry(data.agentId, { type: 'tool_result', ...data });
|
||||
|
||||
if (this.activeSubagentId === data.agentId) {
|
||||
this.renderSubagentDetail();
|
||||
@@ -232,15 +211,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Notify about subagent completion
|
||||
const parentId = this.subagentParentMap.get(data.agentId);
|
||||
const parentSession = parentId ? this.sessions.get(parentId) : null;
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'subagent-complete',
|
||||
sessionId: parentId || existing?.sessionId || data.sessionId,
|
||||
sessionName: parentSession?.name || parentId || data.sessionId,
|
||||
title: 'Subagent Completed',
|
||||
message: existing?.description || data.description || 'Background agent finished',
|
||||
});
|
||||
this._notifySession(parentId || existing?.sessionId || data.sessionId, 'info', 'subagent-complete', 'Subagent Completed', existing?.description || data.description || 'Background agent finished');
|
||||
|
||||
// Clean up activity/tool data for completed agents after 5 minutes
|
||||
// This prevents memory leaks from long-running sessions with many subagents
|
||||
@@ -621,13 +592,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
renderTaskPanel() {
|
||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||||
if (this.renderTaskPanelTimeout) {
|
||||
clearTimeout(this.renderTaskPanelTimeout);
|
||||
}
|
||||
this.renderTaskPanelTimeout = setTimeout(() => {
|
||||
this._renderTaskPanelImmediate();
|
||||
}, 100);
|
||||
this._debouncedCall('taskPanel', this._renderTaskPanelImmediate);
|
||||
},
|
||||
|
||||
_renderTaskPanelImmediate() {
|
||||
@@ -2353,12 +2318,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="/api/sessions/${this.activeSessionId}/file-raw?path=${encodeURIComponent(node.path)}&download=true" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
|
||||
html.push(`
|
||||
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
|
||||
${expandIcon}
|
||||
<span class="file-tree-icon">${icon}</span>
|
||||
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
||||
${sizeStr}
|
||||
${downloadBtn}
|
||||
</div>
|
||||
`);
|
||||
|
||||
@@ -2982,13 +2952,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
|
||||
renderMuxSessions() {
|
||||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||||
if (this.renderMuxSessionsTimeout) {
|
||||
clearTimeout(this.renderMuxSessionsTimeout);
|
||||
}
|
||||
this.renderMuxSessionsTimeout = setTimeout(() => {
|
||||
this._renderMuxSessionsImmediate();
|
||||
}, 100);
|
||||
this._debouncedCall('muxSessions', this._renderMuxSessionsImmediate);
|
||||
},
|
||||
|
||||
_renderMuxSessionsImmediate() {
|
||||
@@ -3057,8 +3021,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
|
||||
}
|
||||
|
||||
const sid = escapeHtml(muxSession.sessionId);
|
||||
html += `
|
||||
<div class="process-item">
|
||||
<div class="process-item process-item-clickable" onclick="app.selectSession('${sid}')" title="Switch to session">
|
||||
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
|
||||
<div class="process-info">
|
||||
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
||||
@@ -3071,7 +3036,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killMuxSession('${escapeHtml(muxSession.sessionId)}')" 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>
|
||||
`;
|
||||
|
||||
@@ -45,15 +45,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updateRalphState(data.sessionId, existing);
|
||||
}
|
||||
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'ralph-complete',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Loop Complete',
|
||||
message: `Completion: ${data.phrase || 'unknown'}`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'warning', 'ralph-complete', 'Loop Complete', `Completion: ${data.phrase || 'unknown'}`);
|
||||
},
|
||||
|
||||
_onRalphStatusUpdate(data) {
|
||||
@@ -68,28 +60,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updateRalphState(data.sessionId, { circuitBreaker: data.status });
|
||||
// Notify if circuit breaker opens
|
||||
if (data.status.state === 'OPEN') {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'circuit-breaker',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Circuit Breaker Open',
|
||||
message: data.status.reason || 'Loop stuck - no progress detected',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'circuit-breaker', 'Circuit Breaker Open', data.status.reason || 'Loop stuck - no progress detected');
|
||||
}
|
||||
},
|
||||
|
||||
_onExitGateMet(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'exit-gate',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Exit Gate Met',
|
||||
message: `Loop ready to exit (indicators: ${data.completionIndicators})`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'warning', 'exit-gate', 'Exit Gate Met', `Loop ready to exit (indicators: ${data.completionIndicators})`);
|
||||
},
|
||||
|
||||
// Bash tools
|
||||
@@ -471,13 +447,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
renderRalphStatePanel() {
|
||||
// Debounce renders at 50ms to prevent excessive DOM updates
|
||||
if (this.renderRalphStatePanelTimeout) {
|
||||
clearTimeout(this.renderRalphStatePanelTimeout);
|
||||
}
|
||||
this.renderRalphStatePanelTimeout = setTimeout(() => {
|
||||
this._renderRalphStatePanelImmediate();
|
||||
}, 50);
|
||||
this._debouncedCall('ralphStatePanel', this._renderRalphStatePanelImmediate, 50);
|
||||
},
|
||||
|
||||
_renderRalphStatePanelImmediate() {
|
||||
|
||||
@@ -44,21 +44,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onRespawnBlocked(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
const reasonMap = {
|
||||
circuit_breaker_open: 'Circuit Breaker Open',
|
||||
exit_signal: 'Exit Signal Detected',
|
||||
status_blocked: 'Claude Reported BLOCKED',
|
||||
};
|
||||
const title = reasonMap[data.reason] || 'Respawn Blocked';
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'respawn-blocked',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title,
|
||||
message: data.details,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'respawn-blocked', title, data.details);
|
||||
// Update respawn panel to show blocked state
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
const stateEl = document.getElementById('respawnStateLabel');
|
||||
@@ -71,14 +63,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_onRespawnAutoAcceptSent(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'auto-accept',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Plan Accepted',
|
||||
message: `Accepted plan mode for ${session?.name || 'session'}`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'info', 'auto-accept', 'Plan Accepted', `Accepted plan mode for ${session?.name || 'session'}`);
|
||||
},
|
||||
|
||||
_onRespawnDetectionUpdate(data) {
|
||||
@@ -144,15 +129,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onRespawnError(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'session-error',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Respawn Error',
|
||||
message: data.error || data.message || 'Respawn encountered an error',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'session-error', 'Respawn Error', data.error || data.message || 'Respawn encountered an error');
|
||||
},
|
||||
|
||||
_onRespawnActionLog(data) {
|
||||
|
||||
@@ -109,16 +109,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async saveLastUsedCase(caseName) {
|
||||
try {
|
||||
// Get current settings
|
||||
const res = await fetch('/api/settings');
|
||||
const settings = res.ok ? await res.json() : {};
|
||||
// Update lastUsedCase
|
||||
settings.lastUsedCase = caseName;
|
||||
// Save back
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
body: JSON.stringify({ lastUsedCase: caseName })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to save last used case:', err);
|
||||
@@ -273,6 +267,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
||||
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
||||
// before the first async call. The keyboard opens here and stays open through
|
||||
// the session creation flow; selectSession at the end inherits the focus state.
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Get case path first
|
||||
@@ -299,7 +298,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-(.+)$/);
|
||||
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
@@ -325,6 +324,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
||||
}
|
||||
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
||||
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
|
||||
const modelOverride = useOpus1m ? 'opus[1m]' : '';
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||||
@@ -332,7 +333,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}) })
|
||||
body: JSON.stringify({
|
||||
workingDir, name,
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(modelOverride !== undefined ? { modelOverride } : {}),
|
||||
})
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createResults = await Promise.all(createPromises);
|
||||
@@ -427,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^s(\d+)-(.+)$/);
|
||||
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
@@ -493,6 +498,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available
|
||||
@@ -589,8 +596,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
// Populate session name input
|
||||
document.getElementById('modalSessionName').value = session.name || '';
|
||||
// Populate session name input with prefix/suffix split
|
||||
const _modalParsed = parseSessionPrefix(session.name);
|
||||
const _prefixEl = document.getElementById('modalSessionPrefix');
|
||||
if (_modalParsed) {
|
||||
_prefixEl.textContent = _modalParsed.prefix + ': ';
|
||||
_prefixEl.style.display = '';
|
||||
document.getElementById('modalSessionName').value = _modalParsed.suffix;
|
||||
document.getElementById('modalSessionName').placeholder = 'Add description...';
|
||||
} else {
|
||||
_prefixEl.style.display = 'none';
|
||||
_prefixEl.textContent = '';
|
||||
document.getElementById('modalSessionName').value = session.name || '';
|
||||
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
|
||||
}
|
||||
|
||||
// Initialize color picker with current session color
|
||||
const currentColor = session.color || 'default';
|
||||
@@ -637,7 +656,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async saveSessionName() {
|
||||
if (!this.editingSessionId) return;
|
||||
const name = document.getElementById('modalSessionName').value.trim();
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
const parsed = session ? parseSessionPrefix(session.name) : null;
|
||||
const inputVal = document.getElementById('modalSessionName').value.trim();
|
||||
let name;
|
||||
if (parsed) {
|
||||
name = parsed.prefix + (inputVal ? ': ' + inputVal : '');
|
||||
} else {
|
||||
name = inputVal;
|
||||
}
|
||||
try {
|
||||
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
||||
} catch (err) {
|
||||
@@ -857,29 +884,46 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!tabName) return;
|
||||
|
||||
const currentName = this.getSessionName(session);
|
||||
const parsed = parseSessionPrefix(session.name);
|
||||
const originalContent = tabName.textContent;
|
||||
tabName.textContent = '';
|
||||
tabName.innerHTML = '';
|
||||
|
||||
// If prefix detected, show it as non-editable label
|
||||
if (parsed) {
|
||||
const prefixLabel = document.createElement('span');
|
||||
prefixLabel.textContent = parsed.prefix + ': ';
|
||||
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
|
||||
tabName.appendChild(prefixLabel);
|
||||
}
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = session.name || '';
|
||||
input.placeholder = currentName;
|
||||
input.value = parsed ? parsed.suffix : (session.name || '');
|
||||
input.placeholder = parsed ? 'Add description...' : currentName;
|
||||
input.className = 'tab-rename-input';
|
||||
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
|
||||
|
||||
const originalContent = tabName.textContent;
|
||||
tabName.textContent = '';
|
||||
tabName.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const finishRename = async () => {
|
||||
const newName = input.value.trim();
|
||||
tabName.textContent = newName || originalContent;
|
||||
const suffix = input.value.trim();
|
||||
let fullName;
|
||||
if (parsed) {
|
||||
fullName = parsed.prefix + (suffix ? ': ' + suffix : '');
|
||||
} else {
|
||||
fullName = suffix;
|
||||
}
|
||||
tabName.textContent = fullName || originalContent;
|
||||
|
||||
if (newName && newName !== session.name) {
|
||||
if (fullName !== session.name) {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}/name`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName })
|
||||
body: JSON.stringify({ name: fullName })
|
||||
});
|
||||
} catch (err) {
|
||||
tabName.textContent = originalContent;
|
||||
@@ -912,6 +956,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
|
||||
document.getElementById('caseOpusContext1m').checked = settings.opusContext1m;
|
||||
popover.classList.remove('hidden');
|
||||
|
||||
// Close on outside click (one-shot listener)
|
||||
@@ -933,7 +978,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const stored = localStorage.getItem('caseSettings_' + caseName);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* ignore */ }
|
||||
return { agentTeams: false };
|
||||
return { agentTeams: false, opusContext1m: true };
|
||||
},
|
||||
|
||||
saveCaseSettings(caseName, settings) {
|
||||
@@ -944,10 +989,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
settings.agentTeams = document.getElementById('caseAgentTeams').checked;
|
||||
settings.opusContext1m = document.getElementById('caseOpusContext1m').checked;
|
||||
this.saveCaseSettings(caseName, settings);
|
||||
// Sync mobile checkbox
|
||||
// Sync mobile checkboxes
|
||||
const mobileCheckbox = document.getElementById('caseAgentTeamsMobile');
|
||||
if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams;
|
||||
const mobileOpusCheckbox = document.getElementById('caseOpusContext1mMobile');
|
||||
if (mobileOpusCheckbox) mobileOpusCheckbox.checked = settings.opusContext1m;
|
||||
},
|
||||
|
||||
toggleCaseSettingsMobile() {
|
||||
@@ -956,6 +1004,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
|
||||
document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m;
|
||||
popover.classList.remove('hidden');
|
||||
|
||||
const closeHandler = (e) => {
|
||||
@@ -974,10 +1023,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked;
|
||||
settings.opusContext1m = document.getElementById('caseOpusContext1mMobile').checked;
|
||||
this.saveCaseSettings(caseName, settings);
|
||||
// Sync desktop checkbox
|
||||
// Sync desktop checkboxes
|
||||
const desktopCheckbox = document.getElementById('caseAgentTeams');
|
||||
if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams;
|
||||
const desktopOpusCheckbox = document.getElementById('caseOpusContext1m');
|
||||
if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1023,13 +1075,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
||||
content.classList.toggle('hidden', content.id !== tabName);
|
||||
});
|
||||
// Update submit button text
|
||||
// Update submit button (hide for manage tab)
|
||||
const submitBtn = document.getElementById('caseModalSubmit');
|
||||
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
||||
if (tabName === 'case-manage') {
|
||||
submitBtn.style.display = 'none';
|
||||
this.renderCaseManageList();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
document.getElementById('newCaseName').focus();
|
||||
} else {
|
||||
} else if (tabName === 'case-link') {
|
||||
document.getElementById('linkCaseName').focus();
|
||||
}
|
||||
},
|
||||
@@ -1137,6 +1195,107 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case Management (reorder + delete)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderCaseManageList() {
|
||||
const container = document.getElementById('caseManageList');
|
||||
const cases = this.cases || [];
|
||||
if (cases.length === 0) {
|
||||
container.innerHTML = '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
cases.forEach((c, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === cases.length - 1;
|
||||
const pathDisplay = c.path ? c.path.replace(/^\/Users\/[^/]+/, '~') : '';
|
||||
html += `
|
||||
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
|
||||
<div class="case-manage-info">
|
||||
<span class="case-manage-name">${escapeHtml(c.name)}</span>
|
||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||
</div>
|
||||
<div class="case-manage-actions">
|
||||
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
|
||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
|
||||
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
|
||||
title="Delete case">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
},
|
||||
|
||||
async moveCaseUp(name) {
|
||||
const cases = this.cases || [];
|
||||
const idx = cases.findIndex(c => c.name === name);
|
||||
if (idx <= 0) return;
|
||||
// Swap positions (immutable)
|
||||
const reordered = [...cases];
|
||||
[reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]];
|
||||
this.cases = reordered;
|
||||
this.renderCaseManageList();
|
||||
await this.saveCaseOrder(reordered.map(c => c.name));
|
||||
},
|
||||
|
||||
async moveCaseDown(name) {
|
||||
const cases = this.cases || [];
|
||||
const idx = cases.findIndex(c => c.name === name);
|
||||
if (idx < 0 || idx >= cases.length - 1) return;
|
||||
const reordered = [...cases];
|
||||
[reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]];
|
||||
this.cases = reordered;
|
||||
this.renderCaseManageList();
|
||||
await this.saveCaseOrder(reordered.map(c => c.name));
|
||||
},
|
||||
|
||||
async deleteCase(name) {
|
||||
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
|
||||
// Remove from current list and refresh
|
||||
this.cases = (this.cases || []).filter(c => c.name !== name);
|
||||
this.renderCaseManageList();
|
||||
// Refresh the dropdown
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const currentCase = select.value;
|
||||
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
|
||||
} else {
|
||||
this.showToast(data.error || 'Failed to delete case', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Failed to delete case: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async saveCaseOrder(order) {
|
||||
try {
|
||||
await fetch('/api/cases/order', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order })
|
||||
});
|
||||
// Refresh dropdown to reflect new order
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const currentCase = select.value;
|
||||
await this.loadQuickStartCases(currentCase);
|
||||
} catch (err) {
|
||||
this.showToast('Failed to save case order: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Case Picker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1166,6 +1325,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
</svg>
|
||||
</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">
|
||||
<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"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="mobile-case-item-check">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
@@ -1211,6 +1375,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCaseMobile(name) {
|
||||
if (!confirm(`Delete case "${name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
|
||||
this.cases = (this.cases || []).filter(c => c.name !== name);
|
||||
// Refresh mobile picker and dropdown
|
||||
this.closeMobileCasePicker();
|
||||
await this.loadQuickStartCases();
|
||||
} else {
|
||||
this.showToast(data.error || 'Failed to delete case', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Failed to delete case: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
showCreateCaseFromMobile() {
|
||||
// Close mobile picker first
|
||||
this.closeMobileCasePicker();
|
||||
|
||||
@@ -14,92 +14,46 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// Hooks (Claude Code hook events)
|
||||
_onHookIdlePrompt(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
// Always track pending hook - alert will show when switching away from session
|
||||
if (data.sessionId) {
|
||||
this.setPendingHook(data.sessionId, 'idle_prompt');
|
||||
}
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'hook-idle',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Waiting for Input',
|
||||
message: data.message || 'Claude is idle and waiting for a prompt',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'warning', 'hook-idle', 'Waiting for Input', data.message || 'Claude is idle and waiting for a prompt');
|
||||
},
|
||||
|
||||
_onHookPermissionPrompt(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
// Always track pending hook - action alerts need user interaction to clear
|
||||
if (data.sessionId) {
|
||||
this.setPendingHook(data.sessionId, 'permission_prompt');
|
||||
}
|
||||
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'hook-permission',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Permission Required',
|
||||
message: toolInfo || 'Claude needs tool approval to continue',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue');
|
||||
},
|
||||
|
||||
_onHookElicitationDialog(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
// Always track pending hook - action alerts need user interaction to clear
|
||||
if (data.sessionId) {
|
||||
this.setPendingHook(data.sessionId, 'elicitation_dialog');
|
||||
}
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'hook-elicitation',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Question Asked',
|
||||
message: data.question || 'Claude is asking a question and waiting for your answer',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer');
|
||||
},
|
||||
|
||||
_onHookStop(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
// Clear all pending hooks when Claude finishes responding
|
||||
if (data.sessionId) {
|
||||
this.clearPendingHooks(data.sessionId);
|
||||
}
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'hook-stop',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Response Complete',
|
||||
message: data.reason || 'Claude has finished responding',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
|
||||
},
|
||||
|
||||
_onHookTeammateIdle(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'hook-teammate-idle',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Teammate Idle',
|
||||
message: `A teammate is idle in ${session?.name || data.sessionId}`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
|
||||
},
|
||||
|
||||
_onHookTaskCompleted(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'hook-task-completed',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Task Completed',
|
||||
message: `A team task completed in ${session?.name || data.sessionId}`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'info', 'hook-task-completed', 'Task Completed', `A team task completed in ${session?.name || data.sessionId}`);
|
||||
},
|
||||
|
||||
|
||||
@@ -364,6 +318,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
// Claude CLI settings
|
||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||
@@ -377,6 +333,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
};
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
// CPU Priority settings
|
||||
const niceSettings = settings.nice || {};
|
||||
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
|
||||
@@ -516,16 +473,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
_updateTunnelUrlDisplay(url) {
|
||||
const row = document.getElementById('tunnelUrlRow');
|
||||
const display = document.getElementById('tunnelUrlDisplay');
|
||||
_updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
|
||||
const row = document.getElementById(rowId);
|
||||
const display = document.getElementById(displayId);
|
||||
if (!row || !display) return;
|
||||
if (url) {
|
||||
const fullUrl = url + suffix;
|
||||
row.style.display = '';
|
||||
display.textContent = url;
|
||||
display.textContent = fullUrl;
|
||||
display.onclick = () => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.showToast('Tunnel URL copied', 'success');
|
||||
navigator.clipboard.writeText(fullUrl).then(() => {
|
||||
this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
|
||||
});
|
||||
};
|
||||
} else {
|
||||
@@ -533,24 +491,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
display.textContent = '';
|
||||
display.onclick = null;
|
||||
}
|
||||
// Upload URL row
|
||||
const uploadRow = document.getElementById('tunnelUploadUrlRow');
|
||||
const uploadDisplay = document.getElementById('tunnelUploadUrlDisplay');
|
||||
if (!uploadRow || !uploadDisplay) return;
|
||||
if (url) {
|
||||
const uploadUrl = url + '/upload.html';
|
||||
uploadRow.style.display = '';
|
||||
uploadDisplay.textContent = uploadUrl;
|
||||
uploadDisplay.onclick = () => {
|
||||
navigator.clipboard.writeText(uploadUrl).then(() => {
|
||||
this.showToast('Upload URL copied', 'success');
|
||||
});
|
||||
};
|
||||
} else {
|
||||
uploadRow.style.display = 'none';
|
||||
uploadDisplay.textContent = '';
|
||||
uploadDisplay.onclick = null;
|
||||
}
|
||||
},
|
||||
|
||||
_updateTunnelUrlDisplay(url) {
|
||||
this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
|
||||
this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
|
||||
},
|
||||
|
||||
showTunnelQR() {
|
||||
@@ -1180,12 +1125,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
// CPU Priority settings
|
||||
nice: {
|
||||
enabled: document.getElementById('appSettingsNiceEnabled').checked,
|
||||
@@ -1296,9 +1244,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
||||
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
|
||||
|
||||
// Apply CJK input visibility immediately
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Apply keyboard bar mode
|
||||
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
|
||||
// Save to server (includes notification prefs for cross-browser persistence)
|
||||
// Strip device-specific keys — localEchoEnabled is per-platform (touch default differs)
|
||||
const { localEchoEnabled: _leo, ...serverSettings } = settings;
|
||||
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
||||
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
|
||||
try {
|
||||
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
||||
|
||||
@@ -1329,7 +1283,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Default model
|
||||
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
|
||||
if (defaultModelEl) {
|
||||
defaultModelEl.value = config.defaultModel || 'opus';
|
||||
defaultModelEl.value = config.defaultModel || '';
|
||||
}
|
||||
// Show recommendations
|
||||
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
|
||||
@@ -1368,7 +1322,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
|
||||
|
||||
const config = {
|
||||
defaultModel: defaultModelEl?.value || 'opus',
|
||||
defaultModel: defaultModelEl?.value || '',
|
||||
showRecommendations: showRecsEl?.checked ?? true,
|
||||
agentTypeOverrides,
|
||||
};
|
||||
@@ -1682,7 +1636,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
]);
|
||||
// Merge settings: non-display keys always sync from server,
|
||||
// display keys only seed from server when localStorage has no value
|
||||
|
||||
@@ -1,30 +1,105 @@
|
||||
/**
|
||||
* @fileoverview Service worker for Web Push notifications.
|
||||
* @fileoverview Service worker for PWA install + Web Push notifications.
|
||||
*
|
||||
* Receives push events from the Codeman server (via web-push library) and displays
|
||||
* OS-level notifications. Handles notification clicks to focus an existing Codeman
|
||||
* tab or open a new one. Supports action buttons, per-session deep linking, and
|
||||
* critical notification persistence (requireInteraction).
|
||||
* App-shell caching: on install, precaches the core UI assets so the app
|
||||
* launches instantly and works offline (or on flaky connections). Uses a
|
||||
* network-first strategy for navigation and API calls, cache-first for
|
||||
* static assets.
|
||||
*
|
||||
* Lifecycle: skipWaiting on install, claim clients on activate — ensures the latest
|
||||
* service worker takes control immediately without waiting for tab refresh.
|
||||
* Push notifications: receives push events from the Codeman server (via
|
||||
* web-push library) and displays OS-level notifications. Handles notification
|
||||
* clicks to focus an existing Codeman tab or open a new one.
|
||||
*
|
||||
* Lifecycle: skipWaiting on install, claim clients on activate -- ensures the
|
||||
* latest service worker takes control immediately without waiting for tab
|
||||
* refresh.
|
||||
*
|
||||
* @dependency None (runs in ServiceWorkerGlobalScope, isolated from page scripts)
|
||||
* @see src/push-store.ts — server-side VAPID key management and subscription CRUD
|
||||
* @see src/push-store.ts -- server-side VAPID key management and subscription CRUD
|
||||
*/
|
||||
|
||||
// Codeman Service Worker — Web Push notifications
|
||||
// This service worker receives push events from the server and displays OS-level notifications.
|
||||
// It also handles notification clicks to focus or open the Codeman tab.
|
||||
const CACHE_NAME = 'codeman-v1';
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// Core app shell -- cached on install for instant startup
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/styles.css',
|
||||
'/mobile.css',
|
||||
'/constants.js',
|
||||
'/app.js',
|
||||
'/api-client.js',
|
||||
'/terminal-ui.js',
|
||||
'/session-ui.js',
|
||||
'/settings-ui.js',
|
||||
'/panels-ui.js',
|
||||
'/notification-manager.js',
|
||||
'/mobile-handlers.js',
|
||||
'/keyboard-accessory.js',
|
||||
'/voice-input.js',
|
||||
'/vendor/xterm.min.js',
|
||||
'/vendor/xterm-addon-fit.min.js',
|
||||
'/vendor/xterm-addon-unicode11.min.js',
|
||||
'/vendor/xterm-zerolag-input.js',
|
||||
'/vendor/xterm.css',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// --- Install: precache app shell ---
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
// Use addAll but don't fail install if some assets 404 (hashed filenames)
|
||||
return Promise.allSettled(
|
||||
APP_SHELL.map((url) => cache.add(url).catch(() => {}))
|
||||
);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// --- Activate: clean old caches, claim clients ---
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// --- Fetch: network-first with cache fallback ---
|
||||
// Network-first ensures deploys take effect immediately when online.
|
||||
// Cache is only used when the network is unavailable (offline/flaky).
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
|
||||
// Skip non-GET, WebSocket upgrades, and SSE streams
|
||||
if (request.method !== 'GET') return;
|
||||
if (request.headers.get('upgrade') === 'websocket') return;
|
||||
if (request.headers.get('accept') === 'text/event-stream') return;
|
||||
if (request.url.includes('/api/')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response && response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
);
|
||||
});
|
||||
|
||||
// --- Push notifications ---
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
@@ -40,8 +115,8 @@ self.addEventListener('push', (event) => {
|
||||
const options = {
|
||||
body: body || '',
|
||||
tag: tag || 'codeman-default',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
|
||||
renotify: true,
|
||||
requireInteraction: urgency === 'critical',
|
||||
@@ -75,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// No existing tab — open a new one
|
||||
// No existing tab -- open a new one
|
||||
return self.clients.openWindow(targetUrl);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -64,7 +64,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||
this.terminal.loadAddon(unicode11Addon);
|
||||
this.terminal.unicode.activeVersion = '11';
|
||||
} catch (_e) { /* Unicode11 addon failed — default Unicode handling used */ }
|
||||
} catch (_e) {
|
||||
/* Unicode11 addon failed — default Unicode handling used */
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
@@ -75,9 +77,72 @@ Object.assign(CodemanApp.prototype, {
|
||||
// during composition, causing duplicate or garbled input.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.keyCode === 229) return false;
|
||||
|
||||
// Let Alt+digit pass through to browser (tab switching)
|
||||
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
|
||||
|
||||
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
|
||||
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
|
||||
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
|
||||
// which the inner application recognizes as "insert newline" vs carriage return.
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
if (this.activeSessionId) {
|
||||
if (this._localEchoEnabled) {
|
||||
const text = this._localEchoOverlay?.pendingText || '';
|
||||
this._localEchoOverlay?.clear();
|
||||
this._localEchoOverlay?.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (text) {
|
||||
this._pendingInput += text;
|
||||
flushInput();
|
||||
}
|
||||
setTimeout(() => {
|
||||
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
});
|
||||
}, text ? 80 : 0);
|
||||
} else {
|
||||
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Android virtual keyboard fix: catch non-composition input events.
|
||||
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
|
||||
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
|
||||
// The custom key handler above returns false for keyCode 229, telling xterm
|
||||
// to ignore the keydown. This listener catches those orphaned input events.
|
||||
{
|
||||
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
|
||||
if (xtermTextarea && MobileDetection.isTouchDevice()) {
|
||||
let composing = false;
|
||||
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
|
||||
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
|
||||
xtermTextarea.addEventListener('input', (e) => {
|
||||
if (composing || e.isComposing) return;
|
||||
if (e.inputType !== 'insertText' || !e.data) return;
|
||||
const data = e.data;
|
||||
Promise.resolve().then(() => {
|
||||
const val = xtermTextarea.value;
|
||||
if (!val || val.trim() === '') return;
|
||||
this.terminal._core.coreService.triggerDataEvent(data, true);
|
||||
xtermTextarea.value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WebGL renderer for GPU-accelerated terminal rendering.
|
||||
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
|
||||
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
|
||||
@@ -115,8 +180,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// On mobile Safari, delay initial fit() to allow layout to settle
|
||||
// This prevents 0-column terminals caused by fit() running before container is sized
|
||||
const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' &&
|
||||
document.body.classList.contains('safari-browser');
|
||||
const isMobileSafari =
|
||||
MobileDetection.getDeviceType() === 'mobile' && document.body.classList.contains('safari-browser');
|
||||
if (isMobileSafari) {
|
||||
// Wait for layout, then fit multiple times to ensure proper sizing
|
||||
requestAnimationFrame(() => {
|
||||
@@ -133,90 +198,126 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Always use mouse wheel for terminal scrollback, never forward to application.
|
||||
// Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
|
||||
container.addEventListener('wheel', (ev) => {
|
||||
ev.preventDefault();
|
||||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||||
this.terminal.scrollLines(lines);
|
||||
}, { passive: false });
|
||||
container.addEventListener(
|
||||
'wheel',
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||||
this.terminal.scrollLines(lines);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Touch scrolling - only use custom JS scrolling on desktop
|
||||
// Mobile uses native browser scrolling via CSS touch-action: pan-y
|
||||
const isMobileDevice = MobileDetection.isTouchDevice() && window.innerWidth < 1024;
|
||||
|
||||
if (!isMobileDevice) {
|
||||
// Desktop touch scrolling with custom momentum
|
||||
// Touch scrolling — use terminal.scrollLines() for all devices.
|
||||
// xterm.js DOM renderer doesn't populate xterm-viewport's scroll area,
|
||||
// so native CSS scrolling (overflow-y: scroll + touch-action: pan-y)
|
||||
// has nothing to scroll. Instead, convert touch deltas into scrollLines()
|
||||
// calls, matching the wheel handler above.
|
||||
{
|
||||
const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
|
||||
let touchLastY = 0;
|
||||
let pendingDelta = 0;
|
||||
let velocity = 0;
|
||||
let lastTime = 0;
|
||||
let scrollFrame = null;
|
||||
let isTouching = false;
|
||||
|
||||
const viewport = container.querySelector('.xterm-viewport');
|
||||
|
||||
// Single RAF loop handles both touch and momentum
|
||||
const scrollLoop = (timestamp) => {
|
||||
if (!viewport) return;
|
||||
|
||||
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; // Normalize to 60fps
|
||||
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1;
|
||||
lastTime = timestamp;
|
||||
|
||||
if (isTouching) {
|
||||
// During touch: apply pending delta
|
||||
if (pendingDelta !== 0) {
|
||||
viewport.scrollTop += pendingDelta;
|
||||
pendingDelta = 0;
|
||||
}
|
||||
if (!isTouching && Math.abs(velocity) > 0.3) {
|
||||
// Momentum phase — convert pixel velocity to lines
|
||||
const lines = Math.round(velocity / cellHeight());
|
||||
if (lines !== 0) this.terminal.scrollLines(lines);
|
||||
velocity *= 0.92;
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
} else if (Math.abs(velocity) > 0.1) {
|
||||
// Momentum phase
|
||||
viewport.scrollTop += velocity * dt;
|
||||
velocity *= 0.94; // Smooth deceleration
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
} else {
|
||||
} else if (!isTouching) {
|
||||
scrollFrame = null;
|
||||
velocity = 0;
|
||||
} else {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('touchstart', (ev) => {
|
||||
if (ev.touches.length === 1) {
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
pendingDelta = 0;
|
||||
velocity = 0;
|
||||
isTouching = true;
|
||||
lastTime = 0;
|
||||
if (!scrollFrame) {
|
||||
// Accumulate sub-line pixel deltas so slow swipes still scroll
|
||||
let pixelAccum = 0;
|
||||
|
||||
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
||||
container.addEventListener(
|
||||
'touchstart',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1) {
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
didScroll = false;
|
||||
lastTime = 0;
|
||||
if (scrollFrame) {
|
||||
cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
'touchmove',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
didScroll = true;
|
||||
const touchY = ev.touches[0].clientY;
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
velocity = delta * 1.2;
|
||||
touchLastY = touchY;
|
||||
// Convert accumulated pixels to whole lines
|
||||
const ch = cellHeight();
|
||||
const lines = Math.trunc(pixelAccum / ch);
|
||||
if (lines !== 0) {
|
||||
this.terminal.scrollLines(lines);
|
||||
pixelAccum -= lines * ch;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
'touchend',
|
||||
() => {
|
||||
isTouching = false;
|
||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
|
||||
// routes back to the terminal. Without this, a tap on the terminal area
|
||||
// consumes the touch event but xterm's textarea never regains focus.
|
||||
if (!didScroll && this.terminal) {
|
||||
this.terminal.focus();
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
container.addEventListener('touchmove', (ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
const touchY = ev.touches[0].clientY;
|
||||
const delta = touchLastY - touchY;
|
||||
pendingDelta += delta;
|
||||
velocity = delta * 1.2; // Track for momentum
|
||||
touchLastY = touchY;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchend', () => {
|
||||
isTouching = false;
|
||||
// Momentum continues in scrollLoop
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchcancel', () => {
|
||||
isTouching = false;
|
||||
velocity = 0;
|
||||
}, { passive: true });
|
||||
container.addEventListener(
|
||||
'touchcancel',
|
||||
() => {
|
||||
isTouching = false;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
}
|
||||
// Mobile: native scrolling handles touch via CSS
|
||||
|
||||
// Welcome message
|
||||
this.showWelcome();
|
||||
|
||||
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
|
||||
this._chunkedWriteGen = 0;
|
||||
|
||||
// Handle resize with throttling for performance
|
||||
this._resizeTimeout = null;
|
||||
this._lastResizeDims = null;
|
||||
@@ -255,17 +356,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this.flushFlickerBuffer();
|
||||
}
|
||||
// Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
|
||||
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
||||
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
||||
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended
|
||||
&& this.terminal && this.isTerminalAtBottom()) {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
|
||||
// causes Ink to re-render at the new row count, garbling terminal output.
|
||||
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
||||
@@ -276,14 +366,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
|
||||
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
|
||||
// Only send resize if dimensions actually changed
|
||||
if (!this._lastResizeDims ||
|
||||
cols !== this._lastResizeDims.cols ||
|
||||
rows !== this._lastResizeDims.rows) {
|
||||
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
|
||||
// Clear viewport + scrollback ONLY when dimensions actually change.
|
||||
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
||||
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
||||
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
|
||||
// Clearing without a subsequent Ink redraw leaves the terminal blank.
|
||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
if (
|
||||
activeResizeSession &&
|
||||
activeResizeSession.mode !== 'shell' &&
|
||||
!activeResizeSession._ended &&
|
||||
this.terminal &&
|
||||
this.isTerminalAtBottom()
|
||||
) {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
this._lastResizeDims = { cols, rows };
|
||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows })
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -437,7 +543,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (p) {
|
||||
const buf = this.terminal.buffer.active;
|
||||
const line = buf.getLine(buf.viewportY + p.row);
|
||||
if (line) baseText = line.translateToString(true).slice(p.col + 2).trimEnd();
|
||||
if (line)
|
||||
baseText = line
|
||||
.translateToString(true)
|
||||
.slice(p.col + 2)
|
||||
.trimEnd();
|
||||
}
|
||||
} catch {}
|
||||
this._tabCompletionBaseText = baseText;
|
||||
@@ -452,7 +562,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const selfTab = this;
|
||||
this._tabCompletionFallback = setTimeout(() => {
|
||||
selfTab._tabCompletionFallback = null;
|
||||
if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) return;
|
||||
if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId)
|
||||
return;
|
||||
const ov = selfTab._localEchoOverlay;
|
||||
if (!ov || ov.pendingText) return;
|
||||
selfTab.terminal.write('', () => {
|
||||
@@ -551,7 +662,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const buffer = self.terminal.buffer.active;
|
||||
const line = buffer.getLine(bufferLineNumber);
|
||||
// provideLinks passes 1-based line number, getLine expects 0-based
|
||||
const line = buffer.getLine(bufferLineNumber - 1);
|
||||
|
||||
if (!line) {
|
||||
callback(undefined);
|
||||
@@ -568,12 +680,37 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const links = [];
|
||||
|
||||
// Pattern 0: URLs (https://, http://) — matched first so they take priority
|
||||
const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g;
|
||||
|
||||
const addUrlLink = (url, matchIndex) => {
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const startCol = lineText.indexOf(cleaned, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
|
||||
links.push({
|
||||
text: cleaned,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber },
|
||||
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
|
||||
// Handles: tail -f /path, grep pattern /path, cat -n /path
|
||||
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions
|
||||
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
@@ -583,27 +720,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (startCol === -1) return;
|
||||
|
||||
// Skip if already have link at this position
|
||||
if (links.some(l => l.range.start.x === startCol + 1)) return;
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
|
||||
links.push({
|
||||
text: filePath,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber }
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
decorations: {
|
||||
pointerCursor: true,
|
||||
underline: true
|
||||
underline: true,
|
||||
},
|
||||
activate(event, text) {
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Match all patterns
|
||||
// Match all patterns — URLs first so they take priority
|
||||
let match;
|
||||
|
||||
urlPattern.lastIndex = 0;
|
||||
while ((match = urlPattern.exec(lineText)) !== null) {
|
||||
addUrlLink(match[0], match.index);
|
||||
}
|
||||
|
||||
cmdPattern.lastIndex = 0;
|
||||
while ((match = cmdPattern.exec(lineText)) !== null) {
|
||||
addLink(match[2], match.index);
|
||||
@@ -620,10 +762,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
if (links.length > 0) {
|
||||
console.debug('[LinkProvider] Found links:', links.map(l => l.text));
|
||||
console.debug(
|
||||
'[LinkProvider] Found links:',
|
||||
links.map((l) => l.text)
|
||||
);
|
||||
}
|
||||
callback(links.length > 0 ? links : undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[LinkProvider] File path link provider registered');
|
||||
@@ -652,70 +797,110 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total).
|
||||
* @returns {Promise<Array>} deduplicated session list, sorted by lastModified desc
|
||||
* Fetch and deduplicate history sessions (up to 3 per project, sorted by date).
|
||||
* Uses projectKey for grouping because workingDir decoding is lossy.
|
||||
* @returns {Promise<Array>} deduplicated session list, most recent first
|
||||
*/
|
||||
async _fetchHistorySessions(limit = 12) {
|
||||
async _fetchHistorySessions() {
|
||||
const res = await fetch('/api/history/sessions');
|
||||
const data = await res.json();
|
||||
const sessions = data.sessions || [];
|
||||
if (sessions.length === 0) return [];
|
||||
|
||||
const byDir = new Map();
|
||||
const byProject = new Map();
|
||||
for (const s of sessions) {
|
||||
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []);
|
||||
byDir.get(s.workingDir).push(s);
|
||||
const key = s.projectKey || s.workingDir;
|
||||
if (!byProject.has(key)) byProject.set(key, []);
|
||||
byProject.get(key).push(s);
|
||||
}
|
||||
const items = [];
|
||||
for (const [, group] of byDir) {
|
||||
items.push(...group.slice(0, 2));
|
||||
for (const [, group] of byProject) {
|
||||
items.push(...group.slice(0, 3));
|
||||
}
|
||||
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||||
return items.slice(0, limit);
|
||||
return items;
|
||||
},
|
||||
|
||||
/** Build a single history item DOM element */
|
||||
_buildHistoryItem(s) {
|
||||
const size =
|
||||
s.sizeBytes < 1024
|
||||
? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576
|
||||
? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const date = new Date(s.lastModified);
|
||||
const timeStr =
|
||||
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.title = s.workingDir;
|
||||
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
||||
|
||||
const textCol = document.createElement('div');
|
||||
textCol.className = 'history-item-text';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'history-item-title';
|
||||
titleSpan.textContent = s.firstPrompt || shortDir;
|
||||
|
||||
const subtitleSpan = document.createElement('span');
|
||||
subtitleSpan.className = 'history-item-subtitle';
|
||||
subtitleSpan.textContent = shortDir;
|
||||
|
||||
textCol.append(titleSpan, subtitleSpan);
|
||||
|
||||
const metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'history-item-meta';
|
||||
metaSpan.textContent = timeStr;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'history-item-size';
|
||||
sizeSpan.textContent = size;
|
||||
|
||||
item.append(textCol, metaSpan, sizeSpan);
|
||||
return item;
|
||||
},
|
||||
|
||||
/** Number of history items shown before "Show More" */
|
||||
_HISTORY_INITIAL_COUNT: 4,
|
||||
|
||||
async loadHistorySessions() {
|
||||
const container = document.getElementById('historySessions');
|
||||
const list = document.getElementById('historyList');
|
||||
if (!container || !list) return;
|
||||
|
||||
try {
|
||||
const display = await this._fetchHistorySessions(12);
|
||||
if (display.length === 0) {
|
||||
const allSessions = await this._fetchHistorySessions(30);
|
||||
if (allSessions.length === 0) {
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build DOM safely (no innerHTML with user data)
|
||||
list.replaceChildren();
|
||||
for (const s of display) {
|
||||
const size = s.sizeBytes < 1024 ? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576 ? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const date = new Date(s.lastModified);
|
||||
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||||
const initialCount = this._HISTORY_INITIAL_COUNT;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.title = s.workingDir;
|
||||
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
||||
// Render initial items
|
||||
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
|
||||
list.appendChild(this._buildHistoryItem(allSessions[i]));
|
||||
}
|
||||
|
||||
const dirSpan = document.createElement('span');
|
||||
dirSpan.className = 'history-item-dir';
|
||||
dirSpan.textContent = shortDir;
|
||||
|
||||
const metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'history-item-meta';
|
||||
metaSpan.textContent = timeStr;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'history-item-size';
|
||||
sizeSpan.textContent = size;
|
||||
|
||||
item.append(dirSpan, metaSpan, sizeSpan);
|
||||
list.appendChild(item);
|
||||
// Add "Show More" button if there are more items
|
||||
if (allSessions.length > initialCount) {
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'history-show-more';
|
||||
moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
|
||||
moreBtn.addEventListener('click', () => {
|
||||
for (let i = initialCount; i < allSessions.length; i++) {
|
||||
list.insertBefore(this._buildHistoryItem(allSessions[i]), moreBtn);
|
||||
}
|
||||
moreBtn.remove();
|
||||
});
|
||||
list.appendChild(moreBtn);
|
||||
}
|
||||
|
||||
container.style.display = '';
|
||||
@@ -748,7 +933,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId })
|
||||
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error);
|
||||
@@ -766,7 +951,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal Rendering
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -811,9 +995,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it)
|
||||
if (flickerFilterEnabled) {
|
||||
const hasScreenClear = data.includes('\x1b[2J') ||
|
||||
data.includes('\x1b[H\x1b[J') ||
|
||||
(data.includes('\x1b[H') && data.includes('\x1b[?25l'));
|
||||
const hasScreenClear =
|
||||
data.includes('\x1b[2J') ||
|
||||
data.includes('\x1b[H\x1b[J') ||
|
||||
(data.includes('\x1b[H') && data.includes('\x1b[?25l'));
|
||||
|
||||
if (hasScreenClear) {
|
||||
this.flickerFilterActive = true;
|
||||
@@ -879,47 +1064,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Position is tracked dynamically by _findPrompt() on every render.
|
||||
*/
|
||||
_updateLocalEchoState() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
const shouldEnable = !!(echoEnabled && session);
|
||||
if (this._localEchoEnabled && !shouldEnable) {
|
||||
this._localEchoOverlay?.clear();
|
||||
}
|
||||
this._localEchoEnabled = shouldEnable;
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
const shouldEnable = !!(echoEnabled && session);
|
||||
if (this._localEchoEnabled && !shouldEnable) {
|
||||
this._localEchoOverlay?.clear();
|
||||
}
|
||||
this._localEchoEnabled = shouldEnable;
|
||||
|
||||
// Swap prompt finder based on session mode
|
||||
if (this._localEchoOverlay && session) {
|
||||
if (session.mode === 'opencode') {
|
||||
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
|
||||
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
|
||||
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 3,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const row = buf.cursorY;
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) return null;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL)
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
return null;
|
||||
} catch { return null; }
|
||||
// Swap prompt finder based on session mode
|
||||
if (this._localEchoOverlay && session) {
|
||||
if (session.mode === 'opencode') {
|
||||
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
|
||||
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
|
||||
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 3,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const row = buf.cursorY;
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) return null;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL)
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} else if (session.mode === 'shell') {
|
||||
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
|
||||
// Disable it by clearing any pending text.
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
// Claude Code: scan for ❯ prompt character
|
||||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (session.mode === 'shell') {
|
||||
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
|
||||
// Disable it by clearing any pending text.
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
// Claude Code: scan for ❯ prompt character
|
||||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -936,7 +1123,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const joined = this.pendingWrites.join('');
|
||||
this.pendingWrites = [];
|
||||
const _joinedLen = joined.length;
|
||||
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
|
||||
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen / 1024).toFixed(0)}KB`);
|
||||
|
||||
// Per-frame byte budget to prevent main thread blocking.
|
||||
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
|
||||
@@ -960,7 +1147,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
|
||||
const _dt = performance.now() - _t0;
|
||||
if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`);
|
||||
if (_dt > 100 || deferred)
|
||||
console.warn(
|
||||
`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)`
|
||||
);
|
||||
|
||||
// Sticky scroll: if user was at bottom, keep them there after new output
|
||||
if (this._wasAtBottomBeforeWrite) {
|
||||
@@ -977,8 +1167,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Use terminal.write('', callback) to defer detection until xterm.js
|
||||
// finishes processing ALL queued writes — direct buffer reads after
|
||||
// terminal.write(data) can miss text if xterm processes asynchronously.
|
||||
if (this._tabCompletionSessionId && this._tabCompletionSessionId === this.activeSessionId
|
||||
&& this._localEchoOverlay && !this._localEchoOverlay.pendingText) {
|
||||
if (
|
||||
this._tabCompletionSessionId &&
|
||||
this._tabCompletionSessionId === this.activeSessionId &&
|
||||
this._localEchoOverlay &&
|
||||
!this._localEchoOverlay.pendingText
|
||||
) {
|
||||
const overlay = this._localEchoOverlay;
|
||||
const self = this;
|
||||
this.terminal.write('', () => {
|
||||
@@ -999,7 +1193,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
self._tabCompletionSessionId = null;
|
||||
self._tabCompletionRetries = 0;
|
||||
self._tabCompletionBaseText = null;
|
||||
if (self._tabCompletionFallback) { clearTimeout(self._tabCompletionFallback); self._tabCompletionFallback = null; }
|
||||
if (self._tabCompletionFallback) {
|
||||
clearTimeout(self._tabCompletionFallback);
|
||||
self._tabCompletionFallback = null;
|
||||
}
|
||||
overlay.rerender();
|
||||
}
|
||||
} else {
|
||||
@@ -1022,6 +1219,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @returns {Promise<void>} - Resolves when all chunks written
|
||||
*/
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
|
||||
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
||||
// older writes abort instead of continuing to push stale data into the terminal.
|
||||
const writeGen = ++this._chunkedWriteGen;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
this._finishBufferLoad();
|
||||
@@ -1038,7 +1239,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||
|
||||
const finish = () => {
|
||||
this._finishBufferLoad();
|
||||
// Only finish if we're still the active write — a newer write owns buffer load state
|
||||
if (this._chunkedWriteGen === writeGen) {
|
||||
this._finishBufferLoad();
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
@@ -1056,9 +1260,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _chunkStart = performance.now();
|
||||
let _chunkCount = 0;
|
||||
const writeChunk = () => {
|
||||
// Abort if a newer chunked write started (user switched tabs)
|
||||
if (this._chunkedWriteGen !== writeGen) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (offset >= cleanBuffer.length) {
|
||||
const _totalMs = performance.now() - _chunkStart;
|
||||
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`);
|
||||
console.log(
|
||||
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
|
||||
);
|
||||
// Wait one more frame for xterm to finish rendering before resolving
|
||||
requestAnimationFrame(finish);
|
||||
return;
|
||||
@@ -1069,7 +1281,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(chunk);
|
||||
const _cdt = performance.now() - _ct0;
|
||||
_chunkCount++;
|
||||
if (_cdt > 50) console.warn(`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`);
|
||||
if (_cdt > 50)
|
||||
console.warn(
|
||||
`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`
|
||||
);
|
||||
offset += chunkSize;
|
||||
|
||||
// Schedule next chunk on next frame
|
||||
@@ -1082,21 +1297,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete a buffer load: unblock live SSE writes and flush any queued events.
|
||||
* Complete a buffer load: unblock live SSE writes.
|
||||
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
|
||||
*
|
||||
* Queued SSE events are DISCARDED, not flushed. The loaded buffer from the API
|
||||
* is the source of truth up to the response timestamp. SSE events queued during
|
||||
* the fetch+write overlap with the buffer — flushing them writes duplicate data
|
||||
* (especially Ink cursor-up redraws), corrupting the terminal display.
|
||||
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
||||
*/
|
||||
_finishBufferLoad() {
|
||||
const queue = this._loadBufferQueue;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
if (queue && queue.length > 0) {
|
||||
for (const data of queue) {
|
||||
this.batchTerminalWrite(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal Controls
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1130,7 +1344,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
body: JSON.stringify({ input: '\x0c' }),
|
||||
});
|
||||
|
||||
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
|
||||
@@ -1157,7 +1371,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
body: JSON.stringify({ input: '\x0c' }),
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -1219,7 +1433,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!dims) return null;
|
||||
return {
|
||||
cols: Math.max(dims.cols, MIN_COLS),
|
||||
rows: Math.max(dims.rows, MIN_ROWS)
|
||||
rows: Math.max(dims.rows, MIN_ROWS),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1229,8 +1443,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendResize(sessionId) {
|
||||
// Fit terminal to container before reading dimensions — ensures local
|
||||
// terminal size matches what we report to the server PTY.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) return;
|
||||
// Update _lastResizeDims so the throttledResize handler won't redundantly
|
||||
// clear the terminal for the same dimensions (which would blank the screen
|
||||
// without a subsequent Ink redraw to repaint it).
|
||||
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
|
||||
// Fast path: WebSocket resize
|
||||
if (this._wsReady && this._wsSessionId === sessionId) {
|
||||
try {
|
||||
@@ -1243,7 +1464,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dims)
|
||||
body: JSON.stringify(dims),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1257,11 +1478,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, useMux: true })
|
||||
body: JSON.stringify({ input, useMux: true }),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Directory Input
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* @fileoverview Respawn event wiring — pure functions that connect RespawnController
|
||||
* events to SSE broadcasts, push notifications, and run summary tracking.
|
||||
*
|
||||
* Extracted from WebServer to keep respawn-specific event plumbing separate from
|
||||
* HTTP/session concerns. Follows the same DI pattern as session-listener-wiring.ts.
|
||||
*/
|
||||
|
||||
import { Session } from '../session.js';
|
||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||||
import type { PersistedRespawnConfig } from '../types.js';
|
||||
import type { RunSummaryTracker } from '../run-summary.js';
|
||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
import type { TeamWatcher } from '../team-watcher.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
|
||||
// ============================================================================
|
||||
// Dependency Interface
|
||||
// ============================================================================
|
||||
|
||||
export interface RespawnWiringDeps {
|
||||
broadcast(event: string, data: unknown): void;
|
||||
sendPushNotifications(event: string, data: Record<string, unknown>): void;
|
||||
persistSessionState(session: Session): void;
|
||||
getSession(sessionId: string): Session | undefined;
|
||||
sessionExists(sessionId: string): boolean;
|
||||
getRunSummaryTracker(sessionId: string): RunSummaryTracker | undefined;
|
||||
getRespawnControllers(): Map<string, RespawnController>;
|
||||
getRespawnTimers(): Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }>;
|
||||
getPendingRespawnStarts(): Map<string, NodeJS.Timeout>;
|
||||
teamWatcher: TeamWatcher;
|
||||
serverStartTime: number;
|
||||
respawnRestoreGracePeriodMs: number;
|
||||
mux: TerminalMultiplexer;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Respawn Listener Wiring
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Wire a RespawnController's events to SSE broadcasts, push notifications,
|
||||
* and run summary tracking.
|
||||
*/
|
||||
export function wireRespawnListeners(sessionId: string, controller: RespawnController, deps: RespawnWiringDeps): void {
|
||||
// Wire team watcher for team-aware idle detection
|
||||
controller.setTeamWatcher(deps.teamWatcher);
|
||||
|
||||
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||||
const getTracker = () => deps.getRunSummaryTracker(sessionId);
|
||||
|
||||
// ─── Respawn State Machine ──────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
|
||||
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
|
||||
deps.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordStateChange(state, `${prevState} → ${state}`);
|
||||
});
|
||||
|
||||
// ─── Respawn Cycle Lifecycle ────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
|
||||
controller.on('respawnCycleStarted', (cycleNumber: number) => {
|
||||
deps.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
|
||||
controller.on('respawnCycleCompleted', (cycleNumber: number) => {
|
||||
deps.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
|
||||
controller.on('respawnBlocked', (data: { reason: string; details: string }) => {
|
||||
deps.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
|
||||
const sessionForPush = deps.getSession(sessionId);
|
||||
deps.sendPushNotifications(SseEvent.RespawnBlocked, {
|
||||
sessionId,
|
||||
sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
|
||||
reason: data.reason,
|
||||
});
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
|
||||
});
|
||||
|
||||
// ─── Respawn Step Progress ──────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
|
||||
controller.on('stepSent', (step: string, input: string) => {
|
||||
deps.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:stepCompleted` — respawn step finished */
|
||||
controller.on('stepCompleted', (step: string) => {
|
||||
deps.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
|
||||
controller.on('detectionUpdate', (detection: unknown) => {
|
||||
deps.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
|
||||
controller.on('autoAcceptSent', () => {
|
||||
deps.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
|
||||
});
|
||||
|
||||
// ─── AI Checker Events ──────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
|
||||
controller.on('aiCheckStarted', () => {
|
||||
deps.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
|
||||
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
deps.broadcast(SseEvent.RespawnAiCheckCompleted, {
|
||||
sessionId,
|
||||
verdict: result.verdict,
|
||||
reasoning: result.reasoning,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordAiCheckResult(result.verdict);
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
|
||||
controller.on('aiCheckFailed', (error: string) => {
|
||||
deps.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordError('AI check failed', error);
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
|
||||
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
|
||||
deps.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
|
||||
});
|
||||
|
||||
// ─── Plan Checker Events ────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
|
||||
controller.on('planCheckStarted', () => {
|
||||
deps.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
|
||||
controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
deps.broadcast(SseEvent.RespawnPlanCheckCompleted, {
|
||||
sessionId,
|
||||
verdict: result.verdict,
|
||||
reasoning: result.reasoning,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:planCheckFailed` — plan check errored */
|
||||
controller.on('planCheckFailed', (error: string) => {
|
||||
deps.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
|
||||
});
|
||||
|
||||
// ─── Timer Events (UI countdown display) ────────────────
|
||||
|
||||
/** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
|
||||
controller.on('timerStarted', (timer) => {
|
||||
deps.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
|
||||
controller.on('timerCancelled', (timerName, reason) => {
|
||||
deps.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:timerCompleted` — timer expired */
|
||||
controller.on('timerCompleted', (timerName) => {
|
||||
deps.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
|
||||
});
|
||||
|
||||
// ─── Logging & Errors ───────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
|
||||
controller.on('actionLog', (action) => {
|
||||
deps.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:log` — general respawn log message */
|
||||
controller.on('log', (message: string) => {
|
||||
deps.broadcast(SseEvent.RespawnLog, { sessionId, message });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:error` — respawn controller error */
|
||||
controller.on('error', (error: Error) => {
|
||||
deps.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordError('Respawn error', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timed Respawn
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set up a duration-limited respawn timer that stops respawn after N minutes.
|
||||
*/
|
||||
export function setupTimedRespawn(sessionId: string, durationMinutes: number, deps: RespawnWiringDeps): void {
|
||||
const timers = deps.getRespawnTimers();
|
||||
|
||||
// Clear existing timer if any
|
||||
const existing = timers.get(sessionId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const endAt = now + durationMinutes * 60 * 1000;
|
||||
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
// Stop respawn when time is up
|
||||
const controllers = deps.getRespawnControllers();
|
||||
const controller = controllers.get(sessionId);
|
||||
if (controller) {
|
||||
controller.stop();
|
||||
controller.removeAllListeners();
|
||||
controllers.delete(sessionId);
|
||||
deps.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
|
||||
}
|
||||
timers.delete(sessionId);
|
||||
// Update persisted state (respawn no longer active)
|
||||
const session = deps.getSession(sessionId);
|
||||
if (session) {
|
||||
deps.persistSessionState(session);
|
||||
}
|
||||
},
|
||||
durationMinutes * 60 * 1000
|
||||
);
|
||||
|
||||
timers.set(sessionId, { timer, endAt, startedAt: now });
|
||||
deps.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Respawn Controller Restore
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Restore a RespawnController from persisted configuration.
|
||||
* Creates the controller, wires listeners, and starts after a grace period.
|
||||
*/
|
||||
export function restoreRespawnController(
|
||||
session: Session,
|
||||
config: PersistedRespawnConfig,
|
||||
source: string,
|
||||
deps: RespawnWiringDeps
|
||||
): void {
|
||||
const controller = new RespawnController(session, {
|
||||
idleTimeoutMs: config.idleTimeoutMs,
|
||||
updatePrompt: config.updatePrompt,
|
||||
interStepDelayMs: config.interStepDelayMs,
|
||||
enabled: true,
|
||||
sendClear: config.sendClear,
|
||||
sendInit: config.sendInit,
|
||||
kickstartPrompt: config.kickstartPrompt,
|
||||
completionConfirmMs: config.completionConfirmMs,
|
||||
noOutputTimeoutMs: config.noOutputTimeoutMs,
|
||||
autoAcceptPrompts: config.autoAcceptPrompts,
|
||||
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
||||
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
||||
aiIdleCheckModel: config.aiIdleCheckModel,
|
||||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
|
||||
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
|
||||
aiPlanCheckModel: config.aiPlanCheckModel,
|
||||
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
|
||||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
|
||||
});
|
||||
|
||||
const controllers = deps.getRespawnControllers();
|
||||
controllers.set(session.id, controller);
|
||||
wireRespawnListeners(session.id, controller, deps);
|
||||
|
||||
// Calculate delay: wait until grace period after server start before starting respawn
|
||||
// This prevents false idle detection immediately after a server restart/rebuild
|
||||
const timeSinceStart = Date.now() - deps.serverStartTime;
|
||||
const delayMs = Math.max(0, deps.respawnRestoreGracePeriodMs - timeSinceStart);
|
||||
|
||||
const pendingStarts = deps.getPendingRespawnStarts();
|
||||
|
||||
if (delayMs > 0) {
|
||||
console.log(
|
||||
`[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)`
|
||||
);
|
||||
const delayTimer = setTimeout(() => {
|
||||
pendingStarts.delete(session.id);
|
||||
// Verify session still exists (may have been deleted during grace period)
|
||||
if (!deps.sessionExists(session.id)) {
|
||||
console.log(`[Server] Skipping restored respawn start - session ${session.id} no longer exists`);
|
||||
return;
|
||||
}
|
||||
// Double-check controller still exists and is stopped
|
||||
const ctrl = controllers.get(session.id);
|
||||
if (ctrl && ctrl.state === 'stopped') {
|
||||
ctrl.start();
|
||||
deps.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
|
||||
console.log(`[Server] Restored respawn controller started for session ${session.id}`);
|
||||
}
|
||||
}, delayMs);
|
||||
pendingStarts.set(session.id, delayTimer);
|
||||
} else {
|
||||
// Grace period has passed, start immediately
|
||||
controller.start();
|
||||
console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)`);
|
||||
}
|
||||
|
||||
if (config.durationMinutes && config.durationMinutes > 0) {
|
||||
setupTimedRespawn(session.id, config.durationMinutes, deps);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Respawn Config Persistence
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Save respawn config to mux for restart recovery.
|
||||
*/
|
||||
export function saveRespawnConfig(
|
||||
sessionId: string,
|
||||
config: RespawnConfig,
|
||||
mux: TerminalMultiplexer,
|
||||
durationMinutes?: number
|
||||
): void {
|
||||
const persistedConfig: PersistedRespawnConfig = {
|
||||
enabled: config.enabled,
|
||||
idleTimeoutMs: config.idleTimeoutMs,
|
||||
updatePrompt: config.updatePrompt,
|
||||
interStepDelayMs: config.interStepDelayMs,
|
||||
sendClear: config.sendClear,
|
||||
sendInit: config.sendInit,
|
||||
kickstartPrompt: config.kickstartPrompt,
|
||||
autoAcceptPrompts: config.autoAcceptPrompts,
|
||||
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
||||
completionConfirmMs: config.completionConfirmMs,
|
||||
noOutputTimeoutMs: config.noOutputTimeoutMs,
|
||||
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
||||
aiIdleCheckModel: config.aiIdleCheckModel,
|
||||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
|
||||
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
|
||||
aiPlanCheckModel: config.aiPlanCheckModel,
|
||||
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
|
||||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
|
||||
durationMinutes,
|
||||
};
|
||||
mux.updateRespawnConfig(sessionId, persistedConfig);
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
*/
|
||||
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import type { z } from 'zod';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
@@ -32,6 +35,45 @@ export function validatePathWithinBase(name: string, baseDir: string): string |
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON config file, returning a default value on ENOENT.
|
||||
* Logs an error for any I/O failure other than a missing file.
|
||||
*/
|
||||
export async function readJsonConfig<T>(filePath: string, logLabel: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content) as T;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error(`Failed to read ${logLabel}:`, err);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a file path (possibly containing symlinks) resolves to a location
|
||||
* within the given session working directory. Returns the resolved and relative paths,
|
||||
* or null if the path escapes the directory or doesn't exist.
|
||||
*/
|
||||
export function validateSessionFilePath(
|
||||
sessionWorkingDir: string,
|
||||
filePath: string
|
||||
): { resolvedPath: string; relativePath: string } | null {
|
||||
const fullPath = resolve(sessionWorkingDir, filePath);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(fullPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const relativePath = relative(sessionWorkingDir, resolvedPath);
|
||||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
return { resolvedPath, relativePath };
|
||||
}
|
||||
|
||||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
||||
|
||||
@@ -50,6 +92,31 @@ export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a request body against a Zod schema, or throw a structured 400 error.
|
||||
* Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`.
|
||||
*/
|
||||
export function parseBody<T>(schema: z.ZodType<T>, body: unknown, errorMessage?: string): T {
|
||||
const result = schema.safeParse(body);
|
||||
if (!result.success) {
|
||||
const msg = errorMessage ?? result.error.issues[0]?.message ?? 'Validation failed';
|
||||
throw Object.assign(new Error(msg), {
|
||||
statusCode: 400,
|
||||
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist session state and broadcast a SessionUpdated event.
|
||||
* Replaces the repeated two-line pattern across route handlers.
|
||||
*/
|
||||
export function persistAndBroadcastSession(ctx: SessionPort & EventPort, session: Session): void {
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats uptime in seconds to a human-readable string (e.g., "1d 2h 30m 15s").
|
||||
*/
|
||||
@@ -118,6 +185,26 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
|
||||
return safeFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles a service (watcher/manager) on or off based on an enabled flag.
|
||||
* Logs start/stop to console with the given label. Runs an optional callback after starting.
|
||||
*/
|
||||
export function toggleService(
|
||||
enabled: boolean,
|
||||
service: { isRunning(): boolean; start(): void; stop(): void },
|
||||
label: string,
|
||||
onStart?: () => void
|
||||
): void {
|
||||
if (enabled && !service.isRunning()) {
|
||||
service.start();
|
||||
onStart?.();
|
||||
console.log(`${label} started via settings change`);
|
||||
} else if (!enabled && service.isRunning()) {
|
||||
service.stop();
|
||||
console.log(`${label} stopped via settings change`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-configure Ralph tracker for a session.
|
||||
*
|
||||
|
||||
@@ -11,26 +11,19 @@ import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
||||
import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
|
||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(LINKED_CASES_FILE, 'utf-8'));
|
||||
} catch (err) {
|
||||
// Only warn on real I/O errors, not ENOENT (file missing) or SyntaxError (corrupted JSON)
|
||||
if ((err as NodeJS.ErrnoException).code && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn('[Server] Failed to read linked cases:', err);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||
}
|
||||
|
||||
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
|
||||
@@ -54,7 +47,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
try {
|
||||
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) {
|
||||
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||
cases.push({
|
||||
name: e.name,
|
||||
path: join(CASES_DIR, e.name),
|
||||
@@ -70,7 +63,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const linkedCases = await readLinkedCases();
|
||||
const existingNames = new Set(cases.map((c) => c.name));
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && existsSync(path)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
@@ -79,15 +72,23 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by persisted caseOrder from settings.json
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
|
||||
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
|
||||
if (caseOrder.length > 0) {
|
||||
const orderMap = new Map(caseOrder.map((name, idx) => [name, idx]));
|
||||
cases.sort((a, b) => {
|
||||
const ai = orderMap.get(a.name) ?? Number.MAX_SAFE_INTEGER;
|
||||
const bi = orderMap.get(b.name) ?? Number.MAX_SAFE_INTEGER;
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
|
||||
return cases;
|
||||
});
|
||||
|
||||
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const result = CreateCaseSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { name, description } = result.data;
|
||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||
|
||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||
if (!casePath) {
|
||||
@@ -120,11 +121,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
// Link an existing folder as a case
|
||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const lcResult = LinkCaseSchema.safeParse(req.body);
|
||||
if (!lcResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { name, path: folderPath } = lcResult.data;
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
|
||||
@@ -166,6 +163,68 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Delete / Unlink Case ==========
|
||||
|
||||
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) {
|
||||
delete linkedCases[name];
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
// Case in CASES_DIR — delete the entire directory
|
||||
const casePath = join(CASES_DIR, name);
|
||||
if (!existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(casePath, { recursive: true, force: true });
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' });
|
||||
return { success: true, data: { name } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Reorder Cases ==========
|
||||
|
||||
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
|
||||
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
|
||||
|
||||
try {
|
||||
const dir = join(homedir(), '.codeman');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const merged = { ...existing, caseOrder: order };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
ctx.broadcast(SseEvent.CaseOrderChanged, { order });
|
||||
return { success: true, data: { order } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/cases/:name', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
@@ -148,17 +147,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
}
|
||||
|
||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||
const fullPath = resolve(session.workingDir, filePath);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(fullPath);
|
||||
} catch {
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
if (!validated) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
|
||||
}
|
||||
const relativePath = relative(session.workingDir, resolvedPath);
|
||||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
|
||||
}
|
||||
const { resolvedPath } = validated;
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
@@ -246,7 +239,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
// Serve raw file content (for images/binary files)
|
||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const { path: filePath, download } = req.query as { path?: string; download?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (!filePath) {
|
||||
@@ -255,19 +248,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
}
|
||||
|
||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||
const fullPath = resolve(session.workingDir, filePath);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(fullPath);
|
||||
} catch {
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
const relativePath = relative(session.workingDir, resolvedPath);
|
||||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'));
|
||||
return;
|
||||
}
|
||||
const { resolvedPath } = validated;
|
||||
|
||||
try {
|
||||
// Validate file size before reading (DoS protection - prevent memory exhaustion)
|
||||
@@ -306,6 +292,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
};
|
||||
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
if (download === 'true') {
|
||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
||||
'Content-Length': content.length,
|
||||
});
|
||||
reply.raw.end(content);
|
||||
return;
|
||||
}
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.send(content);
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
||||
import { sanitizeHookData } from '../route-helpers.js';
|
||||
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerHookEventRoutes(
|
||||
@@ -15,11 +15,7 @@ export function registerHookEventRoutes(
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
): void {
|
||||
app.post('/api/hook-event', async (req) => {
|
||||
const result = HookEventSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { event, sessionId, data } = result.data;
|
||||
const { event, sessionId, data } = parseBody(HookEventSchema, req.body);
|
||||
if (!ctx.sessions.has(sessionId)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
@@ -14,4 +14,5 @@ export { registerSessionRoutes } from './session-routes.js';
|
||||
export { registerRespawnRoutes } from './respawn-routes.js';
|
||||
export { registerRalphRoutes } from './ralph-routes.js';
|
||||
export { registerPlanRoutes } from './plan-routes.js';
|
||||
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator loop routes — plan-based autonomous execution.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /api/orchestrator/start — Start orchestration with a goal
|
||||
* - POST /api/orchestrator/approve — Approve generated plan
|
||||
* - POST /api/orchestrator/reject — Reject plan with feedback
|
||||
* - POST /api/orchestrator/pause — Pause execution
|
||||
* - POST /api/orchestrator/resume — Resume from pause
|
||||
* - POST /api/orchestrator/stop — Stop and clean up
|
||||
* - GET /api/orchestrator/status — Get current status
|
||||
* - GET /api/orchestrator/plan — Get current plan
|
||||
* - POST /api/orchestrator/phase/:id/skip — Skip a phase
|
||||
* - POST /api/orchestrator/phase/:id/retry — Retry a failed phase
|
||||
*
|
||||
* @module web/routes/orchestrator-routes
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, OrchestratorPort } from '../ports/index.js';
|
||||
|
||||
export function registerOrchestratorRoutes(app: FastifyInstance, ctx: OrchestratorPort & EventPort): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function getLoop() {
|
||||
const loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
throw Object.assign(new Error('Orchestrator not initialized'), {
|
||||
statusCode: 503,
|
||||
body: createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'Orchestrator not initialized'),
|
||||
});
|
||||
}
|
||||
return loop;
|
||||
}
|
||||
|
||||
const EVENT_MAP: [string, (typeof SseEvent)[keyof typeof SseEvent], string[]][] = [
|
||||
['stateChanged', SseEvent.OrchestratorStateChanged, ['state', 'prevState']],
|
||||
['planProgress', SseEvent.OrchestratorPlanProgress, ['phase', 'detail']],
|
||||
['planReady', SseEvent.OrchestratorPlanReady, ['plan']],
|
||||
['phaseStarted', SseEvent.OrchestratorPhaseStarted, ['phase']],
|
||||
['phaseCompleted', SseEvent.OrchestratorPhaseCompleted, ['phase']],
|
||||
['phaseFailed', SseEvent.OrchestratorPhaseFailed, ['phase', 'reason']],
|
||||
['taskAssigned', SseEvent.OrchestratorTaskAssigned, ['task', 'sessionId']],
|
||||
['taskCompleted', SseEvent.OrchestratorTaskCompleted, ['task']],
|
||||
['taskFailed', SseEvent.OrchestratorTaskFailed, ['task', 'error']],
|
||||
['completed', SseEvent.OrchestratorCompleted, ['stats']],
|
||||
];
|
||||
|
||||
let forwardingLoop: import('../../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
|
||||
if (forwardingLoop === loop) return; // Already attached to this loop instance
|
||||
forwardingLoop = loop;
|
||||
for (const [event, sseEvent, argNames] of EVENT_MAP) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
loop.on(event, (...args: any[]) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
argNames.forEach((name, i) => {
|
||||
payload[name] = args[i];
|
||||
});
|
||||
ctx.broadcast(sseEvent, payload);
|
||||
});
|
||||
}
|
||||
// Special cases with non-trivial payload transforms
|
||||
loop.on('verificationResult', (phase, result) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorVerification, { phaseId: phase.id, result });
|
||||
});
|
||||
loop.on('error', (error) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorError, { error: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Start
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/start', async (req) => {
|
||||
const { goal, config } = parseBody(OrchestratorStartSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Initialize loop if needed
|
||||
let loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
loop = ctx.initOrchestratorLoop();
|
||||
setupEventForwarding(loop);
|
||||
}
|
||||
|
||||
// Check if already running
|
||||
if (loop.isRunning()) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Orchestrator is already running');
|
||||
}
|
||||
|
||||
try {
|
||||
// Start is async — kicks off planning
|
||||
loop.start(goal).catch((err) => {
|
||||
console.error('[Orchestrator Route] Start failed:', getErrorMessage(err));
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: loop.state,
|
||||
message: 'Orchestrator started — generating plan',
|
||||
config: config ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Approve / Reject Plan
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/approve', async () => {
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
loop.approve().catch((err) => {
|
||||
console.error('[Orchestrator Route] Approve failed:', getErrorMessage(err));
|
||||
});
|
||||
return { ok: true, state: loop.state };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/reject', async (req) => {
|
||||
const loop = getLoop();
|
||||
|
||||
const { feedback } = parseBody(OrchestratorRejectSchema, req.body, 'Feedback is required');
|
||||
|
||||
try {
|
||||
loop.reject(feedback).catch((err) => {
|
||||
console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err));
|
||||
});
|
||||
return { ok: true, state: loop.state };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Pause / Resume / Stop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/pause', async () => {
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
loop.pause();
|
||||
return { ok: true, state: loop.state };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/resume', async () => {
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
loop.resume().catch((err) => {
|
||||
console.error('[Orchestrator Route] Resume failed:', getErrorMessage(err));
|
||||
});
|
||||
return { ok: true, state: loop.state };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/stop', async () => {
|
||||
const loop = getLoop();
|
||||
|
||||
try {
|
||||
await loop.stop();
|
||||
return { ok: true, state: loop.state };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Status / Plan
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.get('/api/orchestrator/status', async () => {
|
||||
const loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
return { ok: true, state: 'idle', plan: null, stats: null };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...loop.getStatus(),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/orchestrator/plan', async () => {
|
||||
const loop = ctx.orchestratorLoop;
|
||||
if (!loop) {
|
||||
return { ok: true, plan: null };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
plan: loop.getPlan(),
|
||||
currentPhase: loop.getCurrentPhase(),
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Phase Operations
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.post('/api/orchestrator/phase/:id/skip', async (req) => {
|
||||
const loop = getLoop();
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
try {
|
||||
await loop.skipPhase(id);
|
||||
return { ok: true, state: loop.state };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orchestrator/phase/:id/retry', async (req) => {
|
||||
const loop = getLoop();
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
try {
|
||||
loop.retryPhase(id).catch((err) => {
|
||||
console.error('[Orchestrator Route] Retry failed:', getErrorMessage(err));
|
||||
});
|
||||
return { ok: true, state: loop.state };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
PlanTaskUpdateSchema,
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { findSessionOrFail, parseBody, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -29,11 +29,11 @@ export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// ========== Generate Plan (Simple) ==========
|
||||
|
||||
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
||||
const gpResult = GeneratePlanSchema.safeParse(req.body);
|
||||
if (!gpResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { taskDescription, detailLevel = 'standard' } = gpResult.data;
|
||||
const { taskDescription, detailLevel = 'standard' } = parseBody(
|
||||
GeneratePlanSchema,
|
||||
req.body,
|
||||
'Invalid request body'
|
||||
);
|
||||
|
||||
// Build sophisticated prompt based on Ralph Wiggum methodology
|
||||
const detailConfig = {
|
||||
@@ -223,11 +223,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// ========== Generate Plan (Detailed Orchestration) ==========
|
||||
|
||||
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
|
||||
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
|
||||
if (!gpdResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { taskDescription, caseName } = gpdResult.data;
|
||||
const { taskDescription, caseName } = parseBody(GeneratePlanDetailedSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Determine output directory for saving wizard results
|
||||
let outputDir: string | undefined;
|
||||
@@ -327,11 +323,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// ========== Cancel Plan Generation ==========
|
||||
|
||||
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
|
||||
const cpResult = CancelPlanSchema.safeParse(req.body);
|
||||
if (!cpResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { orchestratorId } = cpResult.data;
|
||||
const { orchestratorId } = parseBody(CancelPlanSchema, req.body, 'Invalid request body');
|
||||
|
||||
// If specific orchestrator ID provided, cancel just that one
|
||||
if (orchestratorId) {
|
||||
@@ -374,11 +366,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||||
}
|
||||
|
||||
const ptuResult = PlanTaskUpdateSchema.safeParse(req.body);
|
||||
if (!ptuResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const update = ptuResult.data as {
|
||||
const update = parseBody(PlanTaskUpdateSchema, req.body, 'Invalid request body') as {
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked';
|
||||
error?: string;
|
||||
incrementAttempts?: boolean;
|
||||
@@ -454,11 +442,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||||
}
|
||||
|
||||
const ptaResult = PlanTaskAddSchema.safeParse(req.body);
|
||||
if (!ptaResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const task = ptaResult.data;
|
||||
const task = parseBody(PlanTaskAddSchema, req.body, 'Invalid request body');
|
||||
|
||||
const result = tracker.addPlanTask(task);
|
||||
ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { PushSubscribeSchema, PushPreferencesUpdateSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import type { InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
@@ -15,11 +16,7 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
});
|
||||
|
||||
app.post('/api/push/subscribe', async (req) => {
|
||||
const result = PushSubscribeSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { endpoint, keys, userAgent, pushPreferences } = result.data;
|
||||
const { endpoint, keys, userAgent, pushPreferences } = parseBody(PushSubscribeSchema, req.body);
|
||||
const record = ctx.pushStore.addSubscription({
|
||||
id: uuidv4(),
|
||||
endpoint,
|
||||
@@ -33,11 +30,8 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
|
||||
app.put('/api/push/subscribe/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = PushPreferencesUpdateSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const updated = ctx.pushStore.updatePreferences(id, result.data.pushPreferences);
|
||||
const { pushPreferences } = parseBody(PushPreferencesUpdateSchema, req.body);
|
||||
const updated = ctx.pushStore.updatePreferences(id, pushPreferences);
|
||||
if (!updated) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Session } from '../../session.js';
|
||||
import { RespawnController } from '../../respawn-controller.js';
|
||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -31,22 +31,18 @@ export function registerRalphRoutes(
|
||||
// Configure Ralph tracker for a session
|
||||
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const ralphResult = RalphConfigSchema.safeParse(req.body);
|
||||
if (!ralphResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as {
|
||||
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = parseBody(
|
||||
RalphConfigSchema,
|
||||
req.body,
|
||||
'Invalid request body'
|
||||
) as {
|
||||
enabled?: boolean;
|
||||
completionPhrase?: string;
|
||||
maxIterations?: number;
|
||||
reset?: boolean | 'full';
|
||||
disableAutoEnable?: boolean;
|
||||
};
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// Ralph tracker is not supported for opencode sessions
|
||||
if (session.mode === 'opencode') {
|
||||
@@ -111,11 +107,7 @@ export function registerRalphRoutes(
|
||||
// Reset circuit breaker for Ralph tracker
|
||||
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.ralphTracker.resetCircuitBreaker();
|
||||
return { success: true };
|
||||
@@ -124,11 +116,7 @@ export function registerRalphRoutes(
|
||||
// Get Ralph status block and circuit breaker state
|
||||
app.get('/api/sessions/:id/ralph-status', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -148,11 +136,7 @@ export function registerRalphRoutes(
|
||||
// Generate @fix_plan.md content from todos
|
||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const content = session.ralphTracker.generateFixPlanMarkdown();
|
||||
return {
|
||||
@@ -167,16 +151,8 @@ export function registerRalphRoutes(
|
||||
// Import todos from @fix_plan.md content
|
||||
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const importResult = FixPlanImportSchema.safeParse(req.body);
|
||||
if (!importResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { content } = importResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||||
ctx.persistSessionState(session);
|
||||
@@ -193,11 +169,7 @@ export function registerRalphRoutes(
|
||||
// Write @fix_plan.md to session's working directory
|
||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -224,11 +196,7 @@ export function registerRalphRoutes(
|
||||
// Read @fix_plan.md from session's working directory and import
|
||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -266,16 +234,8 @@ export function registerRalphRoutes(
|
||||
// This avoids mux input escaping issues with long multi-line prompts
|
||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const promptResult = RalphPromptWriteSchema.safeParse(req.body);
|
||||
if (!promptResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { content } = promptResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -308,11 +268,10 @@ export function registerRalphRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const rlResult = RalphLoopStartSchema.safeParse(req.body);
|
||||
if (!rlResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data;
|
||||
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = parseBody(
|
||||
RalphLoopStartSchema,
|
||||
req.body
|
||||
);
|
||||
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
|
||||
@@ -349,7 +308,7 @@ export function registerRalphRoutes(
|
||||
useMux: true,
|
||||
mode: 'claude',
|
||||
niceConfig,
|
||||
model: rlModelConfig?.defaultModel,
|
||||
model: rlModelConfig?.defaultModel || undefined,
|
||||
claudeMode: rlClaudeModeConfig.claudeMode,
|
||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespa
|
||||
import { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
|
||||
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js';
|
||||
import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
@@ -84,11 +84,7 @@ export function registerRespawnRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
let body: Partial<RespawnConfig> | undefined;
|
||||
if (req.body) {
|
||||
const result = RespawnConfigSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config');
|
||||
}
|
||||
body = result.data as Partial<RespawnConfig>;
|
||||
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
@@ -162,11 +158,7 @@ export function registerRespawnRoutes(
|
||||
app.put('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Validate respawn config to prevent arbitrary field injection
|
||||
const parseResult = RespawnConfigSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`);
|
||||
}
|
||||
const config = parseResult.data as Partial<RespawnConfig>;
|
||||
const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { statSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { ScheduledRunSchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
|
||||
|
||||
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
|
||||
@@ -15,11 +16,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
|
||||
});
|
||||
|
||||
app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<never>> => {
|
||||
const srResult = ScheduledRunSchema.safeParse(req.body);
|
||||
if (!srResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { prompt, workingDir, durationMinutes } = srResult.data;
|
||||
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
if (workingDir) {
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
@@ -32,9 +34,17 @@ import {
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
} from '../schemas.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } from '../route-helpers.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
CASES_DIR,
|
||||
findSessionOrFail,
|
||||
parseBody,
|
||||
persistAndBroadcastSession,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars, updateCaseModel } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -44,6 +54,9 @@ import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
||||
|
||||
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
||||
@@ -51,6 +64,52 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
||||
const CTRL_L_PATTERN = /\x0c/g;
|
||||
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
||||
|
||||
/**
|
||||
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
|
||||
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA, CSI n;m H = CUP)
|
||||
* to animate the spinner and update the status bar. During long thinking phases, these frames
|
||||
* accumulate to 500KB+ of repeated overwrites to the same rows. When the buffer is tailed,
|
||||
* only spinner frames are returned, making the terminal appear empty.
|
||||
*
|
||||
* Strategy: find where absolute-positioned redraws begin (first VPA sequence), then keep
|
||||
* only the last ~4KB of redraw frames (the final visual state) and discard the rest.
|
||||
*/
|
||||
function stripInkRedrawBloat(buffer: string): string {
|
||||
// Find where Ink's absolute-positioned redraws start (first CSI n d = VPA)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const firstVPA = buffer.search(/\x1b\[\d+d/);
|
||||
if (firstVPA === -1) return buffer; // No Ink redraws
|
||||
|
||||
const contentPart = buffer.slice(0, firstVPA);
|
||||
const redrawPart = buffer.slice(firstVPA);
|
||||
|
||||
// If the redraw section is small (<16KB), not worth stripping
|
||||
if (redrawPart.length < 16384) return buffer;
|
||||
|
||||
// Find the last complete Ink frame by searching for where the VPA row
|
||||
// number drops (cursor jumps back to viewport top for a new render cycle).
|
||||
// Search the last 64KB — a single Ink frame with response content can be
|
||||
// 10-20KB, so 4KB was too small and caused partial frames (blank gap).
|
||||
const searchLen = Math.min(redrawPart.length, 65536);
|
||||
const searchWindow = redrawPart.slice(-searchLen);
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const vpaRe = /\x1b\[(\d+)d/g;
|
||||
let lastFrameStart = 0;
|
||||
let prevRow = -1;
|
||||
let match;
|
||||
while ((match = vpaRe.exec(searchWindow)) !== null) {
|
||||
const row = parseInt(match[1], 10);
|
||||
// Row number dropped significantly — Ink started a new frame
|
||||
if (prevRow > 0 && row < prevRow - 5) {
|
||||
lastFrameStart = match.index;
|
||||
}
|
||||
prevRow = row;
|
||||
}
|
||||
|
||||
return contentPart + searchWindow.slice(lastFrameStart);
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
@@ -92,11 +151,7 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const result = CreateSessionSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const body = result.data;
|
||||
const body = parseBody(CreateSessionSchema, req.body);
|
||||
const workingDir = body.workingDir || process.cwd();
|
||||
|
||||
// Validate workingDir exists and is a directory
|
||||
@@ -116,6 +171,11 @@ export function registerSessionRoutes(
|
||||
await updateCaseEnvVars(workingDir, body.envOverrides);
|
||||
}
|
||||
|
||||
// Write model override to .claude/settings.local.json if provided
|
||||
if (body.modelOverride !== undefined) {
|
||||
await updateCaseModel(workingDir, body.modelOverride || null);
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
@@ -127,11 +187,45 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||
// "No conversation found" errors from Claude CLI.
|
||||
let validatedResumeId = body.resumeSessionId;
|
||||
if (validatedResumeId) {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
let found = false;
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const sessionFile = join(projectsDir, projDir, `${validatedResumeId}.jsonl`);
|
||||
try {
|
||||
const stat = await fs.stat(sessionFile);
|
||||
if (stat.size > 4000) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist in this project dir
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Projects dir doesn't exist
|
||||
}
|
||||
if (!found) {
|
||||
console.log(`[Session] Resume session ${validatedResumeId} not found on disk, starting fresh`);
|
||||
validatedResumeId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const globalNice = await ctx.getGlobalNiceConfig();
|
||||
const modelConfig = await ctx.getModelConfig();
|
||||
const mode = body.mode || 'claude';
|
||||
const model =
|
||||
mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined;
|
||||
mode === 'opencode'
|
||||
? body.openCodeConfig?.model
|
||||
: mode !== 'shell'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -144,7 +238,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
resumeSessionId: body.resumeSessionId,
|
||||
resumeSessionId: validatedResumeId,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -164,23 +258,14 @@ export function registerSessionRoutes(
|
||||
|
||||
app.put('/api/sessions/:id/name', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = SessionNameSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||
session.name = name;
|
||||
// Also update the mux session name if applicable
|
||||
ctx.mux.updateSessionName(id, session.name);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
return { success: true, name: session.name };
|
||||
});
|
||||
|
||||
@@ -188,16 +273,8 @@ export function registerSessionRoutes(
|
||||
|
||||
app.put('/api/sessions/:id/color', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = SessionColorSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
||||
if (!validColors.includes(body.color)) {
|
||||
@@ -205,8 +282,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
session.setColor(body.color as SessionColor);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
return { success: true, color: session.color };
|
||||
});
|
||||
|
||||
@@ -245,11 +321,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
||||
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
||||
@@ -264,11 +336,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/output', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -284,11 +352,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -304,11 +368,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/run-summary', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const tracker = ctx.runSummaryTrackers.get(id);
|
||||
if (!tracker) {
|
||||
@@ -328,11 +388,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get('/api/sessions/:id/active-tools', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -350,16 +406,8 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = RunPromptSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { prompt } = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -378,11 +426,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -422,11 +466,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
@@ -456,16 +496,8 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = SessionInputWithLimitSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { input, useMux } = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const inputStr = String(input);
|
||||
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||||
@@ -497,25 +529,168 @@ export function registerSessionRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Send Named Key (tmux send-keys -H) ==========
|
||||
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
|
||||
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
|
||||
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
|
||||
|
||||
app.post('/api/sessions/:id/send-key', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const key = typeof body?.key === 'string' ? body.key : '';
|
||||
|
||||
// Map key names to hex byte sequences
|
||||
const KEY_HEX_MAP: Record<string, string[]> = {
|
||||
'S-Enter': ['0a'], // \n (line feed)
|
||||
'C-Enter': ['0a'], // \n (line feed)
|
||||
};
|
||||
const hex = KEY_HEX_MAP[key];
|
||||
if (!hex) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const muxName = session.muxName;
|
||||
if (!muxName) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Server] send-key failed:', err);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Resize Terminal ==========
|
||||
|
||||
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = ResizeSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { cols, rows } = result.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const { cols, rows } = parseBody(ResizeSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.resize(cols, rows);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Get Last Response (from transcript JSONL) ==========
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// The Claude conversation ID (used as JSONL filename)
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
|
||||
// Scan ~/.claude/projects/*/ for the transcript file
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
let transcriptText = '';
|
||||
let transcriptTimestamp = '';
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
// Search from end for last assistant message with text
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const textBlocks = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text);
|
||||
if (textBlocks.length > 0) {
|
||||
transcriptText = textBlocks.join('\n\n');
|
||||
transcriptTimestamp = entry.timestamp || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
if (transcriptText) break; // Found it, stop scanning directories
|
||||
} catch {
|
||||
// File doesn't exist in this project dir, continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// projects dir doesn't exist
|
||||
}
|
||||
|
||||
// If ?context=full, return all user+assistant messages for conversation view
|
||||
const query = req.query as { context?: string };
|
||||
if (query.context === 'full' && transcriptText) {
|
||||
const allMessages: Array<{ role: string; text: string; timestamp?: string }> = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text =
|
||||
typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: (entry.message.content as Array<{ type: string; text?: string }>)
|
||||
.filter((b) => b.type === 'text' && b.text)
|
||||
.map((b) => b.text)
|
||||
.join('\n');
|
||||
// Skip system/command messages
|
||||
if (text && !text.startsWith('<local-command') && !text.startsWith('<command-name>')) {
|
||||
allMessages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
}
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const text = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text)
|
||||
.join('\n\n');
|
||||
if (text) {
|
||||
allMessages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (allMessages.length > 0) break;
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { text: transcriptText, timestamp: transcriptTimestamp, messages: allMessages };
|
||||
}
|
||||
|
||||
return {
|
||||
text: transcriptText,
|
||||
timestamp: transcriptTimestamp,
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Get Terminal Buffer ==========
|
||||
|
||||
// Query params:
|
||||
@@ -523,21 +698,23 @@ export function registerSessionRoutes(
|
||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { tail?: string };
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||
const fullSize = session.terminalBufferLength;
|
||||
let truncated = false;
|
||||
let cleanBuffer: string;
|
||||
|
||||
if (tailBytes > 0 && fullSize > tailBytes) {
|
||||
// Strip redundant Ink spinner/status redraws BEFORE tailing.
|
||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||
// the terminal appears empty when switching tabs.
|
||||
const strippedBuffer = stripInkRedrawBloat(session.terminalBuffer);
|
||||
|
||||
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
||||
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
||||
// Banner is near the top and gets discarded by tail anyway.
|
||||
cleanBuffer = session.terminalBuffer.slice(-tailBytes);
|
||||
cleanBuffer = strippedBuffer.slice(-tailBytes);
|
||||
truncated = true;
|
||||
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
|
||||
// and start from there. This prevents xterm.js from parsing a partial escape
|
||||
@@ -548,7 +725,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
} else {
|
||||
// Full buffer: clean junk before actual Claude content
|
||||
cleanBuffer = session.terminalBuffer;
|
||||
cleanBuffer = strippedBuffer;
|
||||
|
||||
// Find where Claude banner starts (has color codes before "Claude")
|
||||
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
||||
@@ -580,20 +757,11 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const acResult = AutoClearSchema.safeParse(req.body);
|
||||
if (!acResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = acResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -610,20 +778,11 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const compactResult = AutoCompactSchema.safeParse(req.body);
|
||||
if (!compactResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = compactResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -641,16 +800,8 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const iwResult = ImageWatcherSchema.safeParse(req.body);
|
||||
if (!iwResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = iwResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (body.enabled) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
@@ -674,20 +825,11 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const ffResult = FlickerFilterSchema.safeParse(req.body);
|
||||
if (!ffResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = ffResult.data;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.flickerFilterEnabled = body.enabled;
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -712,11 +854,7 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const qrResult = QuickRunSchema.safeParse(req.body);
|
||||
if (!qrResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { prompt, workingDir } = qrResult.data;
|
||||
const { prompt, workingDir } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
|
||||
|
||||
if (!prompt.trim()) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
||||
@@ -772,11 +910,7 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const result = QuickStartSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
|
||||
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
@@ -789,12 +923,23 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const casePath = validatePathWithinBase(caseName, 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
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
let linkedCases: Record<string, string> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
|
||||
linkedCases = JSON.parse(raw);
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
const linkedCasePath = linkedCases[caseName];
|
||||
const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
|
||||
if (!existsSync(casePath)) {
|
||||
try {
|
||||
mkdirSync(casePath, { recursive: true });
|
||||
@@ -822,7 +967,11 @@ export function registerSessionRoutes(
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const qsModelConfig = await ctx.getModelConfig();
|
||||
const qsModel =
|
||||
mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined;
|
||||
mode === 'opencode'
|
||||
? openCodeConfig?.model
|
||||
: mode !== 'shell'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
@@ -923,6 +1072,154 @@ export function registerSessionRoutes(
|
||||
// History — list past Claude conversations for resume
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Extract the text of the first user message from a JSONL transcript head. */
|
||||
function extractFirstUserPrompt(head: string): string | undefined {
|
||||
const MAX_PROMPT_LEN = 120;
|
||||
// Iterate lines without allocating a full split array
|
||||
let start = 0;
|
||||
while (start < head.length) {
|
||||
const end = head.indexOf('\n', start);
|
||||
const line = end === -1 ? head.slice(start) : head.slice(start, end);
|
||||
start = end === -1 ? head.length : end + 1;
|
||||
if (!line.includes('"type":"user"')) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type !== 'user' || !entry.message) continue;
|
||||
const content = entry.message.content;
|
||||
let text: string | undefined;
|
||||
if (typeof content === 'string') {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
const textBlock = content.find((b: { type: string }) => b.type === 'text');
|
||||
if (textBlock) text = textBlock.text;
|
||||
}
|
||||
if (!text) continue;
|
||||
// Strip XML-like system/command tags and ANSI escapes from transcripts
|
||||
text = text
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
if (!text) continue;
|
||||
// Skip system-injected messages, slash command artifacts, and expanded skill prompts
|
||||
if (
|
||||
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(text) ||
|
||||
/^(Please )?(analyze|review) this codebase/i.test(text) ||
|
||||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(text) ||
|
||||
/^\d+ vulnerabilit/i.test(text) ||
|
||||
/\btoolu_/.test(text) ||
|
||||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(text) ||
|
||||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(text) ||
|
||||
text.length < 8
|
||||
)
|
||||
continue;
|
||||
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '\u2026' : text;
|
||||
} catch {
|
||||
// Malformed line — skip
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
|
||||
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
|
||||
*
|
||||
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
|
||||
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
|
||||
*
|
||||
* Strategy: look-ahead matching. At each '-', try consuming multiple segments
|
||||
* joined by '_' or '-' to find an existing child directory, then recurse.
|
||||
* E.g. for segments [AI, project, Mirror] inside /Workspace:
|
||||
* try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror]
|
||||
*/
|
||||
async function decodeProjectKey(projKey: string): Promise<string> {
|
||||
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
|
||||
const segments = encoded.split('-');
|
||||
|
||||
const isDir = async (p: string): Promise<boolean> =>
|
||||
fs
|
||||
.stat(p)
|
||||
.then((s) => s.isDirectory())
|
||||
.catch(() => false);
|
||||
|
||||
let current = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < segments.length) {
|
||||
// Try progressively longer child names by joining segments with '_' or '-'
|
||||
let matched = false;
|
||||
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
|
||||
const maxLook = Math.min(i + 4, segments.length);
|
||||
for (let end = i; end < maxLook; end++) {
|
||||
// Build candidate child name from segments[i..end]
|
||||
// Try all separator combinations: for 2+ segments, try '_' first then '-'
|
||||
const candidates: string[] = [];
|
||||
if (end === i) {
|
||||
candidates.push(segments[i]);
|
||||
} else {
|
||||
// Build with underscores between joined segments
|
||||
candidates.push(segments.slice(i, end + 1).join('_'));
|
||||
// Build with dashes (literal)
|
||||
candidates.push(segments.slice(i, end + 1).join('-'));
|
||||
}
|
||||
|
||||
for (const child of candidates) {
|
||||
const candidate = current + '/' + child;
|
||||
if (await isDir(candidate)) {
|
||||
current = candidate;
|
||||
i = end + 1;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
if (!matched) {
|
||||
// No directory match found — append as-is and move on
|
||||
current = current + '/' + segments[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
const finalExists = await fs
|
||||
.access(current)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
return finalExists ? current : process.env.HOME || '/tmp';
|
||||
}
|
||||
|
||||
/** Read the first 16KB of a file for content sniffing. */
|
||||
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
|
||||
try {
|
||||
const fd = await fs.open(path, 'r');
|
||||
const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
|
||||
await fd.close();
|
||||
return buf.toString('utf8', 0, bytesRead);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the last `buf.length` bytes of a file (for tail-scanning user prompts). */
|
||||
async function readFileTail(path: string, buf: Buffer, fileSize: number): Promise<string | null> {
|
||||
try {
|
||||
const fd = await fs.open(path, 'r');
|
||||
const offset = Math.max(0, fileSize - buf.length);
|
||||
const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
|
||||
await fd.close();
|
||||
const text = buf.toString('utf8', 0, bytesRead);
|
||||
// Skip first partial line when we didn't read from the start
|
||||
if (offset > 0) {
|
||||
const nl = text.indexOf('\n');
|
||||
return nl >= 0 ? text.slice(nl + 1) : null;
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/history/sessions', async () => {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const results: Array<{
|
||||
@@ -931,7 +1228,9 @@ export function registerSessionRoutes(
|
||||
projectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
}> = [];
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
@@ -940,15 +1239,11 @@ export function registerSessionRoutes(
|
||||
const stat = await fs.stat(projPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) continue;
|
||||
|
||||
// Decode project key to working dir. The encoding replaces '/' with '-',
|
||||
// which is lossy when path components contain '-'. Do naive decode first,
|
||||
// then verify it exists. Fall back to HOME if the decoded path is invalid.
|
||||
const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/');
|
||||
const dirExists = await fs
|
||||
.access(naiveDecode)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp';
|
||||
// Decode project key to working dir. Claude CLI encodes '/' as '-',
|
||||
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
|
||||
// Use recursive backtracking: try each '-' as either '/' or literal '-',
|
||||
// verify which decoded path actually exists on disk.
|
||||
const workingDir = await decodeProjectKey(projDir);
|
||||
|
||||
const entries = await fs.readdir(projPath);
|
||||
for (const entry of entries) {
|
||||
@@ -967,24 +1262,35 @@ export function registerSessionRoutes(
|
||||
// Quick content check: verify actual conversation data exists.
|
||||
// Sessions with only file-history-snapshot or hook_progress entries have
|
||||
// no "user"/"assistant" messages and will fail claude --resume.
|
||||
// Files > 50KB are almost certainly real conversations (skip the read).
|
||||
if (fileStat.size < 50000) {
|
||||
try {
|
||||
const fd = await fs.open(filePath, 'r');
|
||||
const buf = Buffer.alloc(16384);
|
||||
const { bytesRead } = await fd.read(buf, 0, 16384, 0);
|
||||
await fd.close();
|
||||
const head = buf.toString('utf8', 0, bytesRead);
|
||||
if (
|
||||
!head.includes('"type":"user"') &&
|
||||
!head.includes('"type":"assistant"') &&
|
||||
!head.includes('"type":"summary"')
|
||||
) {
|
||||
continue; // No conversation content — skip
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
// Read first 16KB to check content and extract first user prompt.
|
||||
let firstPrompt: string | undefined;
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
const hasConversation = (text: string) =>
|
||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||||
|
||||
let foundContent = head ? hasConversation(head) : false;
|
||||
|
||||
// For large files, head may not contain user messages (e.g. /init followed
|
||||
// by large system entries). Check the tail as well.
|
||||
let tail: string | null = null;
|
||||
if (!foundContent && fileStat.size > 16384) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (tail) foundContent = hasConversation(tail);
|
||||
}
|
||||
|
||||
if (!foundContent) continue; // No conversation content — skip
|
||||
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
|
||||
// If head scan found no usable prompt (e.g. session started with /init),
|
||||
// try reading the tail for a recent user message.
|
||||
if (!firstPrompt && fileStat.size > 65536) {
|
||||
if (!tail) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
results.push({
|
||||
@@ -993,6 +1299,7 @@ export function registerSessionRoutes(
|
||||
projectKey: projDir,
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@ import {
|
||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import {
|
||||
findSessionOrFail,
|
||||
formatUptime,
|
||||
parseBody,
|
||||
readJsonConfig,
|
||||
toggleService,
|
||||
SETTINGS_PATH,
|
||||
} from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
@@ -281,15 +288,20 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Stats ==========
|
||||
|
||||
app.get('/api/stats', async () => {
|
||||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
function collectActiveTokens(): Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> {
|
||||
const tokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
for (const [sessionId, session] of ctx.sessions) {
|
||||
activeSessionTokens[sessionId] = {
|
||||
tokens[sessionId] = {
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
app.get('/api/stats', async () => {
|
||||
const activeSessionTokens = collectActiveTokens();
|
||||
return {
|
||||
success: true,
|
||||
stats: ctx.store.getAggregateStats(activeSessionTokens),
|
||||
@@ -298,14 +310,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.get('/api/token-stats', async () => {
|
||||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
for (const [sessionId, session] of ctx.sessions) {
|
||||
activeSessionTokens[sessionId] = {
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
const activeSessionTokens = collectActiveTokens();
|
||||
return {
|
||||
success: true,
|
||||
daily: ctx.store.getDailyStats(30),
|
||||
@@ -324,11 +329,8 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.put('/api/config', async (req) => {
|
||||
const parseResult = ConfigUpdateSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
|
||||
}
|
||||
ctx.store.setConfig(parseResult.data as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
||||
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
|
||||
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
||||
return { success: true, config: ctx.store.getConfig() };
|
||||
});
|
||||
|
||||
@@ -396,23 +398,11 @@ export function registerSystemRoutes(
|
||||
// ========== Settings ==========
|
||||
|
||||
app.get('/api/settings', async () => {
|
||||
try {
|
||||
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Failed to read settings:', err);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
return readJsonConfig(SETTINGS_PATH, 'settings', {});
|
||||
});
|
||||
|
||||
app.put('/api/settings', async (req) => {
|
||||
const settingsResult = SettingsUpdateSchema.safeParse(req.body);
|
||||
if (!settingsResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
|
||||
}
|
||||
const settings = settingsResult.data as Record<string, unknown>;
|
||||
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
@@ -429,30 +419,17 @@ export function registerSystemRoutes(
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
// Handle subagent tracking toggle dynamically
|
||||
const subagentEnabled = settings.subagentTrackingEnabled ?? true;
|
||||
if (subagentEnabled && !subagentWatcher.isRunning()) {
|
||||
subagentWatcher.start();
|
||||
console.log('Subagent watcher started via settings change');
|
||||
} else if (!subagentEnabled && subagentWatcher.isRunning()) {
|
||||
subagentWatcher.stop();
|
||||
console.log('Subagent watcher stopped via settings change');
|
||||
}
|
||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
const imageWatcherEnabled = settings.imageWatcherEnabled ?? false;
|
||||
if (imageWatcherEnabled && !imageWatcher.isRunning()) {
|
||||
imageWatcher.start();
|
||||
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.imageWatcherEnabled) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
}
|
||||
console.log('Image watcher started via settings change');
|
||||
} else if (!imageWatcherEnabled && imageWatcher.isRunning()) {
|
||||
imageWatcher.stop();
|
||||
console.log('Image watcher stopped via settings change');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle tunnel toggle dynamically
|
||||
if ('tunnelEnabled' in settings) {
|
||||
@@ -479,24 +456,12 @@ export function registerSystemRoutes(
|
||||
// ========== Model Configuration ==========
|
||||
|
||||
app.get('/api/execution/model-config', async () => {
|
||||
try {
|
||||
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
return { success: true, data: settings.modelConfig || {} };
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Failed to read model config:', err);
|
||||
}
|
||||
return { success: true, data: {} };
|
||||
}
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'model config', {});
|
||||
return { success: true, data: settings.modelConfig || {} };
|
||||
});
|
||||
|
||||
app.put('/api/execution/model-config', async (req) => {
|
||||
const mcResult = ModelConfigUpdateSchema.safeParse(req.body);
|
||||
if (!mcResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
|
||||
}
|
||||
const modelConfig = mcResult.data as Record<string, unknown>;
|
||||
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
@@ -536,11 +501,7 @@ export function registerSystemRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const clResult = CpuLimitSchema.safeParse(req.body);
|
||||
if (!clResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const body = clResult.data as Partial<NiceConfig>;
|
||||
const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial<NiceConfig>;
|
||||
|
||||
session.setNice(body);
|
||||
ctx.persistSessionState(session);
|
||||
@@ -560,23 +521,11 @@ export function registerSystemRoutes(
|
||||
// ========== Subagent Window State Persistence ==========
|
||||
|
||||
app.get('/api/subagent-window-states', async () => {
|
||||
try {
|
||||
const content = await fs.readFile(windowStatesPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Failed to read subagent window states:', err);
|
||||
}
|
||||
}
|
||||
return { minimized: {}, open: [] };
|
||||
return readJsonConfig(windowStatesPath, 'subagent window states', { minimized: {}, open: [] });
|
||||
});
|
||||
|
||||
app.put('/api/subagent-window-states', async (req) => {
|
||||
const swResult = SubagentWindowStatesSchema.safeParse(req.body);
|
||||
if (!swResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
|
||||
}
|
||||
const states = swResult.data as Record<string, unknown>;
|
||||
const states = parseBody(SubagentWindowStatesSchema, req.body, 'Invalid window states') as Record<string, unknown>;
|
||||
try {
|
||||
const dir = dirname(windowStatesPath);
|
||||
if (!existsSync(dir)) {
|
||||
@@ -592,23 +541,11 @@ export function registerSystemRoutes(
|
||||
// ========== Subagent Parent Associations ==========
|
||||
|
||||
app.get('/api/subagent-parents', async () => {
|
||||
try {
|
||||
const content = await fs.readFile(parentMapPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Failed to read subagent parent map:', err);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
return readJsonConfig(parentMapPath, 'subagent parent map', {});
|
||||
});
|
||||
|
||||
app.put('/api/subagent-parents', async (req) => {
|
||||
const spResult = SubagentParentMapSchema.safeParse(req.body);
|
||||
if (!spResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
|
||||
}
|
||||
const parentMap = spResult.data;
|
||||
const parentMap = parseBody(SubagentParentMapSchema, req.body, 'Invalid parent map');
|
||||
try {
|
||||
const dir = dirname(parentMapPath);
|
||||
if (!existsSync(dir)) {
|
||||
|
||||
@@ -124,6 +124,8 @@ export const CreateSessionSchema = z.object({
|
||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
@@ -429,6 +431,11 @@ export const LinkCaseSchema = z.object({
|
||||
path: safePathSchema,
|
||||
});
|
||||
|
||||
/** PUT /api/cases/order */
|
||||
export const CaseOrderSchema = z.object({
|
||||
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
|
||||
});
|
||||
|
||||
/** POST /api/auth/revoke */
|
||||
export const RevokeSessionSchema = z.object({
|
||||
sessionToken: z.string().min(1).max(200).optional(),
|
||||
@@ -580,3 +587,31 @@ export type RespawnEnableInput = z.infer<typeof RespawnEnableSchema>;
|
||||
export type PushSubscribeInput = z.infer<typeof PushSubscribeSchema>;
|
||||
export type PushPreferencesUpdateInput = z.infer<typeof PushPreferencesUpdateSchema>;
|
||||
export type RalphLoopStartInput = z.infer<typeof RalphLoopStartSchema>;
|
||||
|
||||
// ========== Orchestrator Loop ==========
|
||||
|
||||
/** POST /api/orchestrator/start */
|
||||
export const OrchestratorStartSchema = z.object({
|
||||
goal: z.string().min(1).max(100000),
|
||||
config: z
|
||||
.object({
|
||||
plannerModel: z.string().max(100).optional(),
|
||||
researchEnabled: z.boolean().optional(),
|
||||
autoApprove: z.boolean().optional(),
|
||||
maxPhaseRetries: z.number().int().min(1).max(10).optional(),
|
||||
phaseTimeoutMs: z.number().int().min(60000).max(7200000).optional(),
|
||||
enableTeamAgents: z.boolean().optional(),
|
||||
maxParallelSessions: z.number().int().min(1).max(10).optional(),
|
||||
verificationMode: z.enum(['strict', 'moderate', 'lenient']).optional(),
|
||||
compactBetweenPhases: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** POST /api/orchestrator/reject */
|
||||
export const OrchestratorRejectSchema = z.object({
|
||||
feedback: z.string().min(1).max(10000),
|
||||
});
|
||||
|
||||
export type OrchestratorStartInput = z.infer<typeof OrchestratorStartSchema>;
|
||||
export type OrchestratorRejectInput = z.infer<typeof OrchestratorRejectSchema>;
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* @fileoverview Session event listener wiring — creates, attaches, and detaches session listeners.
|
||||
*
|
||||
* Extracted from server.ts for modularity. Provides:
|
||||
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
|
||||
* - `createSessionListeners()` — builds all 25 listener handlers via dependency injection
|
||||
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
|
||||
*
|
||||
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
|
||||
* in server.ts (_doCleanupSession, exit handler, stop()).
|
||||
*
|
||||
* @dependencies session.ts (Session, event types), sse-events.ts, types.ts
|
||||
* @consumedby web/server.ts (WebServer delegates listener lifecycle here)
|
||||
*
|
||||
* @module web/session-listener-wiring
|
||||
*/
|
||||
|
||||
import type {
|
||||
Session,
|
||||
ClaudeMessage,
|
||||
BackgroundTask,
|
||||
RalphTrackerState,
|
||||
RalphTodoItem,
|
||||
ActiveBashTool,
|
||||
} from '../session.js';
|
||||
import type { RalphStatusBlock, CircuitBreakerStatus } from '../types.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
|
||||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||||
export interface SessionListenerRefs {
|
||||
terminal: (data: string) => void;
|
||||
clearTerminal: () => void;
|
||||
needsRefresh: () => void;
|
||||
message: (msg: ClaudeMessage) => void;
|
||||
error: (error: string) => void;
|
||||
completion: (result: string, cost: number) => void;
|
||||
exit: (code: number | null) => void;
|
||||
working: () => void;
|
||||
idle: () => void;
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
taskCompleted: (task: BackgroundTask) => void;
|
||||
taskFailed: (task: BackgroundTask, error: string) => void;
|
||||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
ralphCompletionDetected: (phrase: string) => void;
|
||||
ralphStatusBlockDetected: (block: RalphStatusBlock) => void;
|
||||
ralphCircuitBreakerUpdate: (status: CircuitBreakerStatus) => void;
|
||||
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
|
||||
bashToolStart: (tool: ActiveBashTool) => void;
|
||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||
}
|
||||
|
||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||
export interface SessionListenerDeps {
|
||||
broadcast(event: string, data: unknown): void;
|
||||
batchTerminalData(sessionId: string, data: string): void;
|
||||
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
|
||||
broadcastSessionStateDebounced(sessionId: string): void;
|
||||
sendPushNotifications(event: string, data: Record<string, unknown>): void;
|
||||
persistSessionState(session: Session): void;
|
||||
getSessionStateWithRespawn(session: Session): unknown;
|
||||
getRunSummaryTracker(sessionId: string): import('../run-summary.js').RunSummaryTracker | undefined;
|
||||
stopTranscriptWatcher(sessionId: string): void;
|
||||
cleanupSessionBatches(sessionId: string): void;
|
||||
cancelPersistDebounce(sessionId: string): void;
|
||||
removeRunSummaryTracker(sessionId: string): void;
|
||||
removeSessionListenerRefs(sessionId: string): void;
|
||||
cleanupRespawnOnExit(sessionId: string): void;
|
||||
getStore(): import('../state-store.js').StateStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all 25 session listener handlers, capturing dependencies via closure.
|
||||
* Call `attachSessionListeners()` after to wire them to the session.
|
||||
*/
|
||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||
return {
|
||||
// ─── Terminal Output ─────────────────────────────────────
|
||||
|
||||
/** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
|
||||
terminal: (data) => {
|
||||
deps.batchTerminalData(session.id, data);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */
|
||||
clearTerminal: () => {
|
||||
deps.broadcast(SseEvent.SessionClearTerminal, { id: session.id });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:needsRefresh` — tells clients to reload buffer */
|
||||
needsRefresh: () => {
|
||||
deps.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id });
|
||||
},
|
||||
|
||||
// ─── Session Messages & Errors ──────────────────────────
|
||||
|
||||
/** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */
|
||||
message: (msg: ClaudeMessage) => {
|
||||
deps.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:error` + sends push notification */
|
||||
error: (error) => {
|
||||
deps.broadcast(SseEvent.SessionError, { id: session.id, error });
|
||||
deps.sendPushNotifications(SseEvent.SessionError, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
error: String(error),
|
||||
});
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordError('Session error', String(error));
|
||||
},
|
||||
|
||||
/** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
|
||||
completion: (result, cost) => {
|
||||
deps.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
deps.persistSessionState(session);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
},
|
||||
|
||||
// ─── Session Lifecycle ──────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
|
||||
exit: (code) => {
|
||||
getLifecycleLog().log({
|
||||
event: 'exit',
|
||||
sessionId: session.id,
|
||||
name: session.name,
|
||||
exitCode: code,
|
||||
});
|
||||
// Wrap in try/catch to ensure cleanup always happens
|
||||
try {
|
||||
deps.broadcast(SseEvent.SessionExit, { id: session.id, code });
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
deps.persistSessionState(session);
|
||||
} catch (err) {
|
||||
console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err);
|
||||
}
|
||||
|
||||
// Always clean up respawn controller, even if broadcast failed
|
||||
try {
|
||||
deps.cleanupRespawnOnExit(session.id);
|
||||
} catch (err) {
|
||||
console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err);
|
||||
}
|
||||
|
||||
// Clean up per-session resources that are stale after PTY exit.
|
||||
try {
|
||||
// Transcript watcher is tied to the specific PTY run
|
||||
deps.stopTranscriptWatcher(session.id);
|
||||
|
||||
// Finalize run summary tracker
|
||||
deps.removeRunSummaryTracker(session.id);
|
||||
|
||||
// Flush/clear terminal batching state (no more output coming)
|
||||
deps.cleanupSessionBatches(session.id);
|
||||
|
||||
// Clear pending persist-debounce timer
|
||||
deps.cancelPersistDebounce(session.id);
|
||||
|
||||
// Close any active file streams
|
||||
fileStreamManager.closeSessionStreams(session.id);
|
||||
|
||||
// Remove stored listener refs to break closure references (prevents memory leak).
|
||||
deps.removeSessionListenerRefs(session.id);
|
||||
} catch (err) {
|
||||
console.error(`[Server] Error cleaning up session resources on exit for ${session.id}:`, err);
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Activity State ─────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:working` — Claude started processing */
|
||||
working: () => {
|
||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordWorking();
|
||||
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
|
||||
idle: () => {
|
||||
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordIdle();
|
||||
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Background Task Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `task:created` — new background task discovered */
|
||||
taskCreated: (task: BackgroundTask) => {
|
||||
deps.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/** Batched broadcast of `task:updated` — high-frequency progress updates */
|
||||
taskUpdated: (task: BackgroundTask) => {
|
||||
deps.batchTaskUpdate(session.id, task);
|
||||
},
|
||||
|
||||
/** Broadcasts `task:completed` — background task finished successfully */
|
||||
taskCompleted: (task: BackgroundTask) => {
|
||||
deps.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/** Broadcasts `task:failed` — background task errored */
|
||||
taskFailed: (task: BackgroundTask, error: string) => {
|
||||
deps.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// ─── Auto-Operations ────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
|
||||
autoClear: (data: { tokens: number; threshold: number }) => {
|
||||
deps.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordAutoClear(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => {
|
||||
deps.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
// ─── CLI Info ────────────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => {
|
||||
deps.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// ─── Ralph Tracking Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => {
|
||||
deps.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
|
||||
deps.getStore().updateRalphState(session.id, { loop: state });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => {
|
||||
deps.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
|
||||
deps.getStore().updateRalphState(session.id, { todos });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
|
||||
ralphCompletionDetected: (phrase: string) => {
|
||||
deps.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
|
||||
deps.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
phrase,
|
||||
});
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordRalphCompletion(phrase);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
|
||||
ralphStatusBlockDetected: (block: RalphStatusBlock) => {
|
||||
deps.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.addEvent(
|
||||
block.status === 'BLOCKED' ? 'warning' : 'idle_detected',
|
||||
block.status === 'BLOCKED' ? 'warning' : 'info',
|
||||
`Ralph Status: ${block.status}`,
|
||||
`Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */
|
||||
ralphCircuitBreakerUpdate: (status: CircuitBreakerStatus) => {
|
||||
deps.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status });
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker && status.state === 'OPEN') {
|
||||
tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */
|
||||
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => {
|
||||
deps.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data });
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.addEvent(
|
||||
'ralph_completion',
|
||||
'success',
|
||||
'Exit Gate Met',
|
||||
`Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Bash Tool Tracking ────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:bashToolStart` — bash tool invocation started */
|
||||
bashToolStart: (tool: ActiveBashTool) => {
|
||||
deps.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
|
||||
bashToolEnd: (tool: ActiveBashTool) => {
|
||||
deps.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
||||
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach all listeners to a session. */
|
||||
export function attachSessionListeners(session: Session, refs: SessionListenerRefs): void {
|
||||
session.on('terminal', refs.terminal);
|
||||
session.on('clearTerminal', refs.clearTerminal);
|
||||
session.on('needsRefresh', refs.needsRefresh);
|
||||
session.on('message', refs.message);
|
||||
session.on('error', refs.error);
|
||||
session.on('completion', refs.completion);
|
||||
session.on('exit', refs.exit);
|
||||
session.on('working', refs.working);
|
||||
session.on('idle', refs.idle);
|
||||
session.on('taskCreated', refs.taskCreated);
|
||||
session.on('taskUpdated', refs.taskUpdated);
|
||||
session.on('taskCompleted', refs.taskCompleted);
|
||||
session.on('taskFailed', refs.taskFailed);
|
||||
session.on('autoClear', refs.autoClear);
|
||||
session.on('autoCompact', refs.autoCompact);
|
||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
session.on('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||
session.on('ralphStatusBlockDetected', refs.ralphStatusBlockDetected);
|
||||
session.on('ralphCircuitBreakerUpdate', refs.ralphCircuitBreakerUpdate);
|
||||
session.on('ralphExitGateMet', refs.ralphExitGateMet);
|
||||
session.on('bashToolStart', refs.bashToolStart);
|
||||
session.on('bashToolEnd', refs.bashToolEnd);
|
||||
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
||||
}
|
||||
|
||||
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
||||
export function detachSessionListeners(session: Session, refs: SessionListenerRefs): void {
|
||||
session.off('terminal', refs.terminal);
|
||||
session.off('clearTerminal', refs.clearTerminal);
|
||||
session.off('needsRefresh', refs.needsRefresh);
|
||||
session.off('message', refs.message);
|
||||
session.off('error', refs.error);
|
||||
session.off('completion', refs.completion);
|
||||
session.off('exit', refs.exit);
|
||||
session.off('working', refs.working);
|
||||
session.off('idle', refs.idle);
|
||||
session.off('taskCreated', refs.taskCreated);
|
||||
session.off('taskUpdated', refs.taskUpdated);
|
||||
session.off('taskCompleted', refs.taskCompleted);
|
||||
session.off('taskFailed', refs.taskFailed);
|
||||
session.off('autoClear', refs.autoClear);
|
||||
session.off('autoCompact', refs.autoCompact);
|
||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
session.off('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||
session.off('ralphStatusBlockDetected', refs.ralphStatusBlockDetected);
|
||||
session.off('ralphCircuitBreakerUpdate', refs.ralphCircuitBreakerUpdate);
|
||||
session.off('ralphExitGateMet', refs.ralphExitGateMet);
|
||||
session.off('bashToolStart', refs.bashToolStart);
|
||||
session.off('bashToolEnd', refs.bashToolEnd);
|
||||
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* ~90 event constants organized by category:
|
||||
* ~117 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
@@ -292,12 +292,43 @@ export const HookTeammateIdle = 'hook:teammate_idle' as const;
|
||||
/** Claude Code hook: teammate task completed. */
|
||||
export const HookTaskCompleted = 'hook:task_completed' as const;
|
||||
|
||||
// ─── Orchestrator ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Orchestrator state machine transitioned. */
|
||||
export const OrchestratorStateChanged = 'orchestrator:stateChanged' as const;
|
||||
/** Orchestrator plan generation progress update. */
|
||||
export const OrchestratorPlanProgress = 'orchestrator:planProgress' as const;
|
||||
/** Orchestrator plan generated and ready for approval. */
|
||||
export const OrchestratorPlanReady = 'orchestrator:planReady' as const;
|
||||
/** Orchestrator phase started executing. */
|
||||
export const OrchestratorPhaseStarted = 'orchestrator:phaseStarted' as const;
|
||||
/** Orchestrator phase completed successfully. */
|
||||
export const OrchestratorPhaseCompleted = 'orchestrator:phaseCompleted' as const;
|
||||
/** Orchestrator phase failed. */
|
||||
export const OrchestratorPhaseFailed = 'orchestrator:phaseFailed' as const;
|
||||
/** Orchestrator verification result for a phase. */
|
||||
export const OrchestratorVerification = 'orchestrator:verification' as const;
|
||||
/** Orchestrator task assigned to session. */
|
||||
export const OrchestratorTaskAssigned = 'orchestrator:taskAssigned' as const;
|
||||
/** Orchestrator task completed. */
|
||||
export const OrchestratorTaskCompleted = 'orchestrator:taskCompleted' as const;
|
||||
/** Orchestrator task failed. */
|
||||
export const OrchestratorTaskFailed = 'orchestrator:taskFailed' as const;
|
||||
/** All orchestrator phases completed successfully. */
|
||||
export const OrchestratorCompleted = 'orchestrator:completed' as const;
|
||||
/** Orchestrator error. */
|
||||
export const OrchestratorError = 'orchestrator:error' as const;
|
||||
|
||||
// ─── Cases ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** New case directory created. */
|
||||
export const CaseCreated = 'case:created' as const;
|
||||
/** Existing directory linked as a case. */
|
||||
export const CaseLinked = 'case:linked' as const;
|
||||
/** Case deleted or unlinked. */
|
||||
export const CaseDeleted = 'case:deleted' as const;
|
||||
/** Case ordering changed. */
|
||||
export const CaseOrderChanged = 'case:order-changed' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -441,7 +472,23 @@ export const SseEvent = {
|
||||
HookTeammateIdle,
|
||||
HookTaskCompleted,
|
||||
|
||||
// Orchestrator
|
||||
OrchestratorStateChanged,
|
||||
OrchestratorPlanProgress,
|
||||
OrchestratorPlanReady,
|
||||
OrchestratorPhaseStarted,
|
||||
OrchestratorPhaseCompleted,
|
||||
OrchestratorPhaseFailed,
|
||||
OrchestratorVerification,
|
||||
OrchestratorTaskAssigned,
|
||||
OrchestratorTaskCompleted,
|
||||
OrchestratorTaskFailed,
|
||||
OrchestratorCompleted,
|
||||
OrchestratorError,
|
||||
|
||||
// Cases
|
||||
CaseCreated,
|
||||
CaseLinked,
|
||||
CaseDeleted,
|
||||
CaseOrderChanged,
|
||||
} as const;
|
||||
|
||||