Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.7 e8a809ea80 chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 03:36:56 +02:00
Ark0NandClaude Opus 4.7 8006cc5db3 fix: allowlist opusContext1mEnabled in SettingsUpdateSchema (#78)
Same root cause as the thinkingEffort fix in #73: the schema is
.strict(), so unknown keys in PUT /api/settings are rejected with
INVALID_INPUT and the toggle never persists. Verified live:
pre-fix returned {"errorCode":"INVALID_INPUT"}, post-fix accepts.

The frontend has been reading and writing this key for a while
(settings-ui.js:336, :1137; session-ui.js:331), so saves were
silently failing — users never noticed because the load path
falls back to false on missing keys.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 03:29:48 +02:00
arkonandClaude Opus 4.7 0ded279b55 chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 03:25:53 +02:00
Tenggan ZhangandTeigen aa5724c390 feat: improve Resume Conversation UX on mobile (#77)
Default layout was a single nowrap row with path + date + size, which on
narrow screens truncated both the first prompt and the directory suffix
(where project names actually live). The /Users/ home shorthand was also
never applied on macOS.

Changes:
- Title now uses 2-line clamp so more of the first prompt is visible.
- Subtitle resolves workingDir against known cases: exact match shows
  "#caseName", subpath shows "#caseName/sub", otherwise falls back to
  basename. Case labels are styled distinctly.
- Normalize both /home/<user>/ and /Users/<user>/ prefixes to "~/".
- Each history item gets a "..." toggle that expands an in-place detail
  panel with the full prompt, full path, timestamp, size, and short
  session id. Collapses back on second click; clicking the card body
  still triggers resume.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-04-28 03:14:56 +02:00
Tenggan ZhangandTeigen f21df2a9fb fix: eye icon follows /clear to the new Claude conversation (#76)
Interactive Claude CLI never emits session_id on stdout, so the
Session's _claudeSessionId stayed pinned to the pre-/clear jsonl and
the last-response viewer kept showing the old conversation.

Two complementary update paths:

- Session.adoptClaudeSessionId() — public setter mirroring the existing
  no-op-if-same guard. Called from POST /api/hook-event when Claude Code
  hooks carry data.session_id (works once hooks are configured).

- /api/sessions/:id/last-response now resolves the active id from
  ~/.claude/history.jsonl before reading the transcript. This is the
  only source-of-truth that does not require hooks, and we intentionally
  don't write hooks into arbitrary user repos.

History scan filters out sessionIds held by other Codeman sessions in
the same cwd, and validates via jsonl mtime to avoid inheriting a dead
prior session's id.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-04-28 03:03:11 +02:00
e549e15cb8 feat(response-viewer): ASCII diagram wrap toggle, mobile code blocks, chrome-stripping fallback (#75)
* fix: restore clear message separation + proper table layout in response viewer

* fix: capture Claude CLI's real session ID + robust ANSI/CLI-chrome stripping in response viewer fallback

Session constructor seeded _claudeSessionId with Codeman's session.id as a
placeholder, and the message-driven update was gated on !_claudeSessionId —
meaning Claude CLI's actual session UUID was never adopted. This broke
/api/sessions/:id/last-response JSONL lookups, silently falling through to
the terminal-buffer path whose ANSI regex missed \x1b[>c / \x1b[>q queries.

- session.ts: update _claudeSessionId whenever a message's session_id differs
  from current (covers placeholder and stale-resume cases)
- app.js: extract _cleanTerminalBuffer with proper CSI regex (param bytes
  0x30-0x3F now covers > ? < =) plus a chrome filter for status bar,
  progress bar, spinner, shell prompt, and hint lines

* fix: wrap regular code blocks on mobile, keep ASCII diagrams rigid with scroll hint

* feat: add per-block wrap toggle on ASCII-diagram code blocks

* fix: wrap by default, pin toggle button outside scroll container

* fix: narrow diagram detection to box-drawing + block elements only

* feat: show last-response viewer eye icon on desktop too

The response viewer button was mobile-only via a display:none default with a
mobile.css override. Flip the default to inline-flex and drop the override so
the eye icon appears in the header on every form factor — desktop users get
the same quick "Last Response" pane as mobile.

* fix(response-viewer): restore HTML sanitizer + fix undefined `src` in _renderMarkdown

- `_renderMarkdown` referenced an undefined `src` (should be `text`),
  causing a ReferenceError on every markdown render. The try/catch
  swallowed it, so the new table-wrap and ASCII-diagram features
  never actually ran — output silently fell through to plain-text.
  app.js is excluded from ESLint, so this wasn't caught at lint time.
- `_sanitizeHtml` was removed when refactoring the response viewer,
  leaving `marked.parse()` output going straight into `innerHTML`
  without sanitization (XSS regression vs. master). Restored the
  helper and re-applied it before any post-processing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:58:42 +02:00
Ark0N d07b59db4e Merge pull request #74 from TeigenZhang/refactor/envoverrides-tmux-export
refactor: pass envOverrides via tmux export instead of disk write
2026-04-28 02:45:11 +02:00
arkonandClaude Opus 4.7 a5a7e0c94c Merge master into refactor/envoverrides-tmux-export
Resolved conflict in src/web/public/session-ui.js by keeping this
PR's buildEnvOverrides() helper — it already covers both
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS (this PR) and
CLAUDE_CODE_EFFORT_LEVEL (added in #73), so the master-side inline
block is fully replaced.

Also fixed test/session-manager.test.ts MockSession to add a
getEnvOverridesForPersist() stub — without it,
SessionManager.updateSessionState's new call breaks 19 tests with
"TypeError: session.getEnvOverridesForPersist is not a function".

Verified: typecheck, lint, format:check, build, and
test/{session-manager,session-state,tmux-manager,tmux-restart-recovery}.test.ts
all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:43:31 +02:00
Ark0N 79d7117e6d Merge pull request #73 from TeigenZhang/feat/thinking-effort
feat: thinking effort setting for new sessions (with xhigh/max)
2026-04-28 02:21:57 +02:00
arkonandClaude Opus 4.7 3cf486730b fix: allowlist thinkingEffort in SettingsUpdateSchema
Without this, PUT /api/settings rejects the new field with
INVALID_INPUT (schema is .strict()), so the dropdown's value
never persists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:20:11 +02:00
Tenggan Zhang 996b096849 fix: prevent vertical scroll on mobile keyboard accessory bar (#72)
Thank you @TeigenZhang for the clean mobile fix!
2026-04-28 02:15:00 +02:00
ffa7fcf839 fix(tmux-manager): use '|' separator in reconcileSessions (#71)
* fix(tmux-manager): use '|' separator in reconcileSessions

Under non-tty execution contexts (launchd on macOS, systemd without TTY),
tmux emits '\t' in FORMAT strings as the literal two characters `\` + `t`
rather than as a tab. The parser's `line.indexOf('\t')` (a real tab char)
therefore never matches, `activeSessions` stays empty, `reconcileSessions`
returns `alive: []` / `discovered: []`, and `cleanupStaleSessions()` wipes
every entry in `state.json` — even though the underlying tmux sessions are
still alive. On the next startup the user sees an empty session list.

The bug reproduces reliably when codeman is launched via a user LaunchAgent
or a systemd unit without `TTYPath`. Interactive `npm run dev` hides it
because tmux's format parser does interpret `\t` when stdout is a TTY.

Fix: use `|` as the separator. tmux passes it through verbatim in every
environment, and `|` is not a valid tmux session-name character so it
cannot collide with the codeman-<uuid> / claudeman-<uuid> naming scheme.

* test(tmux-manager): cover parsePaneList separator contract

Extract the inline pane-list parser from `reconcileSessions` into an
exported `parsePaneList()` helper plus `PANE_LIST_SEP` / `PANE_LIST_FORMAT`
constants, so the '|' separator contract can be unit-tested directly.

The new tests lock in:
- Well-formed parsing into name -> pid Map
- Empty / blank-line / missing-separator handling
- Non-numeric pid and empty-name rejection
- A literal `\t` (backslash + t) in the input is NOT treated as a
  delimiter — guards against the launchd/systemd regression that
  motivated PR #71.
- Splitting on the first separator only.

No behavior change in `reconcileSessions`; the body now delegates to the
helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:11:11 +02:00
Teigen a1c69f7405 refactor: pass envOverrides via tmux export instead of disk write
CLAUDE_CODE_EFFORT_LEVEL (and any CLAUDE_CODE_* / OPENCODE_* key) now flows:
  UI dropdown → POST /api/sessions { envOverrides }
             → new Session({ envOverrides })
             → this._envOverrides
             → tmux-manager.buildEnvExports appends `export KEY=<shellescape(VALUE)>`

Previously the API wrote envOverrides to <case>/.claude/settings.local.json, which
created stale state (UI dropdown disagreeing with disk) and polluted user project
directories. Now envOverrides are ephemeral spawn-time state, preserved across
respawnPane cycles via this._envOverrides and across server restart via
SessionState.envOverrides in state.json.

Also removes the now-unused updateCaseEnvVars import from session-routes.ts.
2026-04-24 09:49:52 +08:00
Teigen 534899bc2b feat: add xhigh effort option and /effort max mobile shortcut
Add XHigh option to Thinking Effort dropdown (between High and Max),
and add a Max quick button to the mobile keyboard accessory bar that
sends /effort max as a slash command.
2026-04-24 09:48:53 +08:00
Teigen 03d91ffddd feat: add thinking effort setting for new sessions
Allow configuring CLAUDE_CODE_EFFORT_LEVEL (low/medium/high/max) from
Settings → Claude Permissions. Applied as envOverride on session creation.
2026-04-24 09:48:18 +08:00
arkonandClaude Opus 4.7 6280998bd8 chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:22:30 +02:00
arkonandClaude Opus 4.7 02e2f3e8b5 refactor: remove dead code and narrow internal exports (knip sweep)
Knip-driven cleanup. All changes verified with tsc --noEmit, lint, and
build.

Removed (zero consumers):
- VERIFICATION_PROMPT constant + its barrel re-export
- createInitialOrchestratorPersistState factory
- transcriptWatcher singleton export
- createAnsiPatternFull / createAnsiPatternSimple factories
- TimerInfo interface + unused AiCheckResult/AiPlanCheckResult imports
  in respawn-controller.ts
- 35 unused Zod z.infer \`*Input\` types in schemas.ts
- Dead re-exports: SessionMode from session.ts, AuthSessionRecord from
  web/ports/index.ts, EnhancedPlanTask/CheckpointReview from
  ralph-tracker.ts, 7 unused entries in utils/index.ts
- 14 event/config interfaces that lived only as JSDoc hints (no TS type
  position usage): Session/Respawn/RalphLoop/RalphTracker/
  SessionManager/SessionAutoOps/Subagent/TaskQueue/TaskTracker/
  TranscriptWatcher/Image/OrchestratorLoop Events + RespawnPreset +
  SessionOutput

Narrowed to module scope (kept but no longer exported):
- buildPermissionArgs in session-cli-builder.ts
- 28 type/interface declarations used only within their own file:
  Ai{Idle,Plan}Check{Config,State}, BashToolParser{Events,Config},
  FileStream/CreateStream{Options,Result}, PlanSubagentEvent,
  SubagentCallback, RalphLoopConfig, RalphLoop{Events,Options},
  ActiveTimerInfo, DetectionStatus, ActionLogEntry, AutoOpsCallbacks,
  TunnelStatus, Timer/LRUMap/StaleExpirationMap Options, AuthState,
  SessionListenerDeps, SseStreamManagerDeps, and 8 more

Docs: CLAUDE.md advice for global-regex `lastIndex` now points to the
remaining `execPattern()` helper instead of the deleted factories.

Knip delta: unused files 42→0, unused exports 161→16, unused types 92→0.
The 16 remaining exports are a mobile-test helper toolkit intentionally
kept for upcoming tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:57:00 +02:00
arkonandClaude Opus 4.7 41300f0a34 chore: add knip config for dead-code detection
knip.json declares the real entry points (scripts, tests, Remotion roots)
and the devDeps invoked only as external CLIs (esbuild for build,
agent-browser/remotion via npx) so future scans surface only true
findings.

Add \`npm run knip\` as the canonical invocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:56:31 +02:00
arkonandClaude Opus 4.7 adbc083426 chore: remove dead test and script files
Dead-code sweep via knip. All files below have zero importers and were
leftovers from the local-echo overlay exploration or duplicated by files
under test/mocks/.

Deleted:
- test/respawn-test-utils.ts (728-line duplicate of test/mocks/*)
- test/input-echo-test.mjs
- test/local-echo-*.mjs (7 files)
- test/manual/*.mjs (10 files; dir removed)
- scripts/remotion/components/TerminalScreen.tsx (unused Remotion demo)

Also cleaned stale JSDoc references to the removed
respawn-test-utils.ts in test/mocks/mock-session.ts and
test/mocks/test-helpers.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:56:22 +02:00
arkonandClaude Opus 4.7 3754bcd1aa docs: tighten CLAUDE.md — update counts and CI line
- CI line: include `check:lockfile` step now run in workflow
- Frontend: app.js is ~2.9K lines (was ~2.8K)
- Types: document `src/types/index.ts` as the barrel (14 domain files) plus `src/types.ts` root re-export
- API routes: updated handler counts (128 total; sessions 27, cases 9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:55:56 +02:00
arkonandClaude Opus 4.7 f2f909ca9c docs: tighten CLAUDE.md — fix counts, remove footer redundancy
Fix stale counts (types 14 to 15, SSE events ~118 to ~120). Remove
redundant footer sections (References list duplicated inline citations;
Common Workflows bullets were self-evident or already stated; Tunnel and
Memory Leak Prevention folded into neighboring sections). 251 to 234 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:14:16 +02:00
arkonandClaude Opus 4.7 1c3f2f6571 docs: tighten CLAUDE.md and archive 22 completed plan docs
CLAUDE.md: fix stale counts (types 14 to 15, SSE events ~118 to ~120),
remove redundant footer sections (References list duplicated inline citations;
Common Workflows bullets were self-evident or already stated; Tunnel/Memory
Leak Prevention folded into neighboring sections). 251 to 234 lines.

Move 22 completed implementation/phase/audit plans to docs/archive/ via
git mv so history is preserved. Living reference docs remain in docs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:14:07 +02:00
arkonandClaude Opus 4.7 8da1bdf690 chore: prevent package-lock.json version drift permanently
Makes the drift that PR #70 caught impossible to repeat:

- `version-packages` script now runs `changeset version && npm install
  --package-lock-only && check-lockfile-sync`, so the lockfile is always
  regenerated and verified as part of consuming a changeset
- New `scripts/check-lockfile-sync.mjs` compares package.json#.version against
  package-lock.json's root and packages[""] version fields (npm ci does not
  enforce these, which is why the prior drift slipped through CI)
- CI now runs `npm run check:lockfile` on every push/PR — any future drift
  fails the build before merge
- COM workflow in CLAUDE.md collapsed back to a single release-bump step now
  that lockfile sync is automatic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:01:51 +02:00
arkonandClaude Opus 4.7 a93325b312 fix: sync package-lock.json to 0.6.0 and document lockfile step in COM workflow
package.json has been at 0.6.0 since release, but package-lock.json stayed at
0.3.11 because `npm run version-packages` (changesets) does not regenerate the
lockfile. This left `npm ci` broken against the committed state.

Also adds step 4 (`npm install --package-lock-only`) to the COM workflow in
CLAUDE.md so future releases keep the lockfile in sync automatically.

Credit to @Matt2012 (#70) for catching the lockfile drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:00:10 +02:00
arkonandClaude Opus 4.7 2d03e4efc9 docs: update CLAUDE.md and README for clipboard API and tab shortcuts
Reflect changes from PRs #65–#68: bumped route/SSE counts, added
Ctrl+Shift+{/} (tab reorder), Alt+1-9 (tab switch), Ctrl+Shift+V
(voice), and POST /api/clipboard to the keyboard and API references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 04:16:36 +02:00
arkonandClaude Opus 4.7 ab7c502c2a chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 04:13:26 +02:00
Ark0N 546bbcbe7c Merge pull request #68 from aakhter/feat/clipboard-api
feat: add clipboard API for remote browser clipboard access
2026-04-18 04:10:31 +02:00
Ark0N 774d5ff321 Merge pull request #67 from aakhter/feat/tab-badges
feat: improve active tab visibility and add Alt+N number badges
2026-04-18 04:08:09 +02:00
Ark0N 98ceb5da1d Merge pull request #66 from aakhter/feat/tab-reorder-shortcuts
feat: add Ctrl+Shift+{/} to reorder session tabs
2026-04-18 04:07:01 +02:00
Ark0N 34fb5e49f8 Merge pull request #65 from aakhter/fix/android-shift-double-char
fix: prevent double character input on Android Shift+key
2026-04-18 04:05:21 +02:00
arkonandClaude Opus 4.7 002cf81b1e chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 04:02:02 +02:00
Aamer Akhter 9b4aab2502 feat: add clipboard API for remote browser clipboard access
POST /api/clipboard with { text } broadcasts to all connected browsers
via SSE. Browser attempts navigator.clipboard.writeText, falling back
to a modal with manual copy button if blocked.

Enables remote clipboard workflows: CLI tools can push text to the
user's browser clipboard across the network.
2026-04-12 10:07:15 -04:00
Aamer Akhter 829c797726 feat: improve active tab visibility and add Alt+N number badges
- Active tab: bright green border with color-matched glow per session color
- Tab number badges (1-9) showing Alt+N shortcut hints
- Badges update on tab reorder and re-render
2026-04-12 10:04:34 -04:00
Aamer Akhter 85da3bb898 feat: add Ctrl+Shift+{/} keyboard shortcuts to reorder session tabs
Matches WezTerm/terminal emulator tab reorder conventions. Uses the
existing sessionOrder array and saveSessionOrder() for persistence.
2026-04-12 10:02:12 -04:00
Aamer Akhter 29b2653801 fix: prevent double character input on Android Shift+key
On Android tablets, pressing Shift+A produces "AA" because the input
event listener re-sends characters that xterm already processed via
its keydown handler. Track keydown timestamps and skip input events
that fire within 50ms of a handled keydown.

Only affects touch devices (listener gated by isTouchDevice()).
2026-04-12 10:01:29 -04:00
arkonandClaude Opus 4.6 7b8b175133 chore: version packages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:01:32 +02:00
Ark0N c3027b21e1 Merge pull request #63 from Typhon0/fix/quick-start-linked-cases
Good catch — quick-start was ignoring linked-cases.json. Thanks @Typhon0!
2026-04-11 20:58:57 +02:00
Loïc Sculier 0b231edd43 Fix quick-start to resolve linked cases before codeman-cases fallback
/api/quick-start was always resolving caseName against CASES_DIR,
ignoring any entries in ~/.codeman/linked-cases.json. This caused
sessions to start in ~/codeman-cases/<name> even when the case was
linked to an external project directory.

Fix: read linked-cases.json first and prefer that path, falling back
to validatePathWithinBase only when no link is found.
2026-04-11 17:45:41 +02:00
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
arkonandClaude Opus 4.6 93719b41cd chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:34:14 +01:00
arkonandClaude Opus 4.6 a448983be3 refactor: pass 2 — extract shared helpers and simplify patterns
app.js:
- Add _clearTimer() helper replacing 11 inline clearTimeout patterns
- Add _isStaleSelect() helper for generation check + cleanup
- Replace 11 keyboard shortcut if-blocks with data-driven lookup table
- Extract _cleanupPreviousSession() from selectSession() (~75 lines)
- Extract _resetAllAppState() from handleInit() (~75 lines)

tmux-manager:
- Extract buildEnvExports() eliminating duplication in createSession/respawnPane
- Extract buildPathExport() for CLI path resolution
- Extract _configureOpenCode() for OpenCode setup

routes:
- Add readJsonConfig() to route-helpers, replacing 5 inline JSON-read patterns
- Add validateSessionFilePath() to route-helpers, replacing 2 identical path
  traversal validation blocks in file-routes

session-auto-ops:
- Convert executeWhenIdle() from 8 positional params to options object
- Extract validateThreshold() for shared compact/clear validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:32:28 +01:00
arkonandClaude Opus 4.6 3145eac6d9 refactor: extract helper methods to reduce duplication and improve readability
DRY up repeated patterns across 7 core files:
- state-store: extract serializeState() and split assembleStateJson() into 3 focused methods
- session: extract _resetBuffers(), _clearAllTimers(), _handleJsonMessage()
- ralph-tracker: extract completeAllTodos() (was 4x duplicated), emitValidationWarning(), similarity constants
- subagent-watcher: extract markSubagentAsCompleted(), extractFirstTextContent(), emitToolResult(), findOldestInactiveAgent()
- respawn-controller: extract recoveryResetToWatching(), canAutoAccept(), formatRemainingSeconds(), validatePositiveTimeout()
- tmux-manager: replace 15 path.includes() checks with single UNSAFE_PATH_CHARS regex
- session-auto-ops: extract executeWhenIdle() shared retry helper for checkAutoCompact/checkAutoClear

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:21:38 +01:00
arkonandClaude Opus 4.6 e3c609f5f0 test: add coverage for lastUsedCase partial update and strict schema rejection
Tests that partial PUT /api/settings with just lastUsedCase works correctly
and that including modelConfig triggers strict Zod schema rejection (the bug
fixed in #49).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:39:47 +01:00
Tenggan Zhang 52e774f83c fix: case selection not persisting across page refresh (#49)
Thank you for the clean fix! The root cause analysis in the PR description was excellent — the strict Zod schema rejecting modelConfig during the GET-then-PUT pattern was a subtle bug.
2026-03-25 13:39:16 +01:00
arkon 82d08df53f chore: version packages 2026-03-25 00:18:14 +01:00
arkonandClaude Opus 4.6 2709b2fe49 feat: make buffer size limits configurable via environment variables
Allow overriding MAX_TERMINAL_BUFFER_SIZE, TRIM_TERMINAL_TO, MAX_TEXT_OUTPUT_SIZE,
TRIM_TEXT_TO, and MAX_MESSAGES via CODEMAN_* env vars, falling back to existing
defaults. Enables users with fewer sessions or more RAM to tune buffer sizes
without patching source.

Closes #48

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:40:40 +01:00
Ark0N b1d3b27e5b Merge pull request #47 from TeigenZhang/fix/mobile-cjk-input-and-layout
fix: mobile CJK input, terminal flicker, and layout overflow
2026-03-24 18:22:48 +01:00
Teigen 47963b54fa fix: mobile CJK input, terminal flicker, and layout overflow
Terminal flicker:
- Skip buffer-recovered/clear-terminal events during active buffer load
  to prevent competing clear+rewrite cycles (app.js)
- Move viewport+scrollback clear inside dimension-change guard so resize
  without actual SIGWINCH doesn't blank the terminal (terminal-ui.js)
- Sync _lastResizeDims on explicit resize to prevent redundant clears

CJK input rewrite (input-cjk.js):
- Use InputEvent.inputType to distinguish insertText (final) from
  insertCompositionText (tentative) — fixes Chinese punctuation and
  English text being swallowed during Android IME composition
- Remove isComposing guard on Enter so it always sends
- Phantom character (U+200B) keeps textarea non-empty so Android
  long-press backspace generates continuous deleteContentBackward
  events at the keyboard's native repeat rate

CJK input settings:
- Add "CJK Input" toggle in Settings > Input (index.html, settings-ui.js)
- Store as device-specific setting (cjkInputEnabled), not synced to server
- Replace INPUT_CJK_FORM env var dependency with user-controlled setting
  (env var still works as server override)

Mobile layout:
- Fix welcome screen overflow on phones by constraining .welcome-content
  to calc(100vw - 1.5rem) (mobile.css)
- Move xterm helper textarea on-screen for touch devices to fix iOS
  keyboard input (styles.css)
- Focus terminal synchronously in user-gesture context for iOS Safari
  keyboard activation (session-ui.js, app.js)
- Refocus terminal on tap (not scroll) in touch handler (terminal-ui.js)
2026-03-24 09:22:06 +08:00
arkonandClaude Opus 4.6 b7c3c30c8c fix: send Ctrl+L after tab switch to clear stale Ink CUP frames
Tailed terminal buffers contain multiple CUP-positioned Ink frames from
different time points. When replayed in xterm, old frames at viewport
positions not covered by the latest frame persist as ghost content
(e.g. duplicate "bypass permissions" bars). After buffer load, send
Ctrl+L via the session input API to trigger a full Ink redraw, which
overwrites all stale frame content with the correct current state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:59:48 +01:00
arkonandClaude Opus 4.6 0d80524f10 fix: prevent duplicate terminal output on tab switch to busy sessions
Two fixes for the tab-switching corruption bug:

1. _finishBufferLoad() now discards queued SSE events instead of flushing
   them. The loaded API buffer is the source of truth — queued events
   overlap with it, and flushing them writes duplicate Ink cursor-up
   redraws that corrupt the terminal display (garbled text, wrong cursor
   positions).

2. Skip stale cache write for busy sessions. When a session is actively
   working, the cache is always outdated — writing it first and then
   rewriting with the fresh API buffer caused a jarring double-render
   flash. Now busy sessions get a single clean clear+write transition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:32:33 +01:00
arkonandClaude Opus 4.6 a9d83ec4e3 chore: version packages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:55:39 +01:00
arkonandClaude Opus 4.6 84137cdba4 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:59:27 +01:00
arkonandClaude Opus 4.6 6eb3969816 fix: avoid no-control-regex lint error for ANSI strip pattern
Use RegExp constructor with String.raw to express \x1b without
a literal control character in the source, matching the pattern
used elsewhere in the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:57:36 +01:00
arkonandClaude Opus 4.6 de49437a6f docs: add browser-testing-guide to CLAUDE.md references, clarify route count
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:38:14 +01:00
arkonandClaude Opus 4.6 6a27639083 fix: increase Ink frame search window from 4KB to 64KB to prevent partial frames
Single Ink frames with response content can be 10-20KB, so the 4KB tail
was too small and caused blank gaps. Now searches the last 64KB for VPA
row drops to find the last complete frame boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:38:09 +01:00
arkonandClaude Opus 4.6 eb1b38c718 fix: align case select group height — stretch buttons to match dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:06:15 +01:00
arkonandClaude Opus 4.6 ea7b103b47 fix: prevent stale terminal data on tab switch — add chunkedTerminalWrite cancellation
chunkedTerminalWrite used requestAnimationFrame to write buffer chunks across
frames but had no cancellation. When switching tabs, old session's remaining
chunks continued writing stale data into the new session's terminal, causing
visual artifacts and garbled content.

- Add _chunkedWriteGen generation counter to abort in-flight chunked writes
- Bump gen early in selectSession() and SSE reconnect to immediately cancel
- Guard finish() so aborted writes don't flush SSE queue for wrong session
- Add fitAddon.fit() before buffer writes to sync terminal dimensions
- Add fitAddon.fit() in sendResize() to ensure local/server dim parity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:00:58 +01:00
arkonandClaude Opus 4.6 bec8e2f9ee fix: improve history prompt extraction — filter expanded commands, add tail scan fallback
Skip /init expansions, slash commands, orchestrator prompts, ANSI codes, secrets,
and short/vague messages. When head scan finds no usable prompt (e.g. /init sessions),
read last 32KB of transcript to find a recent meaningful user message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:42:50 +01:00
arkonandClaude Opus 4.6 2203f3a347 feat: visual redesign — glass morphism, refined colors, polished UI
Modernize the entire UI with a cohesive "refined dark glass" aesthetic
while preserving all existing functionality.

- Header/toolbar: backdrop-filter blur(16px), semi-transparent backgrounds
- Buttons: 6px radius, multi-stop gradients, inner glow, cubic-bezier transitions
- Welcome screen: gradient text title, radial bg glow, pill-shaped buttons with hover lift
- Panels/modals: glass backgrounds, 12px radius, layered shadows
- Color palette: cooler blue-tinted darks replacing flat blacks
- Forms: refined inputs with focus rings, glass toggle switches
- New CSS vars: --glass-bg, --glass-border, --btn-radius, --transition-smooth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:24:27 +01:00
arkonandClaude Opus 4.6 867a10d78a refactor: optimize history endpoint — reuse buffer, extract readFileHead, use line iterator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:01:56 +01:00
216 changed files with 8046 additions and 10643 deletions
+3
View File
@@ -22,6 +22,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check package-lock.json version sync
run: npm run check:lockfile
- name: Type check
run: npm run typecheck
+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/
+189
View File
@@ -1,5 +1,194 @@
# aicodeman
## 0.6.3
### Patch Changes
- **Fix**
- Allowlist `opusContext1mEnabled` in `SettingsUpdateSchema`. Without this entry, the strict schema rejected `PUT /api/settings {"opusContext1mEnabled":...}` with `INVALID_INPUT`, so the toggle's value never persisted across reloads. The frontend was already reading and writing this key (`settings-ui.js:336/1137`, `session-ui.js:340`), so saves were silently failing — users never noticed because the load path falls back to `false` on missing keys, hiding the bug. (#78)
## 0.6.2
### Patch Changes
- **Mobile UX**
- Resume Conversation list (welcome page) reworked for narrow screens: 2-line title clamp so more of the first prompt is visible; case-aware subtitle that renders `#caseName` (or `#caseName/sub`) when `workingDir` matches a known case, otherwise falls back to the directory basename; inline `⋯` toggle that expands a detail panel with full prompt, full path, timestamp, size, and short session id; `/Users/<user>/` now collapses to `~/` alongside `/home/<user>/`. (#77)
- Response viewer: ASCII diagram wrap toggle, dedicated mobile code-block layout, and chrome-stripping fallback when the model wraps its reply in extra markup. (#75)
- Mobile keyboard accessory bar no longer triggers vertical scroll. (#72)
**Sessions & settings**
- New `thinkingEffort` setting on session creation, with `xhigh` option and `/effort max` mobile shortcut. (#73)
- `thinkingEffort` is now allowlisted in `SettingsUpdateSchema` so it round-trips through PATCH /api/settings.
- `envOverrides` (`CLAUDE_CODE_*` / `OPENCODE_*`) are now passed to Claude via tmux env exports at spawn time instead of being written to `<case>/.claude/settings.local.json`. Eliminates UI/disk drift; the value lives on `Session._envOverrides`, is exported by `tmux-manager.buildEnvExports()`, and is persisted in `SessionState.envOverrides`. (#74)
**Fixes**
- Eye icon (active-session indicator) now follows `/clear` to the new Claude conversation instead of getting stuck on the previous transcript. (#76)
- `tmux-manager.reconcileSessions` now uses `|` as the field separator, fixing parsing when session names contain other delimiters. (#71)
**Docs**
- CLAUDE.md: added `npm run knip` to the dead-code sweep table and a `Common Gotchas` entry documenting the `envOverrides` → tmux export flow.
## 0.6.1
### Patch Changes
- Internal cleanup and release hygiene:
- **Dead-code sweep via knip**: added `knip.json` for dead-code detection and ran a full sweep — removed unused test files, unused scripts, and narrowed internal module exports to the minimum surface area actually consumed.
- **Lockfile drift prevention**: `version-packages` now runs `npm install --package-lock-only` and verifies the lockfile is in sync via `scripts/check-lockfile-sync.mjs`; CI runs the same check on every push/PR so version drift fails the build instead of reaching production. Resolves the `package-lock.json` / `package.json` version mismatch that shipped in 0.6.0.
- **Docs tightening**: archived 22 completed plan docs from `docs/`, corrected file/handler counts in `CLAUDE.md`, documented the lockfile step in the COM workflow, and removed footer redundancy.
## 0.6.0
### Minor Changes
- Community contributions from @aakhter:
- **feat (#66): Tab reorder shortcuts** — `Ctrl+Shift+{` and `Ctrl+Shift+}` move the active session tab left/right, matching WezTerm convention. Order persists across reloads via `saveSessionOrder()`.
- **feat (#67): Active tab visibility + Alt+N badges** — active tab now has a bright green border with color-matched glow, and the first 9 tabs display number badges hinting at the `Alt+N` switch shortcut. Badges update on reorder/rerender.
- **feat (#68): Clipboard API** — new `POST /api/clipboard` accepting `{text}` broadcasts a `clipboard:write` SSE event; connected browsers attempt `navigator.clipboard.writeText()` with a manual-copy modal fallback when the page isn't focused. Auth-protected via the standard middleware. Useful for pushing snippets from remote sessions to the user's local clipboard.
- **fix (#65): Android Shift+key double character** — pressing `Shift+A` on attached Android keyboards no longer produces "AA". Tracks xterm-handled keydown timestamps and skips the orphaned-input listener for 50ms after a real keydown, while still catching Gboard symbol-keyboard inputs (keyCode 229).
## 0.5.13
### Patch Changes
- Fix "Case path not found" error in Quick Start when `~/codeman-cases/` does not exist (issue #64). Two bugs in `session-ui.js`:
- `runClaude()` auto-create read `createCaseData.case`, but `POST /api/cases` returns `{ success, data: { case } }` — corrected to `createCaseData.data.case`.
- `runShell()` had no auto-create logic and would immediately throw on a missing case directory — now mirrors `runClaude()`'s create-on-demand flow.
## 0.5.12
### Patch Changes
- Fix quick-start to resolve linked cases before codeman-cases fallback. `/api/quick-start` was always resolving `caseName` against `CASES_DIR`, ignoring entries in `~/.codeman/linked-cases.json`. Sessions started via quick-start now correctly honour linked external project directories, consistent with regular case routes.
## 0.5.11
### Patch Changes
- Community contributions and security hardening:
- Mobile response viewer: native-scroll panel for reading full Claude responses with markdown rendering via marked.js (PR #62)
- PWA support: service worker caching, web app manifest, and Android home screen install (PR #59)
- Named Cloudflare tunnel support (PR #58)
- Markdown rendering for response viewer with HTML sanitization (XSS prevention) — strips dangerous elements, event handlers, and javascript: URIs
- Service worker switched from stale-while-revalidate to network-first caching so deploys take effect immediately
- Content-Disposition filename sanitization to prevent header injection in file downloads
- Expose session.muxName public getter, replace unsafe `as any` cast in session-routes
- Static import for execFile in session-routes
- Keyboard shortcut updates: Alt+1-9 tab switching, Shift+Enter newline
- Repo restructure for cleaner GitHub landing page
- Mobile logo, expandable history, session resume fixes
## 0.5.10
### Patch Changes
- fix: allow bracket characters in model validation regex so models like opus[1m] (1M context window) are accepted instead of silently dropped. Quote the model flag value in tmux spawn commands to prevent bash glob expansion of bracket patterns.
docs: update macOS launchd instructions to use `launchctl bootstrap` instead of deprecated `load`. Clean up README install and service sections.
## 0.5.9
### Patch Changes
- Mobile keyboard accessory bar: add configurable "Extended Keyboard Bar" setting (Settings > Display > Input) that toggles between simple mode (up/down arrows, /init, /clear, /compact, paste, dismiss) and extended mode (adds left/right arrows, Tab, Shift+Tab, Ctrl+O, Alt+Enter, Esc). Default is simple mode. Setting is device-specific (not synced to server).
Restyle dismiss button: muted steel-blue tone, fills remaining bar space via flex, larger tap target. Arrow buttons now blue.
Fix paste overlay visibility on mobile: dialog repositioned to top of screen (15vh from top) so the virtual keyboard doesn't cover it. Textarea enlarged for better usability.
(Also includes all v0.5.8 changes: case reorder/delete, XSS sanitization, auto-attach PTY on restart, mobile keyboard buttons, macOS installer fixes, terminal flicker fix, state store collision fix.)
## 0.5.8
### Patch Changes
- Case management: add Manage tab with reorder (up/down arrows) and delete for cases; linked cases are unlinked (folder preserved), CASES_DIR cases are permanently deleted. New endpoints: DELETE /api/cases/:name, PUT /api/cases/order. SSE events: case:deleted, case:order-changed.
Security: sanitize case names from filesystem with /^[a-zA-Z0-9_-]+$/ regex before returning from GET /api/cases to prevent XSS via maliciously-named directories reaching frontend inline onclick handlers.
Auto-attach PTY: server now calls startInteractive() for recovered tmux sessions during startup so all sessions resume capturing output immediately after deploy, instead of waiting for client selection. Frontend auto-attach condition relaxed from (pid===null && status==='idle') to (pid===null && !\_ended).
Mobile keyboard accessory: add Shift+Tab, Tab, Esc, Alt+Enter, Left/Right arrow, and Ctrl+O buttons.
Terminal: fix flicker regression by moving viewport clear inside dimension guard.
State store: fix temp file collisions on concurrent writes.
macOS: fix installer failures when piped via curl | bash, add HTML cache support, launchd service template, and trust dialog handling.
Housekeeping: remove accidentally committed dist/state-store.js build artifact.
## 0.5.7
### Patch Changes
- feat: support "Default (CLI default)" option for model selection. Adds a new empty-value option to the model dropdown that defers to the CLI's own default model instead of forcing a specific model. Ensures empty defaultModel values are treated as undefined when passed to session creation and Ralph loop start, preventing empty strings from being sent as model flags.
## 0.5.6
### Patch Changes
- fix: default new sessions to opus[1m] (1M context window) instead of plain opus (200k context)
## 0.5.5
### Patch Changes
- Add 1M Opus context quick setting — per-case and global toggle that writes `model: "opus[1m]"` to `.claude/settings.local.json` when creating new sessions. Fix mobile layout: banners (respawn, timer, orchestrator) between header and main content now visible by switching from margin-top on `.main` to padding-top on `.app`. Add tablet-optimized respawn banner styles and mobile phone banner refinements.
## 0.5.4
### Patch Changes
- Fix terminal flicker regression — re-add server-side DEC 2026 synchronized output wrapping around batched terminal data. Ink spinner frames (cursor-up + redraw cycles) do not emit their own DEC 2026 markers, so without the server wrapper each partial cursor update rendered individually causing visible flicker. Also: extract SSE stream management, session listener wiring, and respawn event wiring from server.ts into dedicated modules; deduplicate error message extraction across 7 files with shared getErrorMessage() helper; update SSE event count in CLAUDE.md (106 → 117).
## 0.5.3
### Patch Changes
- Readability refactor across 12 core files, extracting ~35 helper methods to reduce duplication:
- state-store: extract serializeState(), split assembleStateJson() into focused sub-methods
- session: extract \_resetBuffers() (3x dedup), \_clearAllTimers() (10 timer cleanups), \_handleJsonMessage()
- ralph-tracker: extract completeAllTodos() (4x dedup), emitValidationWarning(), named similarity constants
- subagent-watcher: extract markSubagentAsCompleted(), extractFirstTextContent(), emitToolResult(), findOldestInactiveAgent()
- respawn-controller: extract recoveryResetToWatching(), canAutoAccept(), formatRemainingSeconds(), validatePositiveTimeout()
- tmux-manager: replace 15 path.includes() with UNSAFE_PATH_CHARS regex, extract buildEnvExports/buildPathExport/\_configureOpenCode helpers
- session-auto-ops: extract executeWhenIdle() shared retry helper, convert to options object, add validateThreshold()
- app.js: add \_clearTimer() (11 call sites), \_isStaleSelect(), keyboard shortcut lookup table, \_cleanupPreviousSession(), \_resetAllAppState()
- route-helpers: add readJsonConfig() (5 inline patterns replaced), validateSessionFilePath() (2 duplicated blocks replaced)
## 0.5.2
### Patch Changes
- Make buffer size limits configurable via CODEMAN\_\* environment variables (MAX_TERMINAL_BUFFER, TRIM_TERMINAL_TO, MAX_TEXT_OUTPUT, TRIM_TEXT_TO, MAX_MESSAGES), falling back to existing defaults. Allows users with fewer sessions or more RAM to tune buffer sizes without patching source.
Fix duplicate terminal output on tab switch to busy sessions by clearing the terminal before writing the new buffer.
Fix stale Ink CUP frames after tab switch by sending Ctrl+L to force a clean redraw.
Fix mobile CJK input handling: resolve textarea positioning, terminal flicker during composition, and layout overflow on small screens. Improve CJK composition lifecycle with better event handling and fallback flush timers.
## 0.5.1
### Patch Changes
- refactor: codebase cleanup — extract route helpers, eliminate boilerplate, optimize hot paths
- Add `parseBody()` helper to route-helpers.ts: validates request body against Zod schema with structured 400 error on failure, replacing 37 identical safeParse + error-check blocks across 10 route files
- Add `persistAndBroadcastSession()` helper: combines persist + SessionUpdated broadcast into one call, replacing 5 repeated 2-line pairs
- Migrate session-routes.ts to use `findSessionOrFail()` consistently (17 inline session lookups replaced) and `parseBody()` (12 patterns)
- Migrate ralph-routes.ts to use `findSessionOrFail()` (9 lookups) and `parseBody()` (4 patterns)
- Migrate 8 remaining route files to use `parseBody()` (21 patterns total)
- Fix O(n log n) eviction in bash-tool-parser.ts: replace `Array.from().sort()[0]` with O(n) min-scan for oldest active tool
- Extract `_debouncedCall()` utility in frontend: replaces 4 manual debounce patterns (7 lines each → 1 line) in app.js, panels-ui.js, ralph-panel.js
- Net reduction: 208 lines removed across 16 files
## 0.5.0
### Minor Changes
- Visual redesign with glass morphism, refined colors, and polished UI. Optimize history endpoint with buffer reuse and line iterator. Fix Ink frame search window (4KB→64KB) to prevent partial frames. Fix stale terminal data on tab switch via chunkedTerminalWrite cancellation. Improve history prompt extraction with expanded command filtering and tail scan fallback. Align case select group height to match dropdown. Fix no-control-regex lint error for ANSI strip pattern. Add browser-testing-guide to CLAUDE.md references.
## 0.4.7
### Patch Changes
+32 -42
View File
@@ -6,11 +6,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| Task | Command |
|------|---------|
| Dev server | `npx tsx src/index.ts web` |
| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) |
| Type check | `tsc --noEmit` |
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
| Format | `npm run format` (check: `npm run format:check`) |
| Single test | `npx vitest run test/<file>.test.ts` |
| Single test | `npm test -- test/<file>.test.ts` (or `npx vitest run --config config/vitest.config.ts test/<file>.test.ts`) |
| Build | `npm run build` (esbuild via `scripts/build.mjs`, NOT tsc — `tsc --noEmit` is type-check only) |
| Production | `npm run build && systemctl --user restart codeman-web` |
## CRITICAL: Session Safety
@@ -48,11 +49,13 @@ When user says "COM":
CHANGESET
```
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
3. **Consume the changeset**: `npm run version-packages` (bumps versions in `package.json` files and updates `CHANGELOG.md`)
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.4.7 (must match `package.json`)
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.6.3 (must match `package.json`)
## Project Overview
@@ -75,19 +78,21 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Dev with TLS | `npx tsx src/index.ts web --https` |
| Continuous typecheck | `tsc --noEmit --watch` |
| Test coverage | `npm run test:coverage` |
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master (Node 22). Tests excluded (they spawn tmux).
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `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
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
- **Global regex `lastIndex`** — Use `createAnsiPatternFull/Simple()` factories, not shared `g`-flag patterns in loops
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -109,17 +114,17 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules incl. `ws-routes.ts` + 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` | |
| **Types** | `src/types/index.ts` → 15 domain files | See `@fileoverview` in index.ts |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 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.9K 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` (barrel) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
**Local package**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`.
**Config**: `src/config/` — 9 files. Import from specific files, not barrel.
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks).
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
### Data Flow
@@ -136,7 +141,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 +155,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), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
### Security
@@ -167,11 +172,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
~106 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
~120 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
### API Routes
~124 handlers across 14 route files in `src/web/routes/`: system (36), sessions (25), orchestrator (10), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~128 handlers across 15 route files in `src/web/routes/`: system (36), sessions (27), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
## Adding Features
@@ -193,22 +198,20 @@ All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.
**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Only run individual files:
```bash
npx vitest run test/<specific-file>.test.ts # Single file (SAFE)
npx vitest run -t "pattern" # By name (SAFE)
# npx vitest run # DANGEROUS — DON'T DO THIS
npm test -- test/<specific-file>.test.ts # Single file (SAFE, uses config/vitest.config.ts)
npm test -- -t "pattern" # By name (SAFE)
# npm test # DANGEROUS — runs full suite, DON'T DO THIS
```
Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pass `--config config/vitest.config.ts`.
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s.
**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions and never kills them. Only `registerTestTmuxSession()` sessions get cleaned up.
**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).
## Screenshots
Mobile screenshots in `~/.codeman/screenshots/`. API: `GET /api/screenshots`, `POST /api/screenshots`.
**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).
## Debugging
@@ -220,27 +223,14 @@ curl localhost:3000/api/subagents | jq # Background agents
cat ~/.codeman/state.json | jq # Persisted state
```
Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/screenshots`.
## Performance & Limits
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 2MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
## References
**Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npm test -- test/memory-leak-prevention.test.ts`.
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`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
## Scripts & Tunnel
## Scripts
Key: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh` (tunnel start/stop/url). Production: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`.
## Memory Leak Prevention
24+ hour sessions: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npx vitest run test/memory-leak-prevention.test.ts`.
## Common Workflows
**Bug investigation**: Dev server → reproduce in browser → check terminal + `~/.codeman/state.json`.
**Respawn changes**: Read `docs/respawn-state-machine.md` first. Use `MockSession` from `test/respawn-test-utils.ts`.
## Tunnel
`./scripts/tunnel.sh start|stop|url`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
Key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh start|stop|url` (tunnel). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
+47 -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>
@@ -378,9 +401,12 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `Ctrl+Enter` | Quick-start session |
| `Ctrl+W` | Close session |
| `Ctrl+Tab` | Next session |
| `Alt+1`–`Alt+9` | Switch to tab N |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl+K` | Kill all sessions |
| `Ctrl+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input |
| `Ctrl/Cmd +/-` | Font size |
| `Escape` | Close panels |
@@ -423,6 +449,7 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `GET` | `/api/events` | SSE stream |
| `GET` | `/api/status` | Full app state |
| `POST` | `/api/hook-event` | Hook callbacks |
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
---
+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
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"scripts/*.mjs",
"scripts/*.js",
"scripts/watch-subagents.ts",
"scripts/remotion/Root.tsx",
"scripts/remotion/index.ts",
"test/**/*.test.ts",
"test/mobile/vitest.config.ts",
"test/**/*.mjs"
],
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs,js}", "test/**/*.{ts,mjs}"],
"ignoreExportsUsedInFile": true,
"ignoreDependencies": ["@remotion/cli", "@remotion/transitions", "esbuild", "agent-browser"]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.3.11",
"version": "0.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.3.11",
"version": "0.6.3",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+9 -7
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.4.7",
"version": "0.6.3",
"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,17 +15,19 @@
"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",
"changeset": "changeset",
"version-packages": "changeset version",
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"knip": "npx --yes knip@latest",
"release": "changeset publish"
},
"workspaces": [
-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);
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Fails if package-lock.json's version fields don't match package.json.
// Changesets bumps package.json but NOT the lockfile — this catches that drift
// (the top-level `version` in lockfiles is metadata, so `npm ci` won't flag it).
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
const lock = JSON.parse(readFileSync(resolve(repoRoot, 'package-lock.json'), 'utf8'));
const expected = pkg.version;
const rootVersion = lock.version;
const selfVersion = lock.packages?.['']?.version;
const mismatches = [];
if (rootVersion !== expected) mismatches.push(` package-lock.json#.version = ${rootVersion} (expected ${expected})`);
if (selfVersion !== expected) mismatches.push(` package-lock.json#.packages[""].version = ${selfVersion} (expected ${expected})`);
if (mismatches.length > 0) {
console.error(`\nLockfile version drift detected (package.json is ${expected}):`);
console.error(mismatches.join('\n'));
console.error('\nFix: run `npm install --package-lock-only` and commit the updated package-lock.json.\n');
process.exit(1);
}
console.log(`Lockfile in sync with package.json (${expected}).`);
+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
+1 -1
View File
@@ -41,7 +41,7 @@ import {
// ========== Types ==========
export type AiIdleCheckConfig = AiCheckerConfigBase;
type AiIdleCheckConfig = AiCheckerConfigBase;
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
+2 -2
View File
@@ -40,13 +40,13 @@ import {
// ========== Types ==========
export type AiPlanCheckConfig = AiCheckerConfigBase;
type AiPlanCheckConfig = AiCheckerConfigBase;
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
export type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
// ========== Constants ==========
+104 -113
View File
@@ -99,7 +99,7 @@ const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log
/**
* Events emitted by BashToolParser.
*/
export interface BashToolParserEvents {
interface BashToolParserEvents {
/** New Bash tool with file paths started */
toolStart: [tool: ActiveBashTool];
/** Bash tool completed */
@@ -111,7 +111,7 @@ export interface BashToolParserEvents {
/**
* Configuration options for BashToolParser.
*/
export interface BashToolParserConfig {
interface BashToolParserConfig {
/** Session ID this parser belongs to */
sessionId: string;
/** Whether the parser is enabled (default: true) */
@@ -470,113 +470,91 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
* Process a single pre-stripped line of terminal output.
*/
private processCleanLine(cleanLine: string): void {
// Check for tool start
if (this._handleToolStart(cleanLine)) return;
if (this._handleToolCompletion(cleanLine)) return;
if (this._handleTextCommand(cleanLine)) return;
this._handleLogFileMention(cleanLine);
}
private _handleToolStart(cleanLine: string): boolean {
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
if (startMatch) {
const command = startMatch[1];
const timeout = startMatch[2]?.trim();
if (!startMatch) return false;
// Check if this is a file-viewing command
if (this.isFileViewerCommand(command)) {
const filePaths = this.extractFilePaths(command);
const command = startMatch[1];
const timeout = startMatch[2]?.trim();
// Skip if any file path is already tracked (cross-pattern dedup)
if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
return;
}
if (!this.isFileViewerCommand(command)) return true;
if (filePaths.length > 0) {
const tool: ActiveBashTool = {
id: uuidv4(),
command,
filePaths,
timeout,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
const filePaths = this.extractFilePaths(command);
// Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
if (oldest) {
this._activeTools.delete(oldest[0]);
}
// Skip if any file path is already tracked (cross-pattern dedup)
if (filePaths.some((fp) => this.isFilePathTracked(fp))) return true;
if (filePaths.length > 0) {
const tool = this._createActiveTool(command, filePaths, 'running', timeout);
// Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
let oldestKey: string | undefined;
let oldestTime = Infinity;
for (const [key, entry] of this._activeTools) {
if (entry.startedAt < oldestTime) {
oldestTime = entry.startedAt;
oldestKey = key;
}
this._activeTools.set(tool.id, tool);
this._lastToolId = tool.id;
this.emit('toolStart', tool);
this.scheduleUpdate();
}
}
return;
}
// Check for tool completion
if (TOOL_COMPLETION_PATTERN.test(cleanLine) && this._lastToolId) {
const tool = this._activeTools.get(this._lastToolId);
if (tool && tool.status === 'running') {
tool.status = 'completed';
this.emit('toolEnd', tool);
this.scheduleUpdate();
// Remove completed tool after a short delay to allow UI to show completion
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
2000,
{ description: 'auto-remove completed tool' }
);
}
this._lastToolId = null;
return;
}
// Fallback: Check for command suggestions in plain text (e.g., "tail -f /tmp/file.log")
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
if (textCmdMatch) {
const filePath = textCmdMatch[2];
// Create a suggestion tool (marked as 'suggestion' status)
const tool: ActiveBashTool = {
id: uuidv4(),
command: cleanLine.trim(),
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running', // Shows as clickable
sessionId: this._sessionId,
};
// Don't add if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) {
return;
if (oldestKey) {
this._activeTools.delete(oldestKey);
}
}
this._activeTools.set(tool.id, tool);
this._lastToolId = tool.id;
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove suggestions after 30 seconds
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
30000,
{ description: 'auto-remove suggestion tool' }
);
return;
}
// Last fallback: Check for log file paths mentioned anywhere in the line
return true;
}
private _handleToolCompletion(cleanLine: string): boolean {
if (!TOOL_COMPLETION_PATTERN.test(cleanLine) || !this._lastToolId) return false;
const tool = this._activeTools.get(this._lastToolId);
if (tool && tool.status === 'running') {
tool.status = 'completed';
this.emit('toolEnd', tool);
this.scheduleUpdate();
this._scheduleAutoRemove(tool.id, 2000, 'auto-remove completed tool');
}
this._lastToolId = null;
return true;
}
private _handleTextCommand(cleanLine: string): boolean {
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
if (!textCmdMatch) return false;
const filePath = textCmdMatch[2];
// Don't add if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) return true;
const tool = this._createActiveTool(cleanLine.trim(), [filePath], 'running');
this._activeTools.set(tool.id, tool);
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove suggestions after 30 seconds
this._scheduleAutoRemove(tool.id, 30000, 'auto-remove suggestion tool');
return true;
}
private _handleLogFileMention(cleanLine: string): void {
LOG_FILE_MENTION_PATTERN.lastIndex = 0;
let logMatch;
while ((logMatch = LOG_FILE_MENTION_PATTERN.exec(cleanLine)) !== null) {
@@ -588,33 +566,46 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Skip if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) continue;
const tool: ActiveBashTool = {
id: uuidv4(),
command: `View: ${filePath}`,
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
const tool = this._createActiveTool(`View: ${filePath}`, [filePath], 'running');
this._activeTools.set(tool.id, tool);
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove after 60 seconds
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
60000,
{ description: 'auto-remove log file tool' }
);
this._scheduleAutoRemove(tool.id, 60000, 'auto-remove log file tool');
}
}
private _createActiveTool(
command: string,
filePaths: string[],
status: ActiveBashTool['status'],
timeout?: string
): ActiveBashTool {
return {
id: uuidv4(),
command,
filePaths,
timeout,
startedAt: Date.now(),
status,
sessionId: this._sessionId,
};
}
private _scheduleAutoRemove(toolId: string, delayMs: number, description: string): void {
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(toolId);
this.scheduleUpdate();
},
delayMs,
{ description }
);
}
/**
* Check if a command is a file-viewing command worth tracking.
*/
+10 -5
View File
@@ -22,14 +22,16 @@
* Maximum terminal buffer size in characters.
* Contains raw terminal output with ANSI escape sequences.
* Reduced from 5MB to 2MB for better render performance.
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
*/
export const MAX_TERMINAL_BUFFER_SIZE = 2 * 1024 * 1024; // 2MB
export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024;
/**
* Size to trim terminal buffer to when max is exceeded.
* Keeps the most recent portion to preserve context.
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
*/
export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024;
// ============================================================================
// Text Output Buffer Limits
@@ -38,13 +40,15 @@ export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
/**
* Maximum text output buffer size in characters.
* Contains ANSI-stripped text for search and analysis.
* Override: CODEMAN_MAX_TEXT_OUTPUT (bytes)
*/
export const MAX_TEXT_OUTPUT_SIZE = 1 * 1024 * 1024; // 1MB
export const MAX_TEXT_OUTPUT_SIZE = parseInt(process.env.CODEMAN_MAX_TEXT_OUTPUT || '') || 1 * 1024 * 1024;
/**
* Size to trim text output buffer to when max is exceeded.
* Override: CODEMAN_TRIM_TEXT_TO (bytes)
*/
export const TRIM_TEXT_TO = 768 * 1024; // 768KB
export const TRIM_TEXT_TO = parseInt(process.env.CODEMAN_TRIM_TEXT_TO || '') || 768 * 1024;
// ============================================================================
// Message Buffer Limits
@@ -53,8 +57,9 @@ export const TRIM_TEXT_TO = 768 * 1024; // 768KB
/**
* Maximum number of Claude JSON messages to keep in memory per session.
* Older messages are discarded when limit is exceeded.
* Override: CODEMAN_MAX_MESSAGES (count)
*/
export const MAX_MESSAGES = 1000;
export const MAX_MESSAGES = parseInt(process.env.CODEMAN_MAX_MESSAGES || '') || 1000;
// ============================================================================
// Line Buffer Limits
+5 -7
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 ==========
@@ -47,7 +48,7 @@ const STREAM_INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MS;
/**
* Represents an active file stream.
*/
export interface FileStream {
interface FileStream {
/** Unique stream identifier */
id: string;
/** Session this stream belongs to */
@@ -73,7 +74,7 @@ export interface FileStream {
/**
* Options for creating a file stream.
*/
export interface CreateStreamOptions {
interface CreateStreamOptions {
/** Session ID requesting the stream */
sessionId: string;
/** Path to the file to stream */
@@ -93,7 +94,7 @@ export interface CreateStreamOptions {
/**
* Result of creating a stream.
*/
export interface CreateStreamResult {
interface CreateStreamResult {
success: boolean;
streamId?: string;
error?: string;
@@ -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' };
}
+64
View File
@@ -84,6 +84,42 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
};
}
/**
* Remove a subset of env keys from .claude/settings.local.json.env if present.
* Used during the disk→tmux-setenv migration: when the caller is actively setting
* a fresh value for a Codeman-managed key, any stale disk entry for THAT KEY is
* superseded and should be removed. Keys NOT in `keysToRemove` are left alone
* (they may be user-managed). No-op if the file/keys don't exist.
*/
export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly string[]): Promise<void> {
if (keysToRemove.length === 0) return;
const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return;
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // Malformed — don't rewrite it
}
const env = existing.env as Record<string, string> | undefined;
if (!env) return;
let changed = false;
for (const key of keysToRemove) {
if (key in env) {
delete env[key];
changed = true;
}
}
if (!changed) return;
existing.env = env;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
}
/**
* Updates env vars in .claude/settings.local.json for the given case path.
* Merges with existing env field; removes vars set to empty string.
@@ -116,6 +152,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.
-5
View File
@@ -17,11 +17,6 @@ import { KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
export interface ImageWatcherEvents {
'image:detected': (event: ImageDetectedEvent) => void;
'image:error': (error: Error, sessionId?: string) => void;
}
// ========== Constants ==========
/** Supported image file extensions (lowercase) */
+4
View File
@@ -63,6 +63,8 @@ export interface CreateSessionOptions {
openCodeConfig?: OpenCodeConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EFFORT_LEVEL). Ephemeral — not written to disk. */
envOverrides?: Record<string, string>;
}
/** Options for respawning a dead pane. */
@@ -77,6 +79,8 @@ export interface RespawnPaneOptions {
openCodeConfig?: OpenCodeConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
envOverrides?: Record<string, string>;
}
/**
+31 -38
View File
@@ -60,21 +60,6 @@ const POST_PHASE_DELAY_MS = 1000;
// Events
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorLoopEvents {
stateChanged: (state: OrchestratorState, prevState: OrchestratorState) => void;
planProgress: (phase: string, detail: string) => void;
planReady: (plan: OrchestratorPlan) => void;
phaseStarted: (phase: OrchestratorPhase) => void;
phaseCompleted: (phase: OrchestratorPhase) => void;
phaseFailed: (phase: OrchestratorPhase, reason: string) => void;
taskAssigned: (task: OrchestratorTask, sessionId: string) => void;
taskCompleted: (task: OrchestratorTask) => void;
taskFailed: (task: OrchestratorTask, error: string) => void;
verificationResult: (phase: OrchestratorPhase, result: VerificationResult) => void;
completed: (stats: OrchestratorStats) => void;
error: (error: Error) => void;
}
// ═══════════════════════════════════════════════════════════════
// OrchestratorLoop
// ═══════════════════════════════════════════════════════════════
@@ -499,27 +484,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 +545,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)}`,
};
}
}
+97 -91
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 };
@@ -68,7 +68,7 @@ export interface DetailedPlanResult {
export type ProgressCallback = (phase: string, detail: string) => void;
export interface PlanSubagentEvent {
interface PlanSubagentEvent {
type: 'started' | 'progress' | 'completed' | 'failed';
agentId: string;
agentType: 'research' | 'planner';
@@ -80,7 +80,7 @@ export interface PlanSubagentEvent {
error?: string;
}
export type SubagentCallback = (event: PlanSubagentEvent) => void;
type SubagentCallback = (event: PlanSubagentEvent) => void;
// ============================================================================
// JSON Repair Helper
@@ -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
+1 -7
View File
@@ -7,10 +7,4 @@
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
export { PLANNER_PROMPT } from './planner.js';
export {
PHASE_EXECUTION_PROMPT,
TEAM_LEAD_PROMPT,
VERIFICATION_PROMPT,
REPLAN_PROMPT,
SINGLE_TASK_PROMPT,
} from './orchestrator.js';
export { PHASE_EXECUTION_PROMPT, TEAM_LEAD_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT } from './orchestrator.js';
-16
View File
@@ -57,22 +57,6 @@ Suggested teammate roles:
Each teammate should focus on their assigned task area. Monitor their progress.
When ALL tasks are complete and you've verified the results, output: <promise>{COMPLETION_PHRASE}</promise>`;
/**
* Verification prompt — asks Claude to verify phase completion.
*
* Placeholders:
* - {PHASE_NAME}: Phase name
* - {CRITERIA}: Numbered verification criteria
* - {PASS_PHRASE}: Phrase to output on success
* - {FAIL_PHRASE}: Phrase to output on failure
*/
export const VERIFICATION_PROMPT = `Review the work done in "{PHASE_NAME}". Check these criteria:
{CRITERIA}
If ALL criteria are met, respond with: {PASS_PHRASE}
If ANY criteria fail, respond with: {FAIL_PHRASE} and explain what failed.`;
/**
* Replan prompt — gives failure context and asks for recovery.
*
+1 -1
View File
@@ -24,7 +24,7 @@ const YAML_LINE_PATTERN = /^([a-zA-Z_-]+):\s*"?([^"\n]+)"?\s*$/gm;
/**
* Ralph Loop configuration from .claude/ralph-loop.local.md
*/
export interface RalphLoopConfig {
interface RalphLoopConfig {
enabled: boolean;
iteration: number;
maxIterations: number | null;
+1 -9
View File
@@ -34,19 +34,11 @@ import { RalphLoopStatus, getErrorMessage } from './types.js';
/**
* Events emitted by RalphLoop
*/
export interface RalphLoopEvents {
started: () => void;
stopped: () => void;
taskAssigned: (taskId: string, sessionId: string) => void;
taskCompleted: (taskId: string) => void;
taskFailed: (taskId: string, error: string) => void;
error: (error: Error) => void;
}
/**
* Configuration options for RalphLoop
*/
export interface RalphLoopOptions {
interface RalphLoopOptions {
/** How often to check for new tasks (default from config) */
pollIntervalMs?: number;
/** Minimum time to run before stopping (null = no minimum) */
+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.
+58 -96
View File
@@ -19,7 +19,6 @@
* Key exports:
* - `RalphTracker` class — main tracker, extends EventEmitter
* - `RalphTrackerEvents` interface — typed event map
* - Re-exports: `EnhancedPlanTask`, `CheckpointReview` from ralph-plan-tracker
*
* Key methods: `processData(data)` — feed terminal output, `getState()`,
* `getTodos()`, `getCompletionHistory()`, `getPlanTasks()`, `reset()`
@@ -66,9 +65,6 @@ import { RalphStallDetector } from './ralph-stall-detector.js';
import { RalphStatusParser } from './ralph-status-parser.js';
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// Re-export sub-module types for backward compatibility
export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js';
// ========== Configuration Constants ==========
// Note: MAX_TODOS_PER_SESSION and MAX_LINE_BUFFER_SIZE are imported from config modules
@@ -100,6 +96,18 @@ const TODO_CLEANUP_INTERVAL_MS = INACTIVITY_TIMEOUT_MS;
*/
const TODO_SIMILARITY_THRESHOLD = 0.85;
/**
* Similarity threshold for short todo content (<30 chars).
* Higher threshold reduces false positive deduplication of short strings.
*/
const SIMILARITY_THRESHOLD_SHORT = 0.95;
/**
* Similarity threshold for medium-length todo content (30-60 chars).
* Slightly relaxed compared to short strings.
*/
const SIMILARITY_THRESHOLD_MEDIUM = 0.9;
/**
* Debounce interval for event emissions (milliseconds).
* Prevents UI jitter from rapid consecutive updates.
@@ -378,32 +386,6 @@ const P2_PRIORITY_PATTERNS = [
* @event circuitBreakerUpdate - Fired when circuit breaker state changes
* @event exitGateMet - Fired when dual-condition exit gate is met
*/
export interface RalphTrackerEvents {
/** Emitted when loop state changes */
loopUpdate: (state: RalphTrackerState) => void;
/** Emitted when todo list is modified */
todoUpdate: (todos: RalphTodoItem[]) => void;
/** Emitted when completion phrase detected (loop finished) */
completionDetected: (phrase: string) => void;
/** Emitted when tracker auto-enables from disabled state */
enabled: () => void;
/** Emitted when a RALPH_STATUS block is parsed */
statusBlockDetected: (block: RalphStatusBlock) => void;
/** Emitted when circuit breaker state changes */
circuitBreakerUpdate: (status: CircuitBreakerStatus) => void;
/** Emitted when dual-condition exit gate is met (completion indicators >= 2 AND EXIT_SIGNAL: true) */
exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
/** Emitted when iteration count hasn't changed for an extended period (stall warning) */
iterationStallWarning: (data: { iteration: number; stallDurationMs: number }) => void;
/** Emitted when iteration count hasn't changed for critical period (stall critical) */
iterationStallCritical: (data: { iteration: number; stallDurationMs: number }) => void;
/** Emitted when a common/risky completion phrase is detected (P1-002) */
phraseValidationWarning: (data: {
phrase: string;
reason: 'common' | 'short' | 'numeric';
suggestedPhrase: string;
}) => void;
}
/**
* RalphTracker - Parses terminal output to detect Ralph Wiggum loops and todos
@@ -1299,6 +1281,24 @@ export class RalphTracker extends EventEmitter {
this.detectTodoItems(trimmed);
}
/**
* Mark all tracked todos as completed and emit todoUpdate if any changed.
* @returns true if any todo was updated
*/
private completeAllTodos(): boolean {
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
return updated;
}
/**
* Detect "all tasks complete" messages.
*/
@@ -1318,16 +1318,7 @@ export class RalphTracker extends EventEmitter {
return;
}
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
if (this._loopState.completionPhrase) {
this._loopState.active = false;
@@ -1425,16 +1416,7 @@ export class RalphTracker extends EventEmitter {
if (bareCount > 1) return;
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
@@ -1480,16 +1462,7 @@ export class RalphTracker extends EventEmitter {
if (canonicalCount >= 2 || this._loopState.active) {
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
this.emit('completionDetected', matchedPhrase);
this.emit('loopUpdate', this.loopState);
return;
@@ -1497,16 +1470,7 @@ export class RalphTracker extends EventEmitter {
}
if (this._loopState.active || count >= 2) {
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
@@ -1532,41 +1496,39 @@ export class RalphTracker extends EventEmitter {
const suggestedPhrase = `${phrase}_${uniqueSuffix}`;
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'common',
suggestedPhrase,
});
this.emitValidationWarning(phrase, 'common', suggestedPhrase);
return;
}
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'short',
suggestedPhrase,
});
this.emitValidationWarning(phrase, 'short', suggestedPhrase);
return;
}
if (/^\d+$/.test(normalized)) {
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'numeric',
suggestedPhrase,
});
this.emitValidationWarning(phrase, 'numeric', suggestedPhrase);
}
}
/**
* Emit a phrase validation warning with a console message and event.
*/
private emitValidationWarning(phrase: string, reason: 'common' | 'short' | 'numeric', suggestedPhrase: string): void {
const descriptions: Record<'common' | 'short' | 'numeric', string> = {
common: 'is very common and may cause false positives',
short: `is too short (${phrase.toUpperCase().replace(/[\s_\-.]+/g, '').length} chars)`,
numeric: 'is numeric-only and may cause false positives',
};
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" ${descriptions[reason]}. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason,
suggestedPhrase,
});
}
/**
* Activate the loop if not already active.
*/
@@ -1977,9 +1939,9 @@ export class RalphTracker extends EventEmitter {
let threshold: number;
if (normalized.length < 30) {
threshold = 0.95;
threshold = SIMILARITY_THRESHOLD_SHORT;
} else if (normalized.length < 60) {
threshold = 0.9;
threshold = SIMILARITY_THRESHOLD_MEDIUM;
} else {
threshold = TODO_SIMILARITY_THRESHOLD;
}
+268 -251
View File
@@ -27,7 +27,7 @@
* - `RespawnController` class — state machine, extends EventEmitter
* - `RespawnConfig` interface — all configuration options
* - `RespawnState` type — union of all state machine states
* - `DetectionStatus`, `ActiveTimerInfo`, `RespawnEvents` — status/event types
* - `DetectionStatus`, `ActiveTimerInfo` — status types
*
* Key methods: `start()`, `stop()`, `getStatus()`, `getConfig()`,
* `getDetectionStatus()`, `getActiveTimers()`, `getAggregateMetrics()`,
@@ -46,8 +46,8 @@
import { EventEmitter } from 'node:events';
import { randomUUID } from 'node:crypto';
import { Session } from './session.js';
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
@@ -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 ==========
@@ -98,13 +99,13 @@ const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./;
* Each layer provides a confidence signal that Claude has finished working.
*/
/** Active timer info for UI display */
export interface ActiveTimerInfo {
interface ActiveTimerInfo {
name: string;
remainingMs: number;
totalMs: number;
}
export interface DetectionStatus {
interface DetectionStatus {
/** Layer 0: Stop hook received (highest priority - definitive signal) */
stopHookReceived: boolean;
/** Timestamp when Stop hook was received */
@@ -487,68 +488,19 @@ export interface RespawnConfig {
* @event error - Fired on errors
* @event log - Fired for debug logging
*/
/** Timer info for countdown display */
export interface TimerInfo {
name: string;
durationMs: number;
endsAt: number;
reason?: string;
}
/** Action log entry for detailed UI feedback */
export interface ActionLogEntry {
interface ActionLogEntry {
type: string;
detail: string;
timestamp: number;
}
export interface RespawnEvents {
/** State machine transition */
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
/** New respawn cycle started */
respawnCycleStarted: (cycleNumber: number) => void;
/** Respawn cycle finished */
respawnCycleCompleted: (cycleNumber: number) => void;
/** Command sent to session */
stepSent: (step: string, input: string) => void;
/** Step completed (ready indicator detected) */
stepCompleted: (step: string) => void;
/** Detection status update for UI display */
detectionUpdate: (status: DetectionStatus) => void;
/** Auto-accept sent for plan mode approval */
autoAcceptSent: () => void;
/** AI idle check started */
aiCheckStarted: () => void;
/** AI idle check completed with verdict */
aiCheckCompleted: (result: AiCheckResult) => void;
/** AI idle check failed */
aiCheckFailed: (error: string) => void;
/** AI idle check cooldown state changed */
aiCheckCooldown: (active: boolean, endsAt: number | null) => void;
/** AI plan check started */
planCheckStarted: () => void;
/** AI plan check completed with verdict */
planCheckCompleted: (result: AiPlanCheckResult) => void;
/** AI plan check failed */
planCheckFailed: (error: string) => void;
/** Timer started for countdown display */
timerStarted: (timer: TimerInfo) => void;
/** Timer cancelled */
timerCancelled: (timerName: string, reason?: string) => void;
/** Timer completed */
timerCompleted: (timerName: string) => void;
/** Verbose action log for detailed UI feedback */
actionLog: (action: ActionLogEntry) => void;
/** Error occurred */
error: (error: Error) => void;
/** Debug log message */
log: (message: string) => void;
/** Stuck state warning emitted */
stuckStateWarning: (state: RespawnState, durationMs: number) => void;
/** Stuck state recovery triggered */
stuckStateRecovery: (state: RespawnState, durationMs: number, attempt: number) => void;
/** Respawn blocked by external signal */
respawnBlocked: (data: { reason: string; details: string }) => void;
/**
* Convert milliseconds to a non-negative whole number of seconds for countdown display.
* Rounds up so that e.g. 1200 ms shows as 2 s (never under-reports remaining time).
*/
function formatRemainingSeconds(ms: number): number {
return Math.max(0, Math.ceil(ms / 1000));
}
/** Default configuration values */
@@ -839,27 +791,42 @@ export class RespawnController extends EventEmitter {
private validateConfig(): void {
const c = this.config;
// Ensure timeouts are positive
if (c.idleTimeoutMs <= 0) c.idleTimeoutMs = DEFAULT_CONFIG.idleTimeoutMs;
if (c.completionConfirmMs <= 0) c.completionConfirmMs = DEFAULT_CONFIG.completionConfirmMs;
if (c.noOutputTimeoutMs <= 0) c.noOutputTimeoutMs = DEFAULT_CONFIG.noOutputTimeoutMs;
if (c.autoAcceptDelayMs < 0) c.autoAcceptDelayMs = DEFAULT_CONFIG.autoAcceptDelayMs;
if (c.interStepDelayMs <= 0) c.interStepDelayMs = DEFAULT_CONFIG.interStepDelayMs;
/**
* Validate that a timeout value is positive (or non-negative when allowZero is true).
* Falls back to the DEFAULT_CONFIG value if invalid.
*/
const validatePositiveTimeout = (field: keyof RespawnConfig, allowZero = false): void => {
const value = c[field] as number;
const invalid = allowZero ? value < 0 : value <= 0;
if (invalid) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(c as any)[field] = DEFAULT_CONFIG[field];
}
};
const REQUIRED_TIMEOUT_FIELDS = [
'idleTimeoutMs',
'completionConfirmMs',
'noOutputTimeoutMs',
'interStepDelayMs',
'aiIdleCheckTimeoutMs',
'aiIdleCheckMaxContext',
'aiPlanCheckTimeoutMs',
'aiPlanCheckMaxContext',
] as const;
for (const field of REQUIRED_TIMEOUT_FIELDS) {
validatePositiveTimeout(field);
}
const ALLOW_ZERO_FIELDS = ['autoAcceptDelayMs', 'aiIdleCheckCooldownMs', 'aiPlanCheckCooldownMs'] as const;
for (const field of ALLOW_ZERO_FIELDS) {
validatePositiveTimeout(field, true);
}
// Ensure completion confirm doesn't exceed no-output timeout
if (c.completionConfirmMs > c.noOutputTimeoutMs) {
c.completionConfirmMs = c.noOutputTimeoutMs;
}
// Ensure AI check timeouts are positive
if (c.aiIdleCheckTimeoutMs <= 0) c.aiIdleCheckTimeoutMs = DEFAULT_CONFIG.aiIdleCheckTimeoutMs;
if (c.aiIdleCheckCooldownMs < 0) c.aiIdleCheckCooldownMs = DEFAULT_CONFIG.aiIdleCheckCooldownMs;
if (c.aiIdleCheckMaxContext <= 0) c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext;
// Ensure plan check timeouts are positive
if (c.aiPlanCheckTimeoutMs <= 0) c.aiPlanCheckTimeoutMs = DEFAULT_CONFIG.aiPlanCheckTimeoutMs;
if (c.aiPlanCheckCooldownMs < 0) c.aiPlanCheckCooldownMs = DEFAULT_CONFIG.aiPlanCheckCooldownMs;
if (c.aiPlanCheckMaxContext <= 0) c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext;
}
/** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */
@@ -987,11 +954,11 @@ export class RespawnController extends EventEmitter {
waitingFor = 'AI verdict (IDLE or WORKING)';
} else if (this._state === 'confirming_idle') {
statusText = `Confirming idle (${confidence}% confidence)`;
waitingFor = `${Math.max(0, Math.ceil((this.config.completionConfirmMs - msSinceLastOutput) / 1000))}s more silence`;
waitingFor = `${formatRemainingSeconds(this.config.completionConfirmMs - msSinceLastOutput)}s more silence`;
} else if (this._state === 'watching') {
const aiState = this.aiChecker.getState();
if (aiState.status === 'cooldown') {
const remaining = Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000);
const remaining = formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs());
statusText = `AI Check: WORKING (cooldown ${remaining}s)`;
waitingFor = 'Cooldown to expire';
} else if (completionMessageDetected) {
@@ -1364,98 +1331,13 @@ export class RespawnController extends EventEmitter {
this.lastTokenChangeTime = now;
}
// Detect completion message FIRST (Layer 1) - PRIMARY DETECTION
// Check this before working patterns because completion message indicates
// the work is done, even if working patterns are still in the rolling window
if (isCompletionMessage(data)) {
// Clear the rolling window - completion marks a transition point
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// Layer 1: Completion message (PRIMARY) — checked before working patterns
if (this._detectCompletionMessage(data, now)) return;
// In watching state, start completion confirmation timer
if (this._state === 'watching') {
this.startCompletionConfirmTimer();
return;
}
// Layer 4: Working patterns
if (this._detectWorkingPattern(data, now)) return;
// In waiting states, also use confirmation timer (same detection logic)
// This ensures we wait for Claude to finish before proceeding
// Note: 'watching' is already handled above and returns early
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: completion message is ignored
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'monitoring_init':
case 'sending_kickstart':
case 'stopped':
// Completion message during these states is ignored
break;
default:
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
}
return;
}
// Detect working patterns (Layer 4)
const isWorking = this.checkWorkingPattern(data);
if (isWorking) {
this.workingDetected = true;
this.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now;
// Cancel hook confirmation timer if running
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
// Cancel any pending completion confirmation
this.cancelCompletionConfirm();
// Cancel any pending step confirmation (Claude is still working)
this.cancelStepConfirm();
// If AI check is running, cancel it (Claude is working)
if (this._state === 'ai_checking') {
this.log('Working patterns detected during AI check, cancelling');
this.aiChecker.cancel();
this.setState('watching');
}
// Cancel plan check if running (Claude started working)
if (this.planChecker.status === 'checking') {
this.log('Working patterns detected during plan check, cancelling');
this.planChecker.cancel();
}
// If we're monitoring init and work started, go to watching (no kickstart needed)
if (this._state === 'monitoring_init') {
this.log('/init triggered work, skipping kickstart');
this.emit('stepCompleted', 'init');
this.completeCycle();
}
return;
}
// In confirming_idle or ai_checking state, substantial output cancels the flow.
// This prevents false triggers when Claude pauses briefly mid-work.
// Substantial output during confirming_idle/ai_checking cancels the flow
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
// Strip ANSI escape codes to check if there's real content
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0;
@@ -1476,43 +1358,137 @@ export class RespawnController extends EventEmitter {
}
}
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
if (hasPrompt) {
this.promptDetected = true;
this.workingDetected = false;
// Legacy fallback: prompt detection
this._detectPrompt(data);
}
// Handle legacy detection in waiting states - also use confirmation timers
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'monitoring_init':
this.checkMonitoringInitIdle();
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: prompt detection is informational only
case 'watching':
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'sending_kickstart':
case 'stopped':
// Prompt detection during these states doesn't trigger action
break;
default:
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
}
private _detectCompletionMessage(data: string, now: number): boolean {
if (!isCompletionMessage(data)) return false;
// Clear the rolling window - completion marks a transition point
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// In watching state, start completion confirmation timer
if (this._state === 'watching') {
this.startCompletionConfirmTimer();
return true;
}
// In waiting states, also use confirmation timer (same detection logic)
// This ensures we wait for Claude to finish before proceeding
// Note: 'watching' is already handled above and returns early
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: completion message is ignored
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'monitoring_init':
case 'sending_kickstart':
case 'stopped':
// Completion message during these states is ignored
break;
default:
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
}
return true;
}
private _detectWorkingPattern(data: string, now: number): boolean {
const isWorking = this.checkWorkingPattern(data);
if (!isWorking) return false;
this.workingDetected = true;
this.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now;
// Cancel hook confirmation timer if running
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
// Cancel any pending completion confirmation
this.cancelCompletionConfirm();
// Cancel any pending step confirmation (Claude is still working)
this.cancelStepConfirm();
// If AI check is running, cancel it (Claude is working)
if (this._state === 'ai_checking') {
this.log('Working patterns detected during AI check, cancelling');
this.aiChecker.cancel();
this.setState('watching');
}
// Cancel plan check if running (Claude started working)
if (this.planChecker.status === 'checking') {
this.log('Working patterns detected during plan check, cancelling');
this.planChecker.cancel();
}
// If we're monitoring init and work started, go to watching (no kickstart needed)
if (this._state === 'monitoring_init') {
this.log('/init triggered work, skipping kickstart');
this.emit('stepCompleted', 'init');
this.completeCycle();
}
return true;
}
private _detectPrompt(data: string): void {
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
if (!hasPrompt) return;
this.promptDetected = true;
this.workingDetected = false;
// Handle legacy detection in waiting states - also use confirmation timers
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'monitoring_init':
this.checkMonitoringInitIdle();
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: prompt detection is informational only
case 'watching':
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'sending_kickstart':
case 'stopped':
// Prompt detection during these states doesn't trigger action
break;
default:
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
}
}
@@ -1798,24 +1774,28 @@ export class RespawnController extends EventEmitter {
case 'sending_init':
case 'sending_kickstart':
// For sending states, retry the send
this.log('Recovery: returning to watching state');
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
this.recoveryResetToWatching('returning to watching state');
break;
default:
// Fallback: reset to watching
this.log('Recovery: fallback to watching state');
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
this.recoveryResetToWatching('fallback to watching state');
}
}
/**
* Reset the controller to watching state during stuck-state recovery.
* Sets state to watching and restarts all detection timers.
*
* @param reason - Human-readable reason for the reset (logged)
*/
private recoveryResetToWatching(reason: string): void {
this.log(`Recovery: ${reason}`);
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
}
@@ -2041,6 +2021,18 @@ export class RespawnController extends EventEmitter {
return;
}
// Check for active child processes (bash tools, test suites, builds, etc.)
// These may produce no terminal output, so restart timers to retry periodically.
const activeProcesses = this.session.getActiveChildProcesses();
if (activeProcesses.length > 0) {
const names = activeProcesses.map((p) => p.command).join(', ');
this.log(`Skipping AI check - ${activeProcesses.length} active child process(es): ${names}`);
this.logAction('detection', `Skipped AI check: child processes running (${names})`);
this.startNoOutputTimer();
this.startPreFilterTimer();
return;
}
// If AI check is disabled or errored out, fall back to direct idle confirmation
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
@@ -2051,7 +2043,7 @@ export class RespawnController extends EventEmitter {
// If on cooldown, don't start check - wait for cooldown to expire
if (this.aiChecker.isOnCooldown()) {
this.log(
`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`
`AI check on cooldown (${formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs())}s remaining), waiting...`
);
return;
}
@@ -2136,7 +2128,7 @@ export class RespawnController extends EventEmitter {
}
if (this._state === 'stopped') return; // Guard against stopped state
if (this._state === 'ai_checking') {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
this.emit('aiCheckFailed', errorMsg);
this.setState('watching');
@@ -2198,36 +2190,15 @@ export class RespawnController extends EventEmitter {
* @fires planCheckStarted
*/
private tryAutoAccept(): void {
// Only auto-accept in watching state (not during a respawn cycle)
if (this._state !== 'watching') return;
if (!this.canAutoAccept()) return;
// Don't auto-accept if a completion message was detected (normal idle handles it)
if (this.completionMessageTime !== null) return;
// Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return;
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
if (!this.hasReceivedOutput) return;
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
if (this.elicitationDetected) {
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
return;
}
// Stage 1: Pre-filter — check if buffer looks like plan mode
const buffer = this.terminalBuffer.value;
if (!this.isPlanModePreFilterMatch(buffer)) {
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
return;
}
// Stage 2: AI confirmation (if enabled and available)
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
if (this.planChecker.isOnCooldown()) {
this.log(
`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`
`Skipping auto-accept: plan checker on cooldown (${formatRemainingSeconds(this.planChecker.getCooldownRemainingMs())}s remaining)`
);
return;
}
@@ -2244,6 +2215,40 @@ export class RespawnController extends EventEmitter {
this.sendAutoAcceptEnter();
}
/**
* Check whether all preconditions for auto-accept are met.
* Validates state, config, and pre-filter conditions before attempting auto-accept.
*
* @returns True if auto-accept should proceed to the AI confirmation stage
*/
private canAutoAccept(): boolean {
// Only auto-accept in watching state (not during a respawn cycle)
if (this._state !== 'watching') return false;
// Don't auto-accept if a completion message was detected (normal idle handles it)
if (this.completionMessageTime !== null) return false;
// Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return false;
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
if (!this.hasReceivedOutput) return false;
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
if (this.elicitationDetected) {
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
return false;
}
// Stage 1: Pre-filter — check if buffer looks like plan mode
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
return false;
}
return true;
}
/**
* Check if the terminal buffer matches plan mode pre-filter patterns.
* Only checks the last 2000 chars (plan mode UI appears at the bottom).
@@ -2322,7 +2327,7 @@ export class RespawnController extends EventEmitter {
}
})
.catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
this.emit('planCheckFailed', errorMsg);
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
});
@@ -2632,6 +2637,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(
+99 -58
View File
@@ -27,6 +27,57 @@ const COMPACT_COOLDOWN_MS = 10000;
/** Cooldown after clear completes before re-enabling (5 seconds) */
const CLEAR_COOLDOWN_MS = 5000;
/**
* Executes an action when the session becomes idle, retrying if currently working.
*
* @param action - The async action to execute once idle
* @param isActive - Returns whether this operation is still active (not cancelled)
* @param isWorking - Returns whether the session is currently working
* @param isStopped - Returns whether the session has been stopped
* @param retryMs - Delay between retry attempts when working
* @param cooldownMs - Delay after action completes before calling onCooldownDone
* @param setTimer - Stores the timer reference for cleanup
* @param onCooldownDone - Called after cooldown to reset state
*/
async function executeWhenIdle(
action: () => Promise<void>,
isActive: () => boolean,
isWorking: () => boolean,
isStopped: () => boolean,
retryMs: number,
cooldownMs: number,
setTimer: (timer: NodeJS.Timeout | null) => void,
onCooldownDone: () => void
): Promise<void> {
if (isStopped()) return;
if (!isActive()) return;
if (!isWorking()) {
if (isStopped()) return;
await action();
if (!isStopped()) {
setTimer(
setTimeout(() => {
if (isStopped()) return;
setTimer(null);
onCooldownDone();
}, cooldownMs)
);
}
} else {
if (!isStopped()) {
setTimer(
setTimeout(
() => executeWhenIdle(action, isActive, isWorking, isStopped, retryMs, cooldownMs, setTimer, onCooldownDone),
retryMs
)
);
}
}
}
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
const MIN_AUTO_THRESHOLD = 1000;
@@ -42,7 +93,7 @@ const DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
/**
* Callbacks required by SessionAutoOps to interact with the parent Session.
*/
export interface AutoOpsCallbacks {
interface AutoOpsCallbacks {
/** Send a command via the terminal multiplexer */
writeCommand: (command: string) => Promise<boolean>;
/** Check if Claude is currently working */
@@ -58,12 +109,6 @@ export interface AutoOpsCallbacks {
/**
* Events emitted by SessionAutoOps.
*/
export interface SessionAutoOpsEvents {
/** Auto-compact was triggered and the /compact command was sent */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
/** Auto-clear was triggered and the /clear command was sent */
autoClear: (data: { tokens: number; threshold: number }) => void;
}
/**
* Manages auto-compact and auto-clear automation for a Session.
@@ -181,37 +226,35 @@ export class SessionAutoOps extends EventEmitter {
`[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`
);
const checkAndCompact = async () => {
if (this.callbacks.isStopped()) return;
if (!this._isCompacting) return;
if (!this.callbacks.isWorking()) {
if (this.callbacks.isStopped()) return;
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
await this.callbacks.writeCommand(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined,
});
if (!this.callbacks.isStopped()) {
this._autoCompactTimer = setTimeout(() => {
if (this.callbacks.isStopped()) return;
this._autoCompactTimer = null;
this._isCompacting = false;
}, COMPACT_COOLDOWN_MS);
}
} else {
if (!this.callbacks.isStopped()) {
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS);
}
}
const action = async () => {
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
await this.callbacks.writeCommand(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined,
});
};
if (!this.callbacks.isStopped()) {
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS);
this._autoCompactTimer = setTimeout(
() =>
executeWhenIdle(
action,
() => this._isCompacting,
() => this.callbacks.isWorking(),
() => this.callbacks.isStopped(),
AUTO_RETRY_DELAY_MS,
COMPACT_COOLDOWN_MS,
(timer) => {
this._autoCompactTimer = timer;
},
() => {
this._isCompacting = false;
}
),
AUTO_INITIAL_DELAY_MS
);
}
}
}
@@ -231,32 +274,30 @@ export class SessionAutoOps extends EventEmitter {
`[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`
);
const checkAndClear = async () => {
if (this.callbacks.isStopped()) return;
if (!this._isClearing) return;
if (!this.callbacks.isWorking()) {
if (this.callbacks.isStopped()) return;
await this.callbacks.writeCommand('/clear\r');
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
if (!this.callbacks.isStopped()) {
this._autoClearTimer = setTimeout(() => {
if (this.callbacks.isStopped()) return;
this._autoClearTimer = null;
this._isClearing = false;
}, CLEAR_COOLDOWN_MS);
}
} else {
if (!this.callbacks.isStopped()) {
this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS);
}
}
const action = async () => {
await this.callbacks.writeCommand('/clear\r');
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
};
if (!this.callbacks.isStopped()) {
this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS);
this._autoClearTimer = setTimeout(
() =>
executeWhenIdle(
action,
() => this._isClearing,
() => this.callbacks.isWorking(),
() => this.callbacks.isStopped(),
AUTO_RETRY_DELAY_MS,
CLEAR_COOLDOWN_MS,
(timer) => {
this._autoClearTimer = timer;
},
() => {
this._isClearing = false;
}
),
AUTO_INITIAL_DELAY_MS
);
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { getAugmentedPath } from './utils/index.js';
* Build Claude CLI permission flags based on the configured mode.
* Returns an array of args to pass to the CLI.
*/
export function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
switch (claudeMode) {
case 'dangerously-skip-permissions':
return ['--dangerously-skip-permissions'];
+10 -14
View File
@@ -30,18 +30,6 @@ import { SessionState } from './types.js';
/**
* Events emitted by SessionManager
*/
export interface SessionManagerEvents {
/** Fired when a new session starts successfully */
sessionStarted: (session: Session) => void;
/** Fired when a session stops (graceful or forced) */
sessionStopped: (sessionId: string) => void;
/** Fired when a session encounters an error */
sessionError: (sessionId: string, error: string) => void;
/** Fired when a session produces terminal output */
sessionOutput: (sessionId: string, output: string) => void;
/** Fired when a completion phrase is detected */
sessionCompletion: (sessionId: string, phrase: string) => void;
}
/**
* Manages multiple Claude sessions with lifecycle coordination.
@@ -164,7 +152,7 @@ export class SessionManager extends EventEmitter {
await session.start();
this.sessions.set(session.id, session);
this.store.setSession(session.id, session.toState());
this.updateSessionState(session);
this.emit('sessionStarted', session);
return session;
@@ -259,7 +247,15 @@ export class SessionManager extends EventEmitter {
}
private updateSessionState(session: Session): void {
this.store.setSession(session.id, session.toState());
// envOverrides is intentionally NOT on SessionState (API safety). For disk
// persistence we augment the stored object with __envOverrides so reboot
// recovery can restore them without leaking through any API serializer.
// The key uses the reserved `__` prefix so it is visibly "internal" to any
// future reader of state.json.
const state = session.toState();
const envOverrides = session.getEnvOverridesForPersist();
const toStore = envOverrides ? { ...state, __envOverrides: envOverrides } : state;
this.store.setSession(session.id, toStore as SessionState);
}
/** Gets all sessions from persistent storage (including stopped). */
+319 -301
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,
@@ -151,63 +154,6 @@ export interface ClaudeMessage {
* Event signatures emitted by the Session class.
* Subscribe using `session.on('eventName', handler)`.
*/
export interface SessionEvents {
/** Processed text output (ANSI stripped) */
output: (data: string) => void;
/** Parsed JSON message from Claude CLI */
message: (msg: ClaudeMessage) => void;
/** Error output from the session */
error: (data: string) => void;
/** Session process exited */
exit: (code: number | null) => void;
/** One-shot prompt completed with result and cost */
completion: (result: string, cost: number) => void;
/** Raw terminal data (includes ANSI codes) */
terminal: (data: string) => void;
/** Signal to clear terminal display (after mux attach) */
clearTerminal: () => void;
/** New background task started */
taskCreated: (task: BackgroundTask) => void;
/** Background task status changed */
taskUpdated: (task: BackgroundTask) => void;
/** Background task finished successfully */
taskCompleted: (task: BackgroundTask) => void;
/** Background task failed with error */
taskFailed: (task: BackgroundTask, error: string) => void;
/** Auto-clear triggered due to token threshold */
autoClear: (data: { tokens: number; threshold: number }) => void;
/** Auto-compact triggered due to token threshold */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
/** Ralph loop state changed */
ralphLoopUpdate: (state: RalphTrackerState) => void;
/** Ralph todo list updated */
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
/** Ralph completion phrase detected */
ralphCompletionDetected: (phrase: string) => void;
/** RALPH_STATUS block detected */
ralphStatusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void;
/** Circuit breaker state changed */
ralphCircuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void;
/** Dual-condition exit gate met */
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
/** Bash tool with file paths started */
bashToolStart: (tool: ActiveBashTool) => void;
/** Bash tool completed */
bashToolEnd: (tool: ActiveBashTool) => void;
/** Active Bash tools list updated */
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
/** CLI info (version, model, account) updated */
cliInfoUpdated: (info: {
version: string | null;
model: string | null;
accountType: string | null;
latestVersion: string | null;
}) => void;
}
// SessionMode is imported from types.ts (single source of truth)
// Re-export for backwards compatibility with any external consumers
export type { SessionMode } from './types.js';
/**
* Core session class that wraps a PTY process running Claude CLI or a shell.
@@ -271,6 +217,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
@@ -326,6 +273,10 @@ export class Session extends EventEmitter {
private _openCodeConfig: OpenCodeConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL). Exported by tmux at spawn,
// preserved across respawns via persisted state. Not written to .claude/settings.local.json.
private _envOverrides: Record<string, string> | undefined;
// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';
@@ -385,6 +336,8 @@ export class Session extends EventEmitter {
openCodeConfig?: OpenCodeConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
envOverrides?: Record<string, string>;
}
) {
super();
@@ -432,6 +385,11 @@ export class Session extends EventEmitter {
this._openCodeConfig = config.openCodeConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk)
if (config.envOverrides && Object.keys(config.envOverrides).length > 0) {
this._envOverrides = { ...config.envOverrides };
}
// Initialize task tracker and forward events (store handlers for cleanup)
this._taskTracker = new TaskTracker();
this._taskTrackerHandlers = {
@@ -526,6 +484,20 @@ export class Session extends EventEmitter {
return this._claudeSessionId;
}
// Adopt a Claude conversation ID observed from an external source (e.g. hook
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
// that conveys a post-/clear conversation switch.
adoptClaudeSessionId(newId: string): void {
if (!newId || newId === this._claudeSessionId) return;
this._claudeSessionId = newId;
}
/** 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 +510,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;
}
@@ -794,9 +809,29 @@ export class Session extends EventEmitter {
cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
// envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls
// getEnvOverridesForPersist() and writes alongside state.
};
}
/**
* Returns a subset of env overrides safe for disk persistence (state.json).
* Only non-sensitive `CLAUDE_CODE_*` keys are included. `OPENCODE_*` keys are
* filtered out because the schema permits them and they can carry secrets
* (e.g., OPENCODE_API_KEY); secrets must not land in `~/.codeman/state.json`.
* Must NOT be included in any API-bound serializer — see toState() comment.
*/
getEnvOverridesForPersist(): Record<string, string> | undefined {
if (!this._envOverrides) return undefined;
const safe: Record<string, string> = {};
for (const [key, value] of Object.entries(this._envOverrides)) {
if (key.startsWith('CLAUDE_CODE_')) safe[key] = value;
}
return Object.keys(safe).length > 0 ? safe : undefined;
}
toDetailedState() {
return {
...this.toLightDetailedState(),
@@ -871,18 +906,77 @@ export class Session extends EventEmitter {
* session.write('help me with this code\r');
* ```
*/
private async _setupOrAttachMuxSession(options: {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean }> {
const mux = this._mux!;
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
let needsNewSession = false;
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await mux.respawnPane(options.respawnPaneOptions);
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
// Wait a moment for the respawned process to fully start
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
// Check if we already have a mux session (restored session)
const isRestored = this._muxSession !== null && !needsNewSession;
if (isRestored) {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else {
// Create a new mux session
this._muxSession = await mux.createSession(options.createSessionOptions);
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
}
// Attach to the mux session via PTY
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildMuxAttachEnv(),
});
} catch (spawnErr) {
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
throw spawnErr;
}
return { isRestored };
}
private _handleTerminalOutput(data: string): void {
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
}
async startInteractive(): Promise<void> {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._resetBuffers();
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
console.log(
@@ -892,18 +986,8 @@ export class Session extends EventEmitter {
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
let needsNewSession = false;
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await this._mux.respawnPane({
const { isRestored } = await this._setupOrAttachMuxSession({
respawnPaneOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
@@ -913,23 +997,9 @@ 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({
envOverrides: this._envOverrides,
},
createSessionOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
@@ -940,36 +1010,17 @@ 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
}
envOverrides: this._envOverrides,
},
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.
@@ -1035,7 +1086,8 @@ export class Session extends EventEmitter {
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildClaudeEnv(this.id),
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
});
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
@@ -1056,12 +1108,17 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
if (!data) return; // Skip if only filtered sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this._handleTerminalOutput(data);
this.emit('terminal', data);
this.emit('output', data);
// === Auto-accept workspace trust dialog ===
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
this._trustDialogAccepted = true;
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
// Send Enter to accept the default selection ("Yes, I trust this folder")
this.writeViaMux('\r');
}
// === Idle/working detection runs on every chunk (latency-sensitive) ===
// Detect if Claude is working or at prompt
@@ -1257,13 +1314,7 @@ export class Session extends EventEmitter {
throw new Error('Session already has a running process');
}
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._resetBuffers();
// Use user's default shell or bash
const shell = process.env.SHELL || '/bin/bash';
@@ -1275,69 +1326,28 @@ 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({
envOverrides: this._envOverrides,
},
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;
}
envOverrides: this._envOverrides,
},
spawnErrLabel: 'shell mux attachment',
});
// For NEW sessions: clear by sending 'clear' command to the shell
// For RESTORED sessions: don't clear - we want to see the existing output
if (!isRestoredSession) {
if (!isRestored) {
setTimeout(() => {
if (this.ptyProcess) {
this._terminalBuffer.clear();
@@ -1378,12 +1388,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
this._handleTerminalOutput(data);
});
this.ptyProcess.onExit(({ exitCode }) => {
@@ -1448,13 +1453,7 @@ export class Session extends EventEmitter {
return;
}
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._resetBuffers();
this._promptResolved = false; // Reset race condition guard
this.resolvePromise = resolve;
@@ -1477,7 +1476,8 @@ export class Session extends EventEmitter {
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildClaudeEnv(this.id),
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
});
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
@@ -1497,12 +1497,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
this._handleTerminalOutput(data);
// Also try to parse JSON lines for structured data
this.processOutput(data);
@@ -1534,9 +1529,11 @@ export class Session extends EventEmitter {
this._status = 'idle';
const cost = resultMsg.total_cost_usd || 0;
this._totalCost += cost;
this.emit('completion', resultMsg.result || '', cost);
// Claude CLI stream-json may return empty result field — fall back to accumulated text output
const result = resultMsg.result || this._textOutput.value || '';
this.emit('completion', result, cost);
if (resolve) {
resolve({ result: resultMsg.result || '', cost });
resolve({ result, cost });
}
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
this._status = 'error';
@@ -1565,6 +1562,120 @@ export class Session extends EventEmitter {
});
}
private _resetBuffers(): void {
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
}
private _clearAllTimers(): void {
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
// Clear line buffer flush timer
if (this._lineBufferFlushTimer) {
clearTimeout(this._lineBufferFlushTimer);
this._lineBufferFlushTimer = null;
}
// Destroy auto-compact/auto-clear automation (clears its timers)
this._autoOps.destroy();
// Clear prompt check timers
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
if (this._promptCheckTimeout) {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// Clear shell idle timer
if (this._shellIdleTimer) {
clearTimeout(this._shellIdleTimer);
this._shellIdleTimer = null;
}
// Clear expensive processing timer
if (this._expensiveProcessTimer) {
clearTimeout(this._expensiveProcessTimer);
this._expensiveProcessTimer = null;
}
this._pendingCleanData = '';
}
private _handleJsonMessage(cleanLine: string, rawLine: string): void {
try {
const msg = JSON.parse(cleanLine) as ClaudeMessage;
this._messages.push(msg);
this.emit('message', msg);
// Trim messages array for long-running sessions
if (this._messages.length > MAX_MESSAGES) {
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
}
// Extract Claude session ID from messages (can be in any message type).
// Support both sessionId (camelCase) and session_id (snake_case).
// The constructor seeds _claudeSessionId with this.id as a placeholder;
// once Claude CLI emits its real session ID, adopt it so JSONL lookups
// (e.g. /api/sessions/:id/last-response) can find the transcript file.
const msgSessionId =
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && msgSessionId !== this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
// Process message for task tracking
this._taskTracker.processMessage(msg);
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
this._textOutput.append(block.text);
}
}
// Track tokens from usage (with validation)
if (msg.message.usage) {
const inputDelta = msg.message.usage.input_tokens || 0;
const outputDelta = msg.message.usage.output_tokens || 0;
// Sanity check: max 100k tokens per message (generous limit)
const MAX_TOKENS_PER_MESSAGE = 100_000;
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalInputTokens += inputDelta;
}
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalOutputTokens += outputDelta;
}
// Check if we should auto-compact or auto-clear
this._autoOps.checkAutoCompact();
this._autoOps.checkAutoClear();
}
}
if (msg.type === 'result' && msg.total_cost_usd) {
this._totalCost = msg.total_cost_usd;
}
} catch (parseErr) {
// Not JSON, just regular output - this is expected for non-JSON lines
console.debug(
'[Session] Line not JSON (expected for text output):',
parseErr instanceof Error ? parseErr.message : parseErr
);
this._textOutput.append(rawLine + '\n');
}
}
private processOutput(data: string): void {
// Early return if session is stopped to prevent any processing or timer creation
if (this._isStopped) return;
@@ -1606,64 +1717,7 @@ export class Session extends EventEmitter {
const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, '');
if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) {
try {
const msg = JSON.parse(cleanLine) as ClaudeMessage;
this._messages.push(msg);
this.emit('message', msg);
// Trim messages array for long-running sessions
if (this._messages.length > MAX_MESSAGES) {
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
}
// Extract Claude session ID from messages (can be in any message type)
// Support both sessionId (camelCase) and session_id (snake_case)
const msgSessionId =
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
// Process message for task tracking
this._taskTracker.processMessage(msg);
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
this._textOutput.append(block.text);
}
}
// Track tokens from usage (with validation)
if (msg.message.usage) {
const inputDelta = msg.message.usage.input_tokens || 0;
const outputDelta = msg.message.usage.output_tokens || 0;
// Sanity check: max 100k tokens per message (generous limit)
const MAX_TOKENS_PER_MESSAGE = 100_000;
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalInputTokens += inputDelta;
}
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalOutputTokens += outputDelta;
}
// Check if we should auto-compact or auto-clear
this._autoOps.checkAutoCompact();
this._autoOps.checkAutoClear();
}
}
if (msg.type === 'result' && msg.total_cost_usd) {
this._totalCost = msg.total_cost_usd;
}
} catch (parseErr) {
// Not JSON, just regular output - this is expected for non-JSON lines
console.debug(
'[Session] Line not JSON (expected for text output):',
parseErr instanceof Error ? parseErr.message : parseErr
);
this._textOutput.append(line + '\n');
}
this._handleJsonMessage(cleanLine, line);
} else if (trimmed) {
this._textOutput.append(line + '\n');
}
@@ -1955,7 +2009,7 @@ export class Session extends EventEmitter {
this._status = 'busy';
this._lastActivityAt = Date.now();
this.runPrompt(input).catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
// Clean up task state so the task queue doesn't get stuck
if (this._currentTaskId) {
const taskId = this._currentTaskId;
@@ -2030,43 +2084,7 @@ export class Session extends EventEmitter {
// Set stopped flag first to prevent new timers from being created
this._isStopped = true;
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
// Clear line buffer flush timer
if (this._lineBufferFlushTimer) {
clearTimeout(this._lineBufferFlushTimer);
this._lineBufferFlushTimer = null;
}
// Destroy auto-compact/auto-clear automation (clears its timers)
this._autoOps.destroy();
// Clear prompt check timers
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
if (this._promptCheckTimeout) {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// Clear shell idle timer
if (this._shellIdleTimer) {
clearTimeout(this._shellIdleTimer);
this._shellIdleTimer = null;
}
// Clear expensive processing timer
if (this._expensiveProcessTimer) {
clearTimeout(this._expensiveProcessTimer);
this._expensiveProcessTimer = null;
}
this._pendingCleanData = '';
this._clearAllTimers();
// Immediately cleanup Promise callbacks to prevent orphaned references
// during the rest of stop() processing (e.g., if mux kill times out)
+74 -80
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}`);
}
@@ -195,16 +207,7 @@ export class StateStore {
* Only dirty sessions are re-serialized; clean sessions use cached JSON fragments.
*/
private assembleStateJson(): string {
// Re-serialize dirty sessions and update cache
for (const id of this.dirtySessions) {
const session = this.state.sessions[id];
if (session) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
} else {
this.cachedSessionJsons.delete(id);
}
}
this.dirtySessions.clear();
this.updateDirtySessionCache();
// Build sessions object from cached fragments
const sessionParts: string[] = [];
@@ -218,6 +221,25 @@ export class StateStore {
sessionParts.push(`${JSON.stringify(id)}:${json}`);
}
this.pruneStaleCacheEntries();
return this.buildPartialJson(sessionParts);
}
private updateDirtySessionCache(): void {
// Re-serialize dirty sessions and update cache
for (const id of this.dirtySessions) {
const session = this.state.sessions[id];
if (session) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
} else {
this.cachedSessionJsons.delete(id);
}
}
this.dirtySessions.clear();
}
private pruneStaleCacheEntries(): void {
// Prune stale cache entries (sessions removed via direct state mutation)
if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) {
for (const cachedId of this.cachedSessionJsons.keys()) {
@@ -226,7 +248,9 @@ export class StateStore {
}
}
}
}
private buildPartialJson(sessionParts: string[]): string {
// Build final JSON: sessions from cache, everything else re-serialized (tiny)
const sessionsJson = `{${sessionParts.join(',')}}`;
@@ -249,6 +273,28 @@ export class StateStore {
return `{${parts.join(',')}}`;
}
private serializeState(): string | null {
try {
return this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
return JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return null;
}
}
}
private async _doSaveAsync(): Promise<void> {
this.saveDeb.cancel();
if (!this.dirty) {
@@ -263,30 +309,12 @@ export class StateStore {
this.ensureDir();
const tempPath = this.filePath + '.tmp';
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
const backupPath = this.filePath + '.bak';
let json: string;
// Step 1: Serialize state (validates it's JSON-safe)
try {
json = this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return;
}
}
const json = this.serializeState();
if (json === null) return;
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
// The state snapshot is already captured in `json` above.
@@ -305,11 +333,7 @@ export class StateStore {
await writeFile(tempPath, json, 'utf-8');
await rename(tempPath, this.filePath);
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
this._resetCircuitBreaker();
} catch (err) {
console.error('[StateStore] Failed to write state file:', err);
// Re-mark dirty so the data is retried on the next save cycle
@@ -349,29 +373,11 @@ export class StateStore {
this.ensureDir();
const tempPath = this.filePath + '.tmp';
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
const backupPath = this.filePath + '.bak';
let json: string;
try {
json = this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return;
}
}
const json = this.serializeState();
if (json === null) return;
// Backup via atomic copy (avoids reading entire file into memory)
try {
@@ -387,11 +393,7 @@ export class StateStore {
renameSync(tempPath, this.filePath);
// Clear dirty flag only AFTER successful write
this.dirty = false;
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
this._resetCircuitBreaker();
} catch (err) {
console.error('[StateStore] Failed to write state file:', err);
this.consecutiveSaveFailures++;
@@ -417,15 +419,7 @@ export class StateStore {
if (existsSync(backupPath)) {
const backupContent = readFileSync(backupPath, 'utf-8');
const parsed = JSON.parse(backupContent) as Partial<AppState>;
const initial = createInitialState();
this.state = {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
this.state = this._mergeWithInitialState(parsed);
console.log('[StateStore] Successfully recovered state from backup');
// Reset circuit breaker after successful recovery
this.circuitBreakerOpen = false;
+253 -228
View File
@@ -134,17 +134,6 @@ export interface SubagentToolResult {
isError: boolean; // Whether result is an error
}
export interface SubagentEvents {
'subagent:discovered': (info: SubagentInfo) => void;
'subagent:updated': (info: SubagentInfo) => void;
'subagent:tool_call': (data: SubagentToolCall) => void;
'subagent:tool_result': (data: SubagentToolResult) => void;
'subagent:progress': (data: SubagentProgress) => void;
'subagent:message': (data: SubagentMessage) => void;
'subagent:completed': (info: SubagentInfo) => void;
'subagent:error': (error: Error, agentId?: string) => void;
}
// ========== Constants ==========
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects');
@@ -222,6 +211,83 @@ export class SubagentWatcher extends EventEmitter {
return INTERNAL_AGENT_PATTERNS.some((pattern) => pattern.test(description));
}
/**
* Mark a subagent as completed: clear PID, set status, clean up pending tool calls, emit event.
*/
private markSubagentAsCompleted(info: SubagentInfo): void {
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
}
/**
* Extract text from message content, handling both string and array formats.
* For array content, returns the text from the first 'text' block.
*/
private extractFirstTextContent(
content: string | Array<{ type: string; text?: string }> | undefined
): string | undefined {
if (!content) return undefined;
if (typeof content === 'string') {
const trimmed = content.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(content)) {
const firstContent = content[0];
if (firstContent?.type === 'text' && firstContent.text) {
const trimmed = firstContent.text.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
}
return undefined;
}
/**
* Process a tool_result content block: look up pending tool call, emit tool_result event.
*/
private emitToolResult(
content: { tool_use_id: string; content?: string | Array<{ type: string; text?: string }>; is_error?: boolean },
agentId: string,
sessionId: string,
timestamp: string
): void {
const resultContent = this.extractToolResultContent(content.content);
const agentPendingCalls = this.pendingToolCalls.get(agentId);
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
const toolName = pendingCall?.toolName;
// Delete after lookup to prevent memory leak
agentPendingCalls?.delete(content.tool_use_id);
const toolResult: SubagentToolResult = {
agentId,
sessionId,
timestamp,
toolUseId: content.tool_use_id,
tool: toolName,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
};
this.emit('subagent:tool_result', toolResult);
}
/**
* Find the oldest inactive (non-active) agent for LRU eviction.
* Returns the agent ID of the oldest inactive agent, or null if all are active.
*/
private findOldestInactiveAgent(): string | null {
let oldestId: string | null = null;
let oldestTime = Infinity;
for (const [id, existing] of this.agentInfo) {
if (existing.status !== 'active' && existing.lastActivityAt < oldestTime) {
oldestTime = existing.lastActivityAt;
oldestId = id;
}
}
return oldestId;
}
/**
* Extract short model identifier from full model name
*/
@@ -307,10 +373,7 @@ export class SubagentWatcher extends EventEmitter {
const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
if (!alive) {
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
this.markSubagentAsCompleted(info);
}
}
}
@@ -677,10 +740,7 @@ export class SubagentWatcher extends EventEmitter {
const pid = await this.findSubagentProcess(info.sessionId);
if (pid) {
process.kill(pid, 'SIGTERM');
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
this.markSubagentAsCompleted(info);
return true;
}
} catch {
@@ -688,10 +748,7 @@ export class SubagentWatcher extends EventEmitter {
}
// Mark as completed even if we couldn't find the process
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
this.markSubagentAsCompleted(info);
return true;
}
@@ -843,19 +900,9 @@ export class SubagentWatcher extends EventEmitter {
}
} else if (entry.type === 'user' && entry.message?.content) {
// Handle both string and array content formats
if (typeof entry.message.content === 'string') {
const text = entry.message.content.trim();
if (text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
}
} else {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
const text = firstContent.text.trim();
if (text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
}
}
const text = this.extractFirstTextContent(entry.message.content);
if (text && text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
}
}
}
@@ -930,6 +977,24 @@ export class SubagentWatcher extends EventEmitter {
return truncated.replace(/[.!?,:\s]+$/, '');
}
private async _resolveDescription(
projectHash: string,
sessionId: string,
agentId: string,
filePath: string,
fallbackText?: string
): Promise<string | undefined> {
// First try parent transcript (most reliable)
const fromParent = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
if (fromParent) return fromParent;
// Fallback: inline text (from processEntry) or file extraction
if (fallbackText) {
return this.extractSmartTitle(fallbackText);
}
return this.extractDescriptionFromFile(filePath);
}
/**
* Extract the short description from the parent session's transcript.
* This is the most reliable method because it reads the actual Task tool result
@@ -1027,15 +1092,7 @@ export class SubagentWatcher extends EventEmitter {
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
let text: string | undefined;
if (typeof entry.message.content === 'string') {
text = entry.message.content.trim();
} else if (Array.isArray(entry.message.content)) {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
text = firstContent.text.trim();
}
}
const text = this.extractFirstTextContent(entry.message.content);
if (text) {
resolved = true;
rl.close();
@@ -1195,18 +1252,13 @@ export class SubagentWatcher extends EventEmitter {
// Retry description extraction if missing (race condition fix)
if (!existingInfo.description) {
// First try parent transcript (most reliable)
let extractedDescription = await this.extractDescriptionFromParentTranscript(
const extractedDescription = await this._resolveDescription(
existingInfo.projectHash,
existingInfo.sessionId,
agentId
agentId,
filePath
);
// Fallback to subagent file
if (!extractedDescription) {
extractedDescription = await this.extractDescriptionFromFile(filePath);
}
if (extractedDescription) {
// Check if this is an internal agent - if so, remove it
if (this.isInternalAgent(extractedDescription)) {
this.removeAgent(agentId);
return;
@@ -1257,13 +1309,7 @@ export class SubagentWatcher extends EventEmitter {
}
// Extract description - prefer reading from parent transcript (most reliable)
// The parent transcript has the exact Task tool call with description parameter
let description = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
// Fallback: extract a smart title from the subagent's prompt if parent lookup failed
if (!description) {
description = await this.extractDescriptionFromFile(filePath);
}
const description = await this._resolveDescription(projectHash, sessionId, agentId, filePath);
// Skip internal Claude Code agents (e.g., suggestion mode) - not real subagents
if (this.isInternalAgent(description)) {
@@ -1286,14 +1332,7 @@ export class SubagentWatcher extends EventEmitter {
// Enforce MAX_TRACKED_AGENTS during insertion — evict oldest inactive agent
if (this.agentInfo.size >= MAX_TRACKED_AGENTS) {
let oldestId: string | null = null;
let oldestTime = Infinity;
for (const [id, existing] of this.agentInfo) {
if (existing.status !== 'active' && existing.lastActivityAt < oldestTime) {
oldestTime = existing.lastActivityAt;
oldestId = id;
}
}
const oldestId = this.findOldestInactiveAgent();
if (oldestId) {
this.removeAgent(oldestId);
}
@@ -1363,51 +1402,11 @@ export class SubagentWatcher extends EventEmitter {
private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise<void> {
const info = this.agentInfo.get(agentId);
// Extract model from assistant messages (first one sets the model)
if (info && entry.type === 'assistant' && entry.message?.model && !info.model) {
info.model = entry.message.model;
info.modelShort = this.extractModelShort(entry.message.model);
this.emit('subagent:updated', info);
}
if (info) {
this._processModelInfo(entry, info);
this._processTokenInfo(entry, info);
// Aggregate token usage from messages
if (info && entry.message?.usage) {
if (entry.message.usage.input_tokens) {
info.totalInputTokens = (info.totalInputTokens || 0) + entry.message.usage.input_tokens;
}
if (entry.message.usage.output_tokens) {
info.totalOutputTokens = (info.totalOutputTokens || 0) + entry.message.usage.output_tokens;
}
}
// Check if this is first user message and description is missing
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
// First try parent transcript (most reliable)
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
// Fallback: extract smart title from the prompt content
if (!description) {
let text: string | undefined;
if (typeof entry.message.content === 'string') {
text = entry.message.content.trim();
} else if (Array.isArray(entry.message.content)) {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
text = firstContent.text.trim();
}
}
if (text) {
description = this.extractSmartTitle(text);
}
}
if (description) {
// Check if this is an internal agent - if so, remove it
if (this.isInternalAgent(description)) {
this.removeAgent(agentId);
return;
}
info.description = description;
this.emit('subagent:updated', info);
}
if (await this._processDescription(entry, agentId, info)) return;
}
if (entry.type === 'progress' && entry.data) {
@@ -1418,7 +1417,6 @@ export class SubagentWatcher extends EventEmitter {
progressType: entry.data.type,
query: entry.data.query,
resultCount: entry.data.resultCount,
// Extract hook event info if present
hookEvent: entry.data.hookEvent,
hookName:
entry.data.hookName ||
@@ -1428,139 +1426,166 @@ export class SubagentWatcher extends EventEmitter {
};
this.emit('subagent:progress', progress);
} else if (entry.type === 'assistant' && entry.message?.content) {
// Handle both string and array content formats
if (typeof entry.message.content === 'string') {
const text = entry.message.content.trim();
if (text.length > 0) {
const message: SubagentMessage = {
this._processAssistantContent(entry, agentId, sessionId);
} else if (entry.type === 'user' && entry.message?.content) {
this._processUserContent(entry, agentId, sessionId);
}
}
private _processModelInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
if (entry.type === 'assistant' && entry.message?.model && !agent.model) {
agent.model = entry.message.model;
agent.modelShort = this.extractModelShort(entry.message.model);
this.emit('subagent:updated', agent);
}
}
private _processTokenInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
if (!entry.message?.usage) return;
if (entry.message.usage.input_tokens) {
agent.totalInputTokens = (agent.totalInputTokens || 0) + entry.message.usage.input_tokens;
}
if (entry.message.usage.output_tokens) {
agent.totalOutputTokens = (agent.totalOutputTokens || 0) + entry.message.usage.output_tokens;
}
}
private async _processDescription(
entry: SubagentTranscriptEntry,
agentId: string,
agent: SubagentInfo
): Promise<boolean> {
if (agent.description || entry.type !== 'user' || !entry.message?.content) return false;
const fallbackText = this.extractFirstTextContent(entry.message.content);
const description = await this._resolveDescription(
agent.projectHash,
agent.sessionId,
agentId,
agent.filePath,
fallbackText
);
if (description) {
if (this.isInternalAgent(description)) {
this.removeAgent(agentId);
return true;
}
agent.description = description;
this.emit('subagent:updated', agent);
}
return false;
}
private _processAssistantContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
const messageContent = entry.message!.content;
if (typeof messageContent === 'string') {
const text = messageContent.trim();
if (text.length > 0) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT),
};
this.emit('subagent:message', message);
}
} else {
for (const content of messageContent) {
if (content.type === 'tool_use' && content.name) {
// Store toolUseId for linking to results, with timestamp for TTL cleanup
if (content.id) {
if (!this.pendingToolCalls.has(agentId)) {
this.pendingToolCalls.set(agentId, new Map());
}
const agentCalls = this.pendingToolCalls.get(agentId)!;
// Enforce size limit to prevent memory leak from rapid tool calls
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
// FIFO eviction: delete first (oldest) entry using Map insertion order
const firstKey = agentCalls.keys().next().value;
if (firstKey !== undefined) agentCalls.delete(firstKey);
}
agentCalls.set(content.id, {
toolName: content.name,
timestamp: Date.now(),
});
}
const toolCall: SubagentToolCall = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT),
tool: content.name,
input: this.getTruncatedInput(content.name, content.input || {}),
toolUseId: content.id,
fullInput: content.input || {},
};
this.emit('subagent:message', message);
}
} else {
for (const content of entry.message.content) {
if (content.type === 'tool_use' && content.name) {
// Store toolUseId for linking to results, with timestamp for TTL cleanup
if (content.id) {
if (!this.pendingToolCalls.has(agentId)) {
this.pendingToolCalls.set(agentId, new Map());
}
const agentCalls = this.pendingToolCalls.get(agentId)!;
// Enforce size limit to prevent memory leak from rapid tool calls
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
// FIFO eviction: delete first (oldest) entry using Map insertion order
const firstKey = agentCalls.keys().next().value;
if (firstKey !== undefined) agentCalls.delete(firstKey);
}
agentCalls.set(content.id, {
toolName: content.name,
timestamp: Date.now(),
});
}
this.emit('subagent:tool_call', toolCall);
const toolCall: SubagentToolCall = {
// Update tool call count
const agentInfo = this.agentInfo.get(agentId);
if (agentInfo) {
agentInfo.toolCallCount++;
}
} else if (content.type === 'tool_result' && content.tool_use_id) {
this.emitToolResult(
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
agentId,
sessionId,
entry.timestamp
);
} else if (content.type === 'text' && content.text) {
const text = content.text.trim();
if (text.length > 0) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
tool: content.name,
input: this.getTruncatedInput(content.name, content.input || {}),
toolUseId: content.id,
fullInput: content.input || {},
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT),
};
this.emit('subagent:tool_call', toolCall);
// Update tool call count
const agentInfo = this.agentInfo.get(agentId);
if (agentInfo) {
agentInfo.toolCallCount++;
}
} else if (content.type === 'tool_result' && content.tool_use_id) {
// Extract tool result
const resultContent = this.extractToolResultContent(content.content);
const agentPendingCalls = this.pendingToolCalls.get(agentId);
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
const toolName = pendingCall?.toolName;
// Delete after lookup to prevent memory leak
agentPendingCalls?.delete(content.tool_use_id);
const toolResult: SubagentToolResult = {
agentId,
sessionId,
timestamp: entry.timestamp,
toolUseId: content.tool_use_id,
tool: toolName,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
};
this.emit('subagent:tool_result', toolResult);
} else if (content.type === 'text' && content.text) {
const text = content.text.trim();
if (text.length > 0) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT), // Limit text length
};
this.emit('subagent:message', message);
}
this.emit('subagent:message', message);
}
}
}
} else if (entry.type === 'user' && entry.message?.content) {
// Handle both string and array content formats - also check for tool_result in user messages
if (typeof entry.message.content === 'string') {
const userText = entry.message.content.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
}
}
private _processUserContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
const messageContent = entry.message!.content;
if (typeof messageContent === 'string') {
const userText = messageContent.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
} else {
// Check for tool_result blocks in user messages (common pattern)
for (const content of messageContent) {
if (content.type === 'tool_result' && content.tool_use_id) {
this.emitToolResult(
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
} else {
// Check for tool_result blocks in user messages (common pattern)
for (const content of entry.message.content) {
if (content.type === 'tool_result' && content.tool_use_id) {
const resultContent = this.extractToolResultContent(content.content);
const agentPendingCalls = this.pendingToolCalls.get(agentId);
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
const toolName = pendingCall?.toolName;
// Delete after lookup to prevent memory leak
agentPendingCalls?.delete(content.tool_use_id);
const toolResult: SubagentToolResult = {
entry.timestamp
);
} else if (content.type === 'text' && content.text) {
const userText = content.text.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
toolUseId: content.tool_use_id,
tool: toolName,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
role: 'user',
text: userText,
};
this.emit('subagent:tool_result', toolResult);
} else if (content.type === 'text' && content.text) {
const userText = content.text.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
this.emit('subagent:message', message);
}
}
}
-8
View File
@@ -17,14 +17,6 @@ import { getStore } from './state-store.js';
/**
* Events emitted by TaskQueue
*/
export interface TaskQueueEvents {
/** Fired when a task is added to the queue */
taskAdded: (task: Task) => void;
/** Fired when a task is removed from the queue */
taskRemoved: (taskId: string) => void;
/** Fired when a task's state changes */
taskUpdated: (task: Task) => void;
}
/**
* Priority queue for managing tasks with dependency support.
-10
View File
@@ -153,16 +153,6 @@ export interface BackgroundTask {
* @event taskCompleted - Task finished successfully
* @event taskFailed - Task finished with error
*/
export interface TaskTrackerEvents {
/** New task created */
taskCreated: (task: BackgroundTask) => void;
/** Task state updated */
taskUpdated: (task: BackgroundTask) => void;
/** Task completed successfully */
taskCompleted: (task: BackgroundTask) => void;
/** Task failed with error */
taskFailed: (task: BackgroundTask, error: string) => void;
}
/**
* TaskTracker - Detects and tracks background tasks in Claude Code sessions.
+146 -80
View File
@@ -98,6 +98,47 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/**
* Separator used in `tmux list-panes -F` output between session name and pid.
*
* Must NOT be a backslash-escape (e.g. `\t`, `\n`): under non-tty execution
* contexts (launchd on macOS, systemd without TTYPath) tmux can emit such
* escapes as the literal two characters `\` + letter rather than the control
* byte, breaking the parser and causing every tracked session to be classified
* as dead — which wipes state.json on restart. '|' is passed through verbatim
* in every environment and is rejected by tmux's own session-name validation,
* so it cannot appear inside `#{session_name}` and cause a false split.
*/
const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/**
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
* into a Map of session-name → pane pid. Exported for unit testing.
*
* - Skips empty lines and lines without the separator.
* - Skips entries with a non-numeric pid or empty name.
*/
export function parsePaneList(output: string): Map<string, number> {
const result = new Map<string, number>();
for (const line of output.split('\n')) {
if (!line) continue;
const sep = line.indexOf(PANE_LIST_SEP);
if (sep === -1) continue;
const name = line.slice(0, sep);
const pid = parseInt(line.slice(sep + 1), 10);
if (name && !Number.isNaN(pid)) {
result.set(name, pid);
}
}
return result;
}
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
/**
* Validates that a session name contains only safe characters.
* Prevents command injection via malformed session IDs.
@@ -111,23 +152,7 @@ function isValidMuxName(name: string): boolean {
* Prevents command injection via malformed paths.
*/
function isValidPath(path: string): boolean {
if (
path.includes(';') ||
path.includes('&') ||
path.includes('|') ||
path.includes('$') ||
path.includes('`') ||
path.includes('(') ||
path.includes(')') ||
path.includes('{') ||
path.includes('}') ||
path.includes('<') ||
path.includes('>') ||
path.includes("'") ||
path.includes('"') ||
path.includes('\n') ||
path.includes('\r')
) {
if (UNSAFE_PATH_CHARS.test(path)) {
return false;
}
if (path.includes('..')) {
@@ -198,8 +223,8 @@ function buildSpawnCommand(options: {
}): string {
if (options.mode === 'claude') {
// Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
// fall back to a new session with --session-id so the pane doesn't die.
@@ -371,6 +396,85 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* Build the array of environment export commands shared by createSession() and respawnPane().
* Includes locale, mux markers, session identity, and API URL.
*
* User-supplied envOverrides are NOT inlined here — they go through applyEnvOverrides()
* via `tmux setenv` so secret values (e.g., OPENCODE_API_KEY) never appear in the bash
* command line (visible in `ps`). This also sidesteps shell-metachar injection via keys.
*/
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
return exports;
}
/**
* Apply user-supplied env overrides to a tmux session via `tmux setenv`.
* Values stay off the bash command line (not visible in `ps`), and are inherited
* by new panes — including `respawn-pane`. Persists at tmux-session level, so
* Codeman server restarts don't lose the setting as long as the tmux session lives.
*
* Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against
* shell-metachar injection even if upstream schema check is bypassed.
*/
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): void {
if (!envOverrides) return;
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
for (const [key, value] of Object.entries(envOverrides)) {
if (!value) continue; // Skip empty — nothing to set
if (!VALID_KEY.test(key)) {
console.warn(`[TmuxManager] Skipping invalid env override key: ${JSON.stringify(key)}`);
continue;
}
try {
execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (err) {
console.warn(`[TmuxManager] Failed to set env override ${key}:`, err);
}
}
}
/**
* Resolve the CLI binary directory and return the PATH export prefix string.
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
* In createSession(), a missing binary dir throws — the caller handles that separately.
*/
private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } {
if (mode === 'claude') {
const dir = findClaudeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'opencode') {
const dir = resolveOpenCodeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
/**
* Configure OpenCode-specific environment on a tmux session.
* Sets sensitive API keys and config content via tmux setenv
* (not visible in ps output or tmux history, inherited by panes).
*/
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
}
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session).
@@ -387,6 +491,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
resumeSessionId,
envOverrides,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
@@ -415,33 +520,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Resolve CLI binary directory based on mode
let pathExport = '';
if (mode === 'claude') {
const claudeDir = findClaudeDir();
if (!claudeDir) {
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
}
pathExport = `export PATH="${claudeDir}:$PATH" && `;
} else if (mode === 'opencode') {
const openCodeDir = resolveOpenCodeDir();
if (!openCodeDir) {
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
}
pathExport = `export PATH="${openCodeDir}:$PATH" && `;
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
if (mode === 'claude' && !cliDir) {
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
}
if (mode === 'opencode' && !cliDir) {
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
}
const envExports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
const envExportsStr = envExports.join(' && ');
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
const baseCmd = buildSpawnCommand({
mode,
@@ -489,10 +576,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// For OpenCode: set sensitive env vars and config via tmux setenv
// (not visible in ps output or tmux history, inherited by panes)
if (mode === 'opencode') {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
this._configureOpenCode(muxName, openCodeConfig);
}
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
// so secret values stay off the bash command line. Must run before respawn-pane.
this.applyEnvOverrides(muxName, envOverrides);
// Replace the shell with the actual command (no echo in terminal)
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
@@ -633,6 +723,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
resumeSessionId,
envOverrides,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -641,26 +732,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
// Resolve CLI binary directory based on mode
let pathExport = '';
if (mode === 'claude') {
const claudeDir = findClaudeDir();
pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
} else if (mode === 'opencode') {
const openCodeDir = resolveOpenCodeDir();
pathExport = openCodeDir ? `export PATH="${openCodeDir}:$PATH" && ` : '';
}
const { pathExport } = this.buildPathExport(mode);
const envExports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
const envExportsStr = envExports.join(' && ');
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
const baseCmd = buildSpawnCommand({
mode,
@@ -678,10 +752,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
if (mode === 'opencode') {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
this._configureOpenCode(muxName, openCodeConfig);
}
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
});
@@ -906,23 +982,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const discovered: string[] = [];
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls)
const activeSessions = new Map<string, number>();
let activeSessions = new Map<string, number>();
try {
const output = execSync("tmux list-panes -a -F '#{session_name}\t#{pane_pid}' 2>/dev/null || true", {
const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
for (const line of output.split('\n')) {
if (!line) continue;
const sep = line.indexOf('\t');
if (sep === -1) continue;
const name = line.slice(0, sep);
const pid = parseInt(line.slice(sep + 1), 10);
if (name && !Number.isNaN(pid)) {
activeSessions.set(name, pid);
}
}
activeSessions = parsePaneList(output);
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
}
+2 -15
View File
@@ -20,7 +20,7 @@ import { createInterface } from 'node:readline';
/**
* Parsed transcript entry from the JSONL file
*/
export interface TranscriptEntry {
interface TranscriptEntry {
type: 'user' | 'assistant' | 'system' | 'result';
timestamp: string;
message?: {
@@ -35,7 +35,7 @@ export interface TranscriptEntry {
};
}
export interface TranscriptContentBlock {
interface TranscriptContentBlock {
type: 'text' | 'tool_use' | 'tool_result';
text?: string;
name?: string;
@@ -68,15 +68,6 @@ export interface TranscriptState {
lastUpdateAt: string | null;
}
export interface TranscriptWatcherEvents {
'transcript:update': (state: TranscriptState) => void;
'transcript:complete': (state: TranscriptState) => void;
'transcript:tool_start': (toolName: string) => void;
'transcript:tool_end': (toolName: string, isError: boolean) => void;
'transcript:error': (error: Error) => void;
'transcript:plan_mode': () => void;
}
// ========== Constants ==========
/** How often to check for new content when file watching fails */
@@ -435,7 +426,3 @@ export class TranscriptWatcher extends EventEmitter {
}
}
}
// ========== Singleton Export ==========
export const transcriptWatcher = new TranscriptWatcher();
+3 -2
View File
@@ -29,10 +29,11 @@ import {
RESTART_DELAY_MS,
FORCE_KILL_MS,
} from './config/tunnel-config.js';
import { getErrorMessage } from './types.js';
// ========== Types ==========
export interface TunnelStatus {
interface TunnelStatus {
running: boolean;
url: string | null;
}
@@ -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;
}
-15
View File
@@ -268,18 +268,3 @@ export function createInitialOrchestratorStats(): OrchestratorStats {
replanCount: 0,
};
}
/** Factory function for initial orchestrator persist state */
export function createInitialOrchestratorPersistState(
config: OrchestratorConfig = DEFAULT_ORCHESTRATOR_CONFIG
): OrchestratorPersistState {
return {
state: 'idle',
plan: null,
currentPhaseIndex: 0,
startedAt: null,
completedAt: null,
config,
stats: createInitialOrchestratorStats(),
};
}
-16
View File
@@ -230,22 +230,6 @@ export interface TimingHistory {
/**
* Named respawn configuration preset for quick setup
*/
export interface RespawnPreset {
/** Unique preset identifier */
id: string;
/** User-friendly preset name */
name: string;
/** Description of when to use this preset */
description?: string;
/** The respawn configuration (without enabled flag) */
config: Omit<RespawnConfig, 'enabled'>;
/** Duration in minutes (optional default) */
durationMinutes?: number;
/** Whether this is a built-in preset */
builtIn?: boolean;
/** Timestamp when created */
createdAt: number;
}
/**
* Persisted respawn configuration for mux sessions.
-8
View File
@@ -150,11 +150,3 @@ export interface SessionState {
/**
* Output captured from a session
*/
export interface SessionOutput {
/** Standard output content */
stdout: string;
/** Standard error content */
stderr: string;
/** Exit code of the process, null if still running */
exitCode: number | null;
}
+1 -1
View File
@@ -14,7 +14,7 @@ import type { Disposable, CleanupRegistration, CleanupResourceType } from '../ty
/**
* Options for setTimeout/setInterval with automatic cleanup.
*/
export interface TimerOptions {
interface TimerOptions {
/** Human-readable description for debugging */
description?: string;
}
+3 -6
View File
@@ -7,17 +7,14 @@
*/
export { BufferAccumulator } from './buffer-accumulator.js';
export { LRUMap, type LRUMapOptions } from './lru-map.js';
export { CleanupManager, type TimerOptions } from './cleanup-manager.js';
export { CleanupManager } from './cleanup-manager.js';
export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
ANSI_ESCAPE_PATTERN_SIMPLE,
TOKEN_PATTERN,
SPINNER_PATTERN,
createAnsiPatternFull,
createAnsiPatternSimple,
stripAnsi,
SAFE_PATH_PATTERN,
execPattern,
@@ -27,4 +24,4 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
export { resolveOpenCodeDir, isOpenCodeAvailable } from './opencode-cli-resolver.js';
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
+1 -1
View File
@@ -10,7 +10,7 @@
/**
* Configuration options for LRUMap.
*/
export interface LRUMapOptions<K, V> {
interface LRUMapOptions<K, V> {
/** Maximum number of entries before eviction */
maxSize: number;
/** Optional callback when an entry is evicted */
-18
View File
@@ -40,24 +40,6 @@ export const ANSI_ESCAPE_PATTERN_SIMPLE = /\x1b\[[0-9;]*[A-Za-z]/g;
*/
export const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
/**
* Creates a fresh copy of ANSI_ESCAPE_PATTERN_FULL.
* Use when you need a pattern without shared lastIndex state.
*/
export function createAnsiPatternFull(): RegExp {
// eslint-disable-next-line no-control-regex
return /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
}
/**
* Creates a fresh copy of ANSI_ESCAPE_PATTERN_SIMPLE.
* Use when you need a pattern without shared lastIndex state.
*/
export function createAnsiPatternSimple(): RegExp {
// eslint-disable-next-line no-control-regex
return /\x1b\[[0-9;]*[A-Za-z]/g;
}
/**
* Strips ANSI escape codes from text using the comprehensive pattern.
* @param text - Text containing ANSI escape codes
+1 -1
View File
@@ -22,7 +22,7 @@ interface TimedEntry<V> {
/**
* Configuration options for StaleExpirationMap.
*/
export interface StaleExpirationMapOptions<K, V> {
interface StaleExpirationMapOptions<K, V> {
/** Time-to-live in milliseconds before entries expire */
ttlMs: number;
/** How often to run cleanup (default: ttlMs / 2) */
+1 -1
View File
@@ -23,7 +23,7 @@ import {
export const AUTH_COOKIE_NAME = 'codeman_session';
/** State returned from registerAuthMiddleware for cleanup in server stop() */
export interface AuthState {
interface AuthState {
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
authFailures: StaleExpirationMap<string, number> | null;
qrAuthFailures: StaleExpirationMap<string, number> | null;
+1 -1
View File
@@ -11,5 +11,5 @@ export type { EventPort } from './event-port.js';
export type { RespawnPort } from './respawn-port.js';
export type { ConfigPort } from './config-port.js';
export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
+569 -192
View File
File diff suppressed because it is too large Load Diff

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