Compare commits

...
Author SHA1 Message Date
arkon ea1c2ee4ec chore: version packages 2026-04-11 07:21:18 +02:00
arkonandClaude Opus 4.6 b4a808adcf fix: security hardening and cleanup from community PR cherry-picks
- Add HTML sanitizer for markdown rendering (XSS prevention)
- Switch service worker to network-first caching (deploys take effect immediately)
- Sanitize Content-Disposition filenames (header injection prevention)
- Expose session.muxName getter, replace unsafe `as any` cast
- Static import for execFile, update CLAUDE.md keyboard shortcuts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 07:20:09 +02:00
arkonandClaude Opus 4.6 f3cbe9bca6 feat: cherry-pick keyboard UX and file download from community PRs
Cherry-picked from PR #60 (keyboard UX) and PR #61 (file download):

- Alt+1-9 session switching
- Disable Ctrl+K (too easy to trigger accidentally)
- Session rename with prefix preservation (w1-case: description)
- Shift+Enter / Ctrl+Enter multiline input via tmux send-keys -H
- Android virtual keyboard fix for non-composition input
- File download button in browser file explorer (?download=true)

Dropped from PR #60: stale package-lock.json, upload popup (missing upload.html)
Dropped from PR #61: standalone /api/download endpoint (arbitrary fs access)
Fixed from PR #60: execFileSync replaced with async execFile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 06:59:50 +02:00
Ark0N a11bcb0029 Merge pull request #59 from aakhter/feat/pwa-support
feat: add PWA support for Android/iOS home screen install
2026-04-11 06:54:06 +02:00
Ark0N 47fd9a922f Merge pull request #58 from ToRvaLDz/master
feat: add named Cloudflare tunnel support
2026-04-11 06:53:56 +02:00
Ark0N 14f7d8298d Merge pull request #62 from TeigenZhang/feat/mobile-response-viewer
feat: mobile response viewer with markdown rendering
2026-04-11 06:53:47 +02:00
Teigen d32f4debb2 feat: markdown rendering for response viewer
Add marked.js (39KB) for rich text display in the response viewer.
Renders headings, code blocks, lists, tables, blockquotes, and
inline formatting with dark theme styling.

Falls back to escaped plain text if marked.js fails to load.
2026-04-10 19:12:06 +08:00
Teigen 3cb7b510f8 feat: mobile response viewer — read full Claude responses via native scroll
Claude Code's Ink framework uses alternate screen buffer + VPA cursor
positioning, resulting in near-zero xterm.js scrollback on mobile.
Instead of fighting terminal scrollback, this adds a native scrollable
overlay that reads structured responses from Claude's JSONL transcripts.

- New API: GET /api/sessions/:id/last-response reads transcript JSONL
  - ?context=full returns full conversation thread (user + assistant)
  - Fallback to terminal buffer with ANSI stripping if no transcript
- Response viewer panel: bottom sheet with native iOS/Android scroll
- "More" button loads full conversation context as threaded view
- Eye icon in header bar (mobile only), no toolbar space impact
2026-04-10 19:07:02 +08:00
Aamer AkhterandClaude Opus 4.6 6a12a72c9c local: add PWA support for Android home screen install
Add app icons, update manifest with icon entries, and add app-shell
caching to service worker for offline/instant startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:15:24 -04:00
Marco Migozzi f1a126efeb feat: add named Cloudflare tunnel support
Add named tunnel mode alongside existing quick tunnel, with systemd
service and setup helper. All tunnel parameters are configurable via
environment variables:

  CLOUDFLARED_TUNNEL_NAME   — tunnel name (default: codeman)
  CLOUDFLARED_TUNNEL_ID     — tunnel UUID (from: cloudflared tunnel list)
  CODEMAN_TUNNEL_HOSTNAME   — public hostname

Backward compatible: ./tunnel.sh [start|stop|status|url] still works.
2026-04-04 13:41:28 +02:00
Marco Migozzi 12fd780af8 feat: add named Cloudflare tunnel support
Add named tunnel mode alongside existing quick tunnel, with systemd
service and setup helper. Tunnel ID and hostname are configurable via
CLOUDFLARED_TUNNEL_ID and CODEMAN_TUNNEL_HOSTNAME env vars.
2026-04-04 13:37:20 +02:00
Teigen fd74a42933 Merge remote-tracking branch 'origin/master' into dev 2026-04-04 08:07:15 +08:00
arkonandClaude Opus 4.6 7101e64800 refactor: restructure repo for cleaner GitHub landing page
Reduce visible top-level items from 21 to 14:
- Untrack test-results/, tmp/, public symlink (added to .gitignore)
- Move agent-teams/ → docs/agent-teams/
- Move mobile-test/ → test/mobile/
- Move tools/remotion/ → scripts/remotion/
- Move eslint.config.js, vitest.config.ts → config/

All path references updated across CLAUDE.md, package.json,
.prettierignore, vitest configs, and capture scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:23:54 +02:00
Teigen 1b10d9b733 feat: mobile logo, expandable history, fix session resume
- Show Codeman logo on mobile as compact home button (was hidden)
- Add "Show More" button for history sessions (initial 4, expand all)
- Deduplicate by projectKey instead of workingDir (lossy decode fix)
- Fix project key decoding: handle '_' encoded as '-' with look-ahead
- Pre-validate resumeSessionId before passing to Claude CLI
- Apply content validation to all session files regardless of size
2026-04-03 11:02:43 +08:00
arkon 5078f5251d chore: version packages 2026-04-03 04:28:36 +02:00
arkonandClaude Opus 4.6 196af8fba7 fix: allow bracket chars in model flag for opus[1m] context window
The model validation regex rejected brackets, silently dropping models
like opus[1m]. Also quote the model flag to prevent bash glob expansion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 04:27:32 +02:00
arkonandClaude Opus 4.6 a9b22b86a4 docs: use launchctl bootstrap instead of deprecated load
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 04:20:42 +02:00
arkonandClaude Opus 4.6 28cace5858 docs: clean up README install and service sections
Remove fork/branch install instructions and env vars table for cleaner
first impression. Reformat systemd and launchd service blocks as
readable multi-line heredocs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 04:19:45 +02:00
arkon 0a594b61bd chore: version packages 2026-04-03 04:17:33 +02:00
arkon 89d787a949 chore: version packages 2026-04-03 04:01:08 +02:00
arkonandClaude Opus 4.6 bd9797b68c fix: sanitize case names from filesystem to prevent XSS in inline handlers
Filter readdir and linked-case names through /^[a-zA-Z0-9_-]+$/ before
returning them from GET /api/cases. Prevents XSS via maliciously-named
directories reaching frontend inline onclick handlers where escapeHtml
is insufficient (HTML-decoded back to quotes before JS execution).

Also fix misleading "Drag or use arrows" hint (no drag-and-drop exists).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 03:58:29 +02:00
Ark0N 0e6cd94312 Merge pull request #56 from TeigenZhang/feat/case-manage-reorder-delete
feat: add case reorder and delete in Manage tab
2026-04-03 03:53:25 +02:00
arkonandClaude Opus 4.6 24a6f1cac8 chore: remove accidentally committed build artifact and dev-specific script
Remove dist/state-store.js (compiled build artifact that should not be tracked)
and scripts/claudeman-launchd-wrapper.sh (developer-specific launchd wrapper
with hardcoded paths) that were included in #55.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 03:51:00 +02:00
Ark0N 8e679a280b Merge pull request #55 from TeigenZhang/fix/auto-attach-on-restart
fix: auto-attach PTY on server restart
2026-04-03 03:50:31 +02:00
Teigen c642689bbd feat: add case reorder and delete in Manage tab
Add a "Manage" tab to the create-case modal with up/down reorder
buttons and delete for each case. Linked cases are unlinked (folder
preserved); CASES_DIR cases are permanently deleted.

Backend:
- DELETE /api/cases/:name — unlink or delete
- PUT /api/cases/order — persist ordering to settings.json
- GET /api/cases now respects saved caseOrder

Frontend:
- Third "Manage" tab in createCaseModal with case list
- Delete button in mobile case picker bottom sheet
- SSE events: case:deleted, case:order-changed
2026-04-03 09:45:19 +08:00
Teigen 28a6247c27 fix: auto-attach PTY to surviving tmux sessions on server restart
Previously, restoreMuxSessions() only created Session objects without
attaching PTY processes. Sessions stayed at pid=null until the client
manually selected them, causing terminals to appear "closed" after deploy.

Now the server calls startInteractive() for each recovered session during
startup, so all sessions resume capturing output immediately. The frontend
auto-attach condition is also relaxed from (pid===null && status==='idle')
to (pid===null && !_ended) as a safety net for edge cases.
2026-04-02 21:35:00 +08:00
Teigen 0ceb455c4b feat: add Ctrl+O button to mobile keyboard accessory bar 2026-04-02 21:13:17 +08:00
Teigen e51117dfa9 feat: add left/right arrow buttons to mobile keyboard accessory bar
Support cursor left/right movement on mobile, using the same blue
accessory-btn-arrow style as the existing up/down arrows.
2026-04-02 15:19:15 +08:00
Teigen 13d41cf7c7 feat: add Tab, Esc, ⌥Enter buttons to mobile keyboard accessory bar
- Add Tab (forward), Esc, and Option+Enter (newline) buttons
- Reorder buttons: ↑ ↓ 📋 Tab ⇧Tab ⌥Enter Esc /init /clear /compact dismiss
- Unify dismiss button style with arrow buttons (was oversized with custom class)
- Remove unused .accessory-btn-dismiss CSS rules
2026-04-02 14:32:54 +08:00
Teigen 2c7557d002 fix state store temp file collisions 2026-04-02 14:24:10 +08:00
arkonandClaude Opus 4.6 53b473708f fix: macOS support — HTML cache, launchd service, trust dialog
Three fixes for macOS deployments:

1. HTML cache bug: @fastify/static with preCompressed serves .html.br/.html.gz
   files, so path.endsWith('.html') missed them — HTML got 1-year immutable
   cache headers instead of no-cache, causing stale pages after deploys.

2. Installer launchd support: macOS now gets proper LaunchAgent setup (like
   systemd on Linux). Removes competing LaunchDaemons to prevent duplicate
   services fighting over the port. Update/uninstall also handle launchd.

3. Trust dialog auto-accept: Claude CLI 2.x shows a workspace trust prompt
   on first launch per directory. Sessions detect "trust this folder" in PTY
   output and auto-send Enter, preventing sessions from hanging on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 08:51:37 +02:00
arkonandClaude Opus 4.6 2cba393ae5 fix: installer fails on macOS when piped via curl | bash
When running `curl | bash`, stdin is the pipe, not the terminal.
Homebrew and sudo need TTY access to prompt for the password.
Redirect /dev/tty as stdin for these subprocesses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 19:34:39 +02:00
arkon 64b8ea30b2 chore: version packages 2026-03-31 03:10:22 +02:00
Ark0N 5743af3339 Merge pull request #52 from TeigenZhang/feat/default-model-support
Thanks for the contribution @TeigenZhang! Clean, well-scoped change — applied consistently across all session creation paths. 🎉
2026-03-30 19:19:04 +02:00
Teigen 2011bd8d89 fix: terminal flicker regression — move viewport clear inside dimension guard
Three fixes from the WIP flicker branch that were lost during master merges:

1. Move viewport clear (\x1b[3J\x1b[H\x1b[2J) inside the dimension-change
   guard so it only fires when cols/rows actually change. Previously every
   resize event cleared the screen even at identical dimensions, causing
   visible flicker with no subsequent Ink redraw to repaint.

2. Sync _lastResizeDims in sendResize() so restoreTerminalSize() doesn't
   trigger a redundant viewport clear on the next throttledResize tick.

3. Add didScroll tracking to touch events — tap (no scroll) now refocuses
   xterm's hidden textarea, fixing mobile keyboard input routing after
   tapping the terminal area.
2026-03-30 16:45:20 +08:00
Teigen b76724690d Merge branch 'feat/mobile-shift-tab' into dev 2026-03-30 16:45:16 +08:00
Teigen f277f9664c feat: add Shift+Tab button to mobile keyboard accessory bar
Mobile users cannot press Shift+Tab on virtual keyboards. Add a ⇧Tab
button that sends the escape sequence (\x1b[Z) to the PTY, enabling
mode switching on mobile devices.

Also fix accessory bar overflow on narrow screens by making it
horizontally scrollable with hidden scrollbar.
2026-03-30 16:44:43 +08:00
Teigen cd49171bbc feat: support "Default (CLI default)" option for model selection
Allow users to leave the default model unset, so sessions use whatever
the Claude CLI defaults to rather than forcing a specific model.

- Add empty-value "Default (CLI default)" option to the model dropdown
- Treat empty string as undefined when passing model to Session
- Apply consistently across session creation, quick-start, and Ralph
2026-03-30 16:44:00 +08:00
arkon 0f57342b10 chore: version packages 2026-03-29 05:10:16 +02:00
arkonandClaude Opus 4.6 e1f0ac993a fix: default new sessions to opus[1m] (1M context) instead of opus (200k)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 05:09:24 +02:00
arkon a84ef52992 chore: version packages 2026-03-28 16:49:55 +01:00
arkonandClaude Opus 4.6 692c894760 fix: correct process tree detection and prevent timer starvation
1. Rewrote getActiveChildProcesses() to use a single `ps --ppid` call
   instead of two-level pgrep. The pane PID is typically claude itself
   (bash exec'd into it), not a bash wrapper — so direct children of
   pane_pid ARE the tool processes.

2. Added timer restart in tryStartAiCheck() when skipping due to child
   processes. Without this, the pre-filter and no-output timers (both
   one-shot) would never fire again, permanently stalling idle detection
   for sessions with silent long-running processes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 05:14:39 +01:00
arkonandClaude Opus 4.6 ad0acb6d58 feat: detect active child processes to prevent false idle during running tools
When Claude Code spawns bash tools (test suites, builds, servers), the
respawn controller could falsely detect idle if terminal output paused.
Now checks the process tree for active children of the Claude process
before triggering AI idle checks or confirming idle state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 04:34:53 +01:00
arkonandClaude Opus 4.6 d866c8f30e chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 01:43:59 +01:00
arkonandClaude Opus 4.6 28537de39d refactor: pass 3 — extract helpers, split long functions, deduplicate patterns
Backend:
- subagent-watcher: split 176L processEntry() into 5 focused methods; extract
  _resolveDescription() deduplicating 3 call sites for description acquisition
- bash-tool-parser: split 152L processCleanLine() into 4 handlers; extract
  _createActiveTool() factory and _scheduleAutoRemove() helper
- session: extract _setupOrAttachMuxSession() deduplicating ~80L between
  startInteractive/startShell; extract _handleTerminalOutput()
- respawn-controller: split 180L handleTerminalData() into 3 detection layers;
  data-driven validation loop replacing 9 individual calls
- plan-orchestrator: extract _extractJsonFromResponse(), _emitAgentFailure(),
  _formatResearchSection() helpers
- orchestrator-loop: extract _finalizeTask() unifying task completion/failure;
  _clearTimer() utility for correct clearInterval/clearTimeout dispatch
- ralph-status-parser: config-driven FIELD_PARSERS[] replacing 8 near-identical
  field-matching blocks; split updateCircuitBreaker() into focused handlers
- state-store: extract _mergeWithInitialState() and _resetCircuitBreaker()

Frontend:
- app.js: add _notifySession() helper used by 18 call sites across 5 modules
- panels-ui.js: extract _addActivityEntry() replacing 4 duplicate blocks
- settings-ui.js: extract _updateTunnelUrlRow() deduplicating 2 blocks
- ralph-panel.js, respawn-ui.js: convert to _notifySession()

Routes:
- route-helpers: add toggleService() helper
- system-routes: use toggleService() for watcher toggles; extract collectActiveTokens()
- orchestrator-routes: data-driven EVENT_MAP replacing 10 identical listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 22:50:25 +01:00
arkonandClaude Opus 4.6 ba09184efa fix: wizard "No JSON found" — Claude CLI stream-json returns empty result field
Claude CLI's --output-format stream-json now returns "result": "" in the result
message. The actual response text lives in assistant message text blocks, which
_textOutput correctly accumulates. runPrompt() was returning the empty
resultMsg.result without falling back to _textOutput.value.

Also improved plan-orchestrator JSON extraction to try code-block-wrapped JSON
first (```json {...} ```) before the greedy regex, plus debug logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:53:57 +01:00
132 changed files with 4810 additions and 2530 deletions
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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/
+81
View File
@@ -1,5 +1,86 @@
# aicodeman
## 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
+8 -8
View File
@@ -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.5.3 (must match `package.json`)
**Version**: 0.5.11 (must match `package.json`)
## Project Overview
@@ -80,7 +80,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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
@@ -110,7 +110,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **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` (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.6K 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` | |
| **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.
@@ -136,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`.
@@ -150,7 +150,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**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
@@ -167,7 +167,7 @@ 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
@@ -204,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
@@ -226,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`, `orchestrator-loop-architecture.md`, `browser-testing-guide.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
+43 -20
View File
@@ -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>
+1 -2
View File
@@ -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'],
+147 -22
View File
@@ -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
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.5.3",
"version": "0.5.11",
"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",
-1
View File
@@ -1 +0,0 @@
tools/remotion/public
+1 -1
View File
@@ -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' },
],
};
+14 -14
View File
@@ -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);
+19
View File
@@ -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

+199 -35
View File
@@ -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
+3 -4
View File
@@ -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
+102 -118
View File
@@ -470,120 +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 (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;
}
}
if (oldestKey) {
this._activeTools.delete(oldestKey);
}
// 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) {
@@ -595,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.
*/
+2 -4
View File
@@ -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' };
}
+28
View File
@@ -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.
+31 -23
View File
@@ -499,27 +499,34 @@ export class OrchestratorLoop extends EventEmitter {
}
}
private handleTaskCompleted(queueTaskId: string): void {
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;
orchTask.status = 'completed';
orchTask.completedAt = Date.now();
this.stats.totalTasksCompleted++;
this.persist();
this.emit('taskCompleted', orchTask);
this.checkPhaseCompletion();
}
private handleTaskFailed(queueTaskId: string, error: string): void {
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId);
const orchTask = this._finalizeTask(queueTaskId, 'failed', error);
if (!orchTask) return;
orchTask.status = 'failed';
orchTask.error = error;
this.stats.totalTasksFailed++;
this.persist();
this.emit('taskFailed', orchTask, error);
// Check if we should retry the task or fail the phase
@@ -553,19 +560,20 @@ export class OrchestratorLoop extends EventEmitter {
}, 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 {
if (this.phasePollTimer) {
clearInterval(this.phasePollTimer);
this.phasePollTimer = null;
}
if (this.phaseTimeoutTimer) {
clearTimeout(this.phaseTimeoutTimer);
this.phaseTimeoutTimer = null;
}
if (this.postPhaseTimer) {
clearTimeout(this.postPhaseTimer);
this.postPhaseTimer = null;
}
this._clearTimer('phasePollTimer', clearInterval);
this._clearTimer('phaseTimeoutTimer', clearTimeout);
this._clearTimer('postPhaseTimer', clearTimeout);
}
private pollPhaseStatus(phase: OrchestratorPhase): void {
+9 -3
View File
@@ -20,7 +20,13 @@
*/
import type { Session } from './session.js';
import type { OrchestratorPhase, OrchestratorConfig, VerificationResult, VerificationCheck } from './types.js';
import {
getErrorMessage,
type OrchestratorPhase,
type OrchestratorConfig,
type VerificationResult,
type VerificationCheck,
} from './types.js';
// ═══════════════════════════════════════════════════════════════
// Constants
@@ -111,7 +117,7 @@ export class OrchestratorVerifier {
type: 'test_command',
description: `Run: ${command}`,
passed: false,
output: err instanceof Error ? err.message : String(err),
output: getErrorMessage(err),
});
}
}
@@ -164,7 +170,7 @@ export class OrchestratorVerifier {
type: 'ai_review',
description: `AI review of "${phase.name}"`,
passed: false,
output: `AI review timed out or failed: ${err instanceof Error ? err.message : String(err)}`,
output: `AI review timed out or failed: ${getErrorMessage(err)}`,
};
}
}
+95 -89
View File
@@ -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
+109 -107
View File
@@ -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.
+186 -150
View File
@@ -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 ==========
@@ -860,27 +861,29 @@ export class RespawnController extends EventEmitter {
}
};
// Ensure timeouts are positive
validatePositiveTimeout('idleTimeoutMs');
validatePositiveTimeout('completionConfirmMs');
validatePositiveTimeout('noOutputTimeoutMs');
validatePositiveTimeout('autoAcceptDelayMs', true);
validatePositiveTimeout('interStepDelayMs');
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
validatePositiveTimeout('aiIdleCheckTimeoutMs');
validatePositiveTimeout('aiIdleCheckCooldownMs', true);
validatePositiveTimeout('aiIdleCheckMaxContext');
// Ensure plan check timeouts are positive
validatePositiveTimeout('aiPlanCheckTimeoutMs');
validatePositiveTimeout('aiPlanCheckCooldownMs', true);
validatePositiveTimeout('aiPlanCheckMaxContext');
}
/** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */
@@ -1385,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;
@@ -1497,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}`);
}
}
@@ -2066,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}`);
@@ -2161,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');
@@ -2360,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)}`);
});
@@ -2670,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(
+152 -126
View File
@@ -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,6 +923,71 @@ 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');
@@ -886,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,
@@ -907,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,
@@ -934,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.
@@ -1050,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
@@ -1263,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();
@@ -1366,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 }) => {
@@ -1479,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);
@@ -1516,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';
@@ -1991,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;
+26 -30
View File
@@ -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}`);
}
@@ -297,7 +309,7 @@ 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';
// Step 1: Serialize state (validates it's JSON-safe)
@@ -321,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
@@ -365,7 +373,7 @@ 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';
const json = this.serializeState();
@@ -385,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++;
@@ -415,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;
+170 -145
View File
@@ -988,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
@@ -1245,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;
@@ -1307,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)) {
@@ -1406,43 +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) {
const text = this.extractFirstTextContent(entry.message.content);
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) {
@@ -1453,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 ||
@@ -1463,115 +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
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,
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) {
this.emitToolResult(
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
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,
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,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
}
}
+2 -2
View File
@@ -185,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.
+2 -1
View File
@@ -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;
}
+203 -41
View File
@@ -251,6 +251,23 @@ const _SSE_HANDLER_MAP = [
[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
// ═══════════════════════════════════════════════════════════════
@@ -523,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();
@@ -612,10 +632,8 @@ class CodemanApp {
// shift? (require Shift), action }.
const SHORTCUTS = [
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
@@ -634,6 +652,16 @@ class CodemanApp {
this.closeHelp();
}
// 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;
}
// Match against shortcut table
for (const s of SHORTCUTS) {
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
@@ -866,6 +894,153 @@ 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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.
@@ -939,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) {
@@ -960,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}`);
}
}
@@ -984,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));
}
@@ -1023,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) {
@@ -1622,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
@@ -1730,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>
@@ -1983,6 +2132,18 @@ class CodemanApp {
return this.getShortId(session.id);
}
_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,
@@ -2124,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`
+6
View File
@@ -291,6 +291,12 @@ const SSE_EVENTS = {
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',
};
// ═══════════════════════════════════════════════════════════════
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+61 -3
View File
@@ -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,6 +29,7 @@
<!-- 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) -->
@@ -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>
@@ -331,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()">&times;</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">
@@ -384,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 -->
@@ -408,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>
@@ -449,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>
@@ -649,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">
@@ -882,6 +912,17 @@
</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>
<div class="settings-item" title="Show A-/A+ font size buttons in header">
@@ -1046,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">
@@ -1067,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>
@@ -1341,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 -->
@@ -1368,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>
+86 -11
View File
@@ -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;
+17 -1
View File
@@ -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"
}
]
}
+213 -80
View File
@@ -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,18 +365,22 @@ 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,
and bottom padding for the fixed toolbar (40px) so terminal
content doesn't extend behind it. JS overrides paddingBottom
/* 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. */
.main {
margin-top: 42px;
padding-bottom: calc(40px + var(--safe-area-bottom));
.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
@@ -850,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;
@@ -860,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;
@@ -894,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 */
@@ -950,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 {
@@ -962,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);
@@ -1145,6 +1195,20 @@ html.mobile-init .file-browser-panel {
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);
@@ -1213,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;
@@ -1247,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 ---- */
@@ -1292,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 {
@@ -1331,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;
@@ -1971,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;
}
@@ -1979,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;
@@ -2012,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;
}
/* ============================================================================
+18 -34
View File
@@ -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();
@@ -106,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) {
@@ -135,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();
}
@@ -150,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();
}
@@ -164,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();
}
@@ -190,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();
@@ -224,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
@@ -2339,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()">&#x2B07;</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>
`);
+3 -27
View File
@@ -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
+3 -26
View File
@@ -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) {
+202 -20
View File
@@ -298,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) {
@@ -324,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`);
@@ -331,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);
@@ -426,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) {
@@ -590,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';
@@ -638,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) {
@@ -858,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;
@@ -913,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)
@@ -934,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) {
@@ -945,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() {
@@ -957,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) => {
@@ -975,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;
},
// ═══════════════════════════════════════════════════════════════
@@ -1024,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();
}
},
@@ -1138,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' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
title="Move down" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
title="Delete case">&#x2715;</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
// ═══════════════════════════════════════════════════════════════
@@ -1167,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"/>
@@ -1212,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();
+29 -80
View File
@@ -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}`);
},
@@ -365,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
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');
@@ -378,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;
@@ -517,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 {
@@ -534,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() {
@@ -1182,12 +1126,14 @@ Object.assign(CodemanApp.prototype, {
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,
@@ -1301,9 +1247,12 @@ Object.assign(CodemanApp.prototype, {
// 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/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
@@ -1334,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');
@@ -1373,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,
};
@@ -1687,7 +1636,7 @@ Object.assign(CodemanApp.prototype, {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
'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
+381 -1
View File
@@ -330,6 +330,15 @@ body {
text-overflow: ellipsis;
}
.session-tab .tab-prefix {
color: var(--text-muted);
}
.session-tab .tab-suffix {
color: var(--text);
font-weight: 500;
}
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
.session-tab .tab-folder {
font-size: 0.6rem;
@@ -2224,6 +2233,24 @@ body {
text-align: right;
}
.history-show-more {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-muted);
font-size: 0.75rem;
cursor: pointer;
transition: background var(--transition-smooth), color var(--transition-smooth);
}
.history-show-more:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.welcome-hint {
color: var(--text-muted);
font-size: 0.8rem;
@@ -2964,6 +2991,93 @@ body {
font-size: 0.6rem;
}
/* Case Management List (Manage tab) */
.case-manage-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 320px;
overflow-y: auto;
}
.case-manage-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
transition: background var(--transition-smooth);
}
.case-manage-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.case-manage-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.case-manage-name {
font-size: 0.8rem;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.case-manage-path {
font-size: 0.65rem;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.case-manage-actions {
display: flex;
gap: 4px;
margin-left: 10px;
flex-shrink: 0;
}
.case-manage-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
color: var(--text-dim);
font-size: 0.65rem;
cursor: pointer;
transition: all var(--transition-smooth);
}
.case-manage-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--text);
}
.case-manage-btn:disabled {
opacity: 0.25;
cursor: not-allowed;
}
.case-manage-btn-delete:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.toolbar-input {
padding: 0.4rem 0.5rem;
background: var(--bg-input);
@@ -3692,6 +3806,25 @@ body {
opacity: 1;
}
.mobile-case-item-delete {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
opacity: 0.4;
flex-shrink: 0;
border-radius: 4px;
transition: all var(--transition-smooth);
}
.mobile-case-item-delete:active {
opacity: 1;
color: #ef4444;
background: rgba(239, 68, 68, 0.15);
}
.mobile-case-picker-footer {
padding: 12px 20px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
@@ -5379,7 +5512,12 @@ kbd {
/* Responsive */
@media (max-width: 600px) {
.header-brand {
display: none;
padding-right: 0.4rem;
margin-right: 0.3rem;
}
.header-brand .logo {
font-size: 0.75rem;
}
.connection-text {
@@ -6450,6 +6588,22 @@ kbd {
padding-left: 0.5rem;
}
.file-tree-download {
display: none;
margin-left: auto;
padding: 0 4px;
color: #888;
text-decoration: none;
font-size: 12px;
flex-shrink: 0;
}
.file-tree-item:hover .file-tree-download {
display: inline;
}
.file-tree-download:hover {
color: #4fc3f7;
}
/* File tree indent levels */
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
.file-tree-item[data-depth="1"] { padding-left: 1.25rem; }
@@ -7635,6 +7789,232 @@ kbd {
padding-left: 0.5rem;
}
/* ═══════════════════════════════════════════════════════════════
Response Viewer — native-scroll overlay for reading Claude responses
═══════════════════════════════════════════════════════════════ */
/* Hidden on desktop — only shown on mobile via mobile.css override */
.btn-response-viewer-header {
display: none !important;
}
.response-viewer {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 85vh;
background: #1a1a2e;
border-top: 1px solid #333;
border-radius: 12px 12px 0 0;
z-index: 5000;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.25s ease-out;
}
.response-viewer.visible {
display: flex;
transform: translateY(0);
}
.response-viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #333;
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
.response-viewer-actions {
display: flex;
align-items: center;
gap: 8px;
}
.response-viewer-more {
background: #2a2a4a;
border: 1px solid #444;
color: #aaa;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
}
.response-viewer-more:active {
background: #3a3a5a;
}
.response-viewer-close {
background: none;
border: none;
color: #888;
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
/* Conversation thread messages */
.rv-message {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #2a2a3a;
}
.rv-message:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.rv-role {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.rv-role-user {
color: #5c7cfa;
}
.rv-role-assistant {
color: #51cf66;
}
/* Markdown rendered content inside response viewer */
.rv-text {
word-break: break-word;
line-height: 1.6;
}
.rv-text p {
margin: 0 0 0.6em;
}
.rv-text p:last-child {
margin-bottom: 0;
}
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
color: #e0e0e0;
margin: 1em 0 0.4em;
line-height: 1.3;
}
.rv-text h1 { font-size: 1.3em; }
.rv-text h2 { font-size: 1.15em; }
.rv-text h3 { font-size: 1.05em; }
.rv-text code {
background: #2a2a3e;
padding: 1px 5px;
border-radius: 3px;
font-size: 0.9em;
}
.rv-text pre {
background: #1e1e2e;
border: 1px solid #333;
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
margin: 0.6em 0;
}
.rv-text pre code {
background: none;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
}
.rv-text ul, .rv-text ol {
margin: 0.4em 0;
padding-left: 1.4em;
}
.rv-text li {
margin-bottom: 0.2em;
}
.rv-text blockquote {
border-left: 3px solid #444;
margin: 0.6em 0;
padding: 0.3em 0 0.3em 12px;
color: #999;
}
.rv-text strong {
color: #f0f0f0;
}
.rv-text a {
color: #5c7cfa;
text-decoration: none;
}
.rv-text table {
border-collapse: collapse;
margin: 0.6em 0;
width: 100%;
font-size: 0.9em;
}
.rv-text th, .rv-text td {
border: 1px solid #333;
padding: 4px 8px;
text-align: left;
}
.rv-text th {
background: #2a2a3e;
color: #e0e0e0;
}
.rv-text hr {
border: none;
border-top: 1px solid #333;
margin: 1em 0;
}
.response-viewer-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
}
.response-viewer-body:empty::after {
content: 'No response yet';
color: #555;
font-style: italic;
}
.response-viewer-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 4999;
}
.response-viewer-backdrop.visible {
display: block;
}
/* ═══════════════════════════════════════════════════════════════
CJK IME Input
═══════════════════════════════════════════════════════════════ */
+91 -16
View File
@@ -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);
})
);
+172 -68
View File
@@ -77,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
@@ -179,6 +242,7 @@ Object.assign(CodemanApp.prototype, {
// 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) => {
@@ -187,6 +251,7 @@ Object.assign(CodemanApp.prototype, {
velocity = 0;
pixelAccum = 0;
isTouching = true;
didScroll = false;
lastTime = 0;
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
@@ -201,6 +266,7 @@ Object.assign(CodemanApp.prototype, {
'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;
@@ -225,6 +291,12 @@ Object.assign(CodemanApp.prototype, {
if (!scrollFrame && Math.abs(velocity) > 0.3) {
scrollFrame = requestAnimationFrame(scrollLoop);
}
// 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 }
);
@@ -284,22 +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.
@@ -311,6 +367,24 @@ Object.assign(CodemanApp.prototype, {
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) {
// 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',
@@ -723,84 +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 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);
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 = '';
@@ -1348,6 +1448,10 @@ Object.assign(CodemanApp.prototype, {
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 {
File diff suppressed because one or more lines are too long
+360
View File
@@ -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);
}
+20
View File
@@ -185,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.
*
+79 -4
View File
@@ -11,14 +11,15 @@ 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, parseBody, readJsonConfig } 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>> {
@@ -46,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),
@@ -62,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,
@@ -71,6 +72,18 @@ 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;
});
@@ -150,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 };
+13 -1
View File
@@ -239,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) {
@@ -292,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) {
+24 -30
View File
@@ -39,43 +39,37 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
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;
loop.on('stateChanged', (state, prevState) => {
ctx.broadcast(SseEvent.OrchestratorStateChanged, { state, prevState });
});
loop.on('planProgress', (phase, detail) => {
ctx.broadcast(SseEvent.OrchestratorPlanProgress, { phase, detail });
});
loop.on('planReady', (plan) => {
ctx.broadcast(SseEvent.OrchestratorPlanReady, { plan });
});
loop.on('phaseStarted', (phase) => {
ctx.broadcast(SseEvent.OrchestratorPhaseStarted, { phase });
});
loop.on('phaseCompleted', (phase) => {
ctx.broadcast(SseEvent.OrchestratorPhaseCompleted, { phase });
});
loop.on('phaseFailed', (phase, reason) => {
ctx.broadcast(SseEvent.OrchestratorPhaseFailed, { phase, reason });
});
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('taskAssigned', (task, sessionId) => {
ctx.broadcast(SseEvent.OrchestratorTaskAssigned, { task, sessionId });
});
loop.on('taskCompleted', (task) => {
ctx.broadcast(SseEvent.OrchestratorTaskCompleted, { task });
});
loop.on('taskFailed', (task, error) => {
ctx.broadcast(SseEvent.OrchestratorTaskFailed, { task, error });
});
loop.on('completed', (stats) => {
ctx.broadcast(SseEvent.OrchestratorCompleted, { stats });
});
loop.on('error', (error) => {
ctx.broadcast(SseEvent.OrchestratorError, { error: error.message });
});
+1 -1
View File
@@ -308,7 +308,7 @@ export function registerRalphRoutes(
useMux: true,
mode: 'claude',
niceConfig,
model: rlModelConfig?.defaultModel,
model: rlModelConfig?.defaultModel || undefined,
claudeMode: rlClaudeModeConfig.claudeMode,
allowedTools: rlClaudeModeConfig.allowedTools,
});
+290 -24
View File
@@ -7,6 +7,7 @@
import { FastifyInstance } from 'fastify';
import { join, dirname } from 'node:path';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import {
ApiErrorCode,
@@ -42,7 +43,7 @@ import {
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';
@@ -166,6 +167,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');
@@ -177,11 +183,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,
@@ -194,7 +234,7 @@ export function registerSessionRoutes(
claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: body.resumeSessionId,
resumeSessionId: validatedResumeId,
});
ctx.addSession(session);
@@ -485,6 +525,46 @@ 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> => {
@@ -496,6 +576,117 @@ export function registerSessionRoutes(
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:
@@ -761,7 +952,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,
@@ -911,6 +1106,74 @@ export function registerSessionRoutes(
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 {
@@ -961,15 +1224,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) {
@@ -991,24 +1250,31 @@ export function registerSessionRoutes(
// 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"');
if (fileStat.size < 50000) {
if (
!head ||
(!head.includes('"type":"user"') &&
!head.includes('"type":"assistant"') &&
!head.includes('"type":"summary"'))
) {
continue; // No conversation content — skip
}
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) {
const tailBuf = Buffer.alloc(32768);
const tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (!tail) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
+20 -28
View File
@@ -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, parseBody, readJsonConfig, 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),
@@ -414,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) {
+7
View File
@@ -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(),
+141 -1092
View File
File diff suppressed because it is too large Load Diff
+392
View File
@@ -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);
}
+6
View File
@@ -325,6 +325,10 @@ export const OrchestratorError = 'orchestrator:error' as const;
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 ─────────────────────────────────────────────────────
@@ -485,4 +489,6 @@ export const SseEvent = {
// Cases
CaseCreated,
CaseLinked,
CaseDeleted,
CaseOrderChanged,
} as const;
+488
View File
@@ -0,0 +1,488 @@
/**
* @fileoverview SSE stream manager — owns all SSE client state, broadcasting, and event batching.
*
* Extracted from server.ts for modularity. Handles:
* - SSE client connection tracking with subscription filtering
* - Backpressure-aware message delivery
* - Terminal data batching with adaptive intervals (16-50ms for 60fps)
* - Task update and session state batching
* - Dead client cleanup and keepalive
* - Cloudflare tunnel padding for proxy buffer flushing
*
* @dependencies CleanupManager (managed timers), config/server-timing (constants)
* @consumedby web/server.ts (WebServer delegates all SSE operations here)
*
* @module web/sse-stream-manager
*/
import type { FastifyReply } from 'fastify';
import type { BackgroundTask } from '../session.js';
import { CleanupManager, StaleExpirationMap } from '../utils/index.js';
import { SseEvent } from './sse-events.js';
import {
TERMINAL_BATCH_INTERVAL,
TASK_UPDATE_BATCH_INTERVAL,
STATE_UPDATE_DEBOUNCE_INTERVAL,
BATCH_FLUSH_THRESHOLD,
SSE_PADDING_SIZE,
INACTIVITY_TIMEOUT_MS,
} from '../config/server-timing.js';
// SSE padding for Cloudflare tunnel buffer flushing.
// Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
// Pre-computed once at startup to avoid repeated string allocation.
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
/** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */
export interface SseStreamManagerDeps {
/** Get session state with respawn info for session:updated broadcasts */
getSessionStateWithRespawn(sessionId: string): unknown;
}
export class SseStreamManager {
// ─── SSE Client Tracking ────────────────────────────────
/**
* SSE clients mapped to their session subscription filter.
* Value is a Set of session IDs the client wants events for,
* or `null` meaning "receive all events" (backwards-compatible default).
*/
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
private remoteSseClients: Set<FastifyReply> = new Set();
/** Clients with backpressure — skip writes until 'drain' fires */
private backpressuredClients: Set<FastifyReply> = new Set();
// ─── Tunnel State ───────────────────────────────────────
/** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
private _isTunnelActive: boolean = false;
// ─── Terminal Batching ──────────────────────────────────
private terminalBatches: Map<string, string[]> = new Map();
private terminalBatchSizes: Map<string, number> = new Map(); // Running total avoids O(n) reduce per push
private terminalBatchTimers: Map<string, NodeJS.Timeout> = new Map(); // Per-session timers (staggered flushes)
// Adaptive batching: track rapid events to extend batch window (per-session)
// StaleExpirationMap auto-cleans entries for sessions that stop generating output
private lastTerminalEventTime: StaleExpirationMap<string, number>;
// ─── Event Batching ─────────────────────────────────────
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = new Map();
private taskUpdateBatchTimerId: string | null = null;
// State update batching (reduce expensive toDetailedState() serialization)
private stateUpdatePending: Set<string> = new Set();
private stateUpdateTimerId: string | null = null;
// ─── Lifecycle ──────────────────────────────────────────
private _isStopping: boolean = false;
constructor(
private deps: SseStreamManagerDeps,
private cleanup: CleanupManager
) {
this.lastTerminalEventTime = new StaleExpirationMap({
ttlMs: INACTIVITY_TIMEOUT_MS, // 5 minutes - auto-expire stale session timing data
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
});
}
// ========== SSE Connection Management ==========
get clientCount(): number {
return this.sseClients.size;
}
get remoteClientCount(): number {
return this.remoteSseClients.size;
}
get isTunnelActive(): boolean {
return this._isTunnelActive;
}
setTunnelActive(active: boolean): void {
this._isTunnelActive = active;
}
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean): void {
this.sseClients.set(reply, sessionFilter);
if (isRemote) {
this.remoteSseClients.add(reply);
}
}
removeClient(reply: FastifyReply): void {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
}
/** Send a single SSE event to a specific client. */
sendSSE(reply: FastifyReply, event: string, data: unknown): void {
try {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
}
}
/** Send pre-formatted tunnel padding to a specific client. */
sendPadding(reply: FastifyReply): void {
if (!this._isTunnelActive) return;
try {
reply.raw.write(SSE_PADDING);
} catch {
/* client gone */
}
}
// Optimized: send pre-formatted SSE message to a client
// Returns false if client is backpressured or dead
private sendSSEPreformatted(reply: FastifyReply, message: string): void {
// Skip backpressured clients to prevent unbounded memory growth.
// Terminal data dropped here is recovered via session:needsRefresh on drain.
if (this.backpressuredClients.has(reply)) return;
try {
const ok = reply.raw.write(message);
if (!ok) {
// Buffer is full — mark as backpressured, resume on drain
this.backpressuredClients.add(reply);
reply.raw.once('drain', () => {
this.backpressuredClients.delete(reply);
// Client may have missed terminal data during backpressure.
// Tell it to reload the active session's buffer to recover.
try {
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
} catch {
/* client gone */
}
});
}
} catch {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
}
}
// ========== Broadcasting ==========
broadcast(event: string, data: unknown): void {
// Skip serialization entirely when no clients are listening
if (this.sseClients.size === 0) return;
// Performance optimization: serialize JSON once for all clients.
// Only append Cloudflare tunnel padding for latency-sensitive events —
// Recovery events need immediate proxy flush; low-frequency metadata events
// (session:created, ralph:*, respawn:*, etc.) don't need padding.
// Note: session:terminal has its own padding in flushSessionTerminalBatch().
const needsPadding = this._isTunnelActive && event === SseEvent.SessionNeedsRefresh;
const padding = needsPadding ? SSE_PADDING : '';
let message: string;
try {
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding;
} catch (err) {
// Handle circular references or non-serializable values
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
return;
}
// Extract sessionId from event data for subscription filtering.
const eventSessionId = this.extractSessionId(event, data);
for (const [client, filter] of this.sseClients) {
// No filter (null) = receive everything. Otherwise, skip if event is
// session-scoped and the session isn't in the client's subscription set.
if (filter && eventSessionId && !filter.has(eventSessionId)) continue;
this.sendSSEPreformatted(client, message);
}
}
/**
* Extract the session ID from an event's data payload for subscription filtering.
* Returns the sessionId string if the event is session-scoped, or null for global events.
*/
private extractSessionId(event: string, data: unknown): string | null {
if (data == null || typeof data !== 'object') return null;
const record = data as Record<string, unknown>;
// Most session-scoped events use `sessionId`
if (typeof record.sessionId === 'string') return record.sessionId;
// Session lifecycle events (session:*) use `id` from the session state object
if (typeof record.id === 'string' && event.startsWith('session:')) return record.id;
// No session ID found — treat as global event (sent to all clients)
return null;
}
// ========== Terminal Data Batching ==========
// Batch terminal data for better performance (60fps)
// Uses per-session timers with adaptive intervals to prevent thundering herd:
// each session flushes independently rather than all sessions flushing in one burst.
batchTerminalData(sessionId: string, data: string): void {
// Skip if server is stopping
if (this._isStopping) return;
let chunks = this.terminalBatches.get(sessionId);
if (!chunks) {
chunks = [];
this.terminalBatches.set(sessionId, chunks);
}
chunks.push(data);
const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0;
const totalLength = prevSize + data.length;
this.terminalBatchSizes.set(sessionId, totalLength);
// Adaptive batching: detect rapid events and extend batch window (per-session)
const now = Date.now();
const lastEvent = this.lastTerminalEventTime.get(sessionId) ?? 0;
const eventGap = now - lastEvent;
this.lastTerminalEventTime.set(sessionId, now);
// Adjust batch interval based on event frequency (per-session)
// Rapid events (<10ms gap) = 50ms batch, moderate (<20ms) = 32ms, else 16ms
let sessionInterval: number;
if (eventGap > 0 && eventGap < 10) {
sessionInterval = 50;
} else if (eventGap > 0 && eventGap < 20) {
sessionInterval = 32;
} else {
sessionInterval = TERMINAL_BATCH_INTERVAL;
}
// Flush immediately if batch is large for responsiveness
if (totalLength > BATCH_FLUSH_THRESHOLD) {
const existingTimer = this.terminalBatchTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
this.terminalBatchTimers.delete(sessionId);
}
this.flushSessionTerminalBatch(sessionId);
return;
}
// Start per-session batch timer if not already running
// Each session flushes independently — prevents one busy session from
// forcing all sessions to flush at its rate (thundering herd)
if (!this.terminalBatchTimers.has(sessionId)) {
this.terminalBatchTimers.set(
sessionId,
setTimeout(() => {
this.terminalBatchTimers.delete(sessionId);
this.flushSessionTerminalBatch(sessionId);
}, sessionInterval)
);
}
}
/** Flush a single session's batched terminal data */
private flushSessionTerminalBatch(sessionId: string): void {
if (this._isStopping) {
this.terminalBatches.delete(sessionId);
this.terminalBatchSizes.delete(sessionId);
return;
}
const chunks = this.terminalBatches.get(sessionId);
if (chunks && chunks.length > 0) {
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
const data = chunks.join('');
// Wrap batched output in DEC 2026 synchronized output markers so xterm.js
// renders the entire batch atomically. Ink spinner frames (cursor-up + redraw)
// do NOT emit their own 2026 markers, so without this wrapper each partial
// cursor update renders individually, causing visible flicker.
// xterm.js 6.0+ handles DEC 2026 natively: it buffers everything between
// 2026h/2026l and renders in one pass.
const syncData = '\x1b[?2026h' + data + '\x1b[?2026l';
// Fast path: build SSE message directly without JSON.stringify on wrapper object.
// Only the terminal data string needs escaping; sessionId is a UUID (safe to template).
const escapedData = JSON.stringify(syncData);
// Append tunnel padding for immediate Cloudflare proxy flush —
// terminal data is high-frequency and latency-sensitive.
const padding = this._isTunnelActive ? SSE_PADDING : '';
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding;
for (const [client, filter] of this.sseClients) {
// Skip clients that have a session filter and aren't subscribed to this session
if (filter && !filter.has(sessionId)) continue;
this.sendSSEPreformatted(client, message);
}
}
this.terminalBatches.delete(sessionId);
this.terminalBatchSizes.delete(sessionId);
}
// ========== Task Update Batching ==========
// Batch task:updated events at 100ms - only send latest update per task
// Key is sessionId:taskId to avoid collisions when multiple tasks update concurrently
batchTaskUpdate(sessionId: string, task: BackgroundTask): void {
// Skip if server is stopping
if (this._isStopping) return;
// Use composite key to avoid losing updates when multiple tasks update in same batch window
const key = `${sessionId}:${task.id}`;
this.taskUpdateBatches.set(key, { sessionId, task });
if (!this.taskUpdateBatchTimerId) {
this.taskUpdateBatchTimerId = this.cleanup.setTimeout(
() => {
this.taskUpdateBatchTimerId = null;
this.flushTaskUpdateBatches();
},
TASK_UPDATE_BATCH_INTERVAL,
{ description: 'task update batch flush' }
);
}
}
private flushTaskUpdateBatches(): void {
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.taskUpdateBatches.clear();
return;
}
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
}
this.taskUpdateBatches.clear();
}
// ========== Session State Batching ==========
/**
* Debounce expensive session:updated broadcasts.
* Instead of calling toDetailedState() on every event, batch requests
* and only serialize once per STATE_UPDATE_DEBOUNCE_INTERVAL.
*/
broadcastSessionStateDebounced(sessionId: string): void {
// Skip if server is stopping
if (this._isStopping) return;
this.stateUpdatePending.add(sessionId);
if (!this.stateUpdateTimerId) {
this.stateUpdateTimerId = this.cleanup.setTimeout(
() => {
this.stateUpdateTimerId = null;
this.flushStateUpdates();
},
STATE_UPDATE_DEBOUNCE_INTERVAL,
{ description: 'state update debounce flush' }
);
}
}
private flushStateUpdates(): void {
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.stateUpdatePending.clear();
return;
}
for (const sessionId of this.stateUpdatePending) {
// Single expensive serialization per batch interval
const state = this.deps.getSessionStateWithRespawn(sessionId);
if (state) {
this.broadcast(SseEvent.SessionUpdated, state);
}
}
this.stateUpdatePending.clear();
}
// ========== Client Health ==========
/**
* Clean up dead SSE clients and send keep-alive comments.
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
*/
cleanupDeadClients(): void {
const deadClients: FastifyReply[] = [];
for (const [client] of this.sseClients) {
try {
// Check if the underlying socket is still writable
const socket = client.raw.socket;
if (!socket || socket.destroyed || !socket.writable) {
deadClients.push(client);
} else {
// Send SSE comment as keep-alive. Only add padding when tunnel is
// active — it flushes Cloudflare proxy buffers but wastes bandwidth
// for direct/Tailscale connections.
const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
client.raw.write(ka);
}
} catch {
// Error accessing socket means client is dead
deadClients.push(client);
}
}
// Remove dead clients
for (const client of deadClients) {
this.sseClients.delete(client);
this.remoteSseClients.delete(client);
this.backpressuredClients.delete(client);
}
if (deadClients.length > 0) {
console.log(`[Server] Cleaned up ${deadClients.length} dead SSE client(s)`);
}
}
// ========== Session Cleanup ==========
/** Clean up all batching state for a session (call on session exit or deletion). */
cleanupSessionBatches(sessionId: string): void {
this.terminalBatches.delete(sessionId);
this.terminalBatchSizes.delete(sessionId);
const batchTimer = this.terminalBatchTimers.get(sessionId);
if (batchTimer) {
clearTimeout(batchTimer);
this.terminalBatchTimers.delete(sessionId);
}
this.taskUpdateBatches.delete(sessionId);
this.stateUpdatePending.delete(sessionId);
this.lastTerminalEventTime.delete(sessionId);
}
// ========== Lifecycle ==========
setStopping(): void {
this._isStopping = true;
}
/** Graceful shutdown: notify clients, close connections, clear all state. */
stop(): void {
this._isStopping = true;
// Gracefully close all SSE connections before clearing
for (const [client] of this.sseClients) {
try {
// Send a final event to notify clients of shutdown
this.sendSSE(client, 'server:shutdown', { reason: 'Server stopping' });
client.raw.end();
} catch {
// Client may already be disconnected
}
}
this.sseClients.clear();
this.remoteSseClients.clear();
this.backpressuredClients.clear();
// Clear per-session batch timers
for (const timer of this.terminalBatchTimers.values()) {
clearTimeout(timer);
}
this.terminalBatchTimers.clear();
this.terminalBatches.clear();
this.terminalBatchSizes.clear();
this.taskUpdateBatches.clear();
this.stateUpdatePending.clear();
// Dispose StaleExpirationMap (stops internal cleanup timer)
this.lastTerminalEventTime.dispose();
}
}
-4
View File
@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}
@@ -18,20 +18,20 @@ Validates Codeman's mobile UI across 135 devices, covering:
```bash
# Run all mobile tests
npx vitest run --config mobile-test/vitest.config.ts
npx vitest run --config test/mobile/vitest.config.ts
# Run a single test file
npx vitest run --config mobile-test/vitest.config.ts mobile-test/keyboard.test.ts
npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.ts
# Quick mode — 6 representative devices, skip full matrix
CI_QUICK=1 npx vitest run --config mobile-test/vitest.config.ts
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
# Full device matrix only (135 devices)
npx vitest run --config mobile-test/vitest.config.ts mobile-test/device-matrix.test.ts
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
# Update visual baselines (delete old baselines, re-run)
rm -rf mobile-test/snapshots/*.png
npx vitest run --config mobile-test/vitest.config.ts mobile-test/visual-regression.test.ts
rm -rf test/mobile/snapshots/*.png
npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regression.test.ts
```
## Test Files
@@ -126,7 +126,7 @@ Swipe simulation includes intermediate move points and timing to satisfy `SwipeH
Uses `pixelmatch` + `pngjs` (both in devDeps) for pixel-level comparison:
- **Baselines** stored in `mobile-test/snapshots/` (git-tracked)
- **Baselines** stored in `test/mobile/snapshots/` (git-tracked)
- **Tolerance**: 0.5% pixel diff (handles anti-aliasing)
- **On failure**: `.actual.png` and `.diff.png` generated (gitignored)
- **Update**: Delete baseline PNGs and re-run — new baselines auto-created
@@ -96,7 +96,7 @@ export const SUBAGENT = {
export const VISUAL = {
DEFAULT_THRESHOLD: 0.1,
MAX_DIFF_PERCENT: 0.5,
SNAPSHOT_DIR: 'mobile-test/snapshots',
SNAPSHOT_DIR: 'test/mobile/snapshots',
} as const;
// Visual regression breakpoints (width values for screenshot comparison)

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Some files were not shown because too many files have changed in this diff Show More