Compare commits

..
Author SHA1 Message Date
arkon 44439c951b chore: version packages 2026-06-07 04:44:07 +02:00
Tenggan ZhangandTeigen b2f8b03b3c feat: inject effort as soft default via CLI flags instead of env var (#104)
CLAUDE_CODE_EFFORT_LEVEL hard-locks effort for the whole session and makes
Claude reject in-session /effort switching (incl. ultracode). Carry effort
as a dedicated payload field instead, injected at spawn as a soft default:

- regular levels (incl. max) -> claude --effort <level>
  (the settings effortLevel key is enum([low,medium,high,xhigh]) with
  .catch(undefined), so max would be silently dropped there)
- ultracode -> claude --settings '{"ultracode":true}'
  (dedicated boolean settings key, rejected by the --effort flag)

Changes:
- add effort enum field to create/quick-start/ralph-loop schemas and thread
  it through Session -> CreateSessionOptions/RespawnPaneOptions -> spawn
- buildEffortCliArgs() in session-cli-builder, shared by tmux spawn command
  and direct-PTY fallback args
- frontend: buildEnvOverrides() no longer emits CLAUDE_CODE_EFFORT_LEVEL;
  validated effort goes into payloads via getEffortSetting()
- settings UI: add Ultracode option to the Thinking Effort dropdown
- legacy migration: Session constructor extracts CLAUDE_CODE_EFFORT_LEVEL
  from persisted envOverrides; applyEnvOverrides() unsets the stale tmux
  session var so respawned panes are no longer locked
- tests: test/effort-injection.test.ts (13 cases)

Co-authored-by: Teigen <teigenzhang@gmail.com>
2026-06-07 04:33:11 +02:00
arkonandClaude Opus 4.8 e3c496e1a4 chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:01:44 +02:00
arkonandClaude Opus 4.8 eb831487a0 feat(web): remove /compact button from mobile keyboard accessory bar
Drops /compact from both the simple and extended accessory-bar layouts,
the action handler (case folded back to clear-only), the refocus guard,
and the JSDoc. /clear retains its double-tap confirmation. Verified on a
touch-emulated viewport: neither layout renders a compact action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:00:38 +02:00
68594ac395 feat(web): response-viewer transcript fallback + code-block rendering (#102)
* feat(web): response-viewer transcript fallback + code-block rendering

- Add _cleanTerminalBuffer(): strip ANSI escapes and Claude CLI chrome
  (status bar, spinner, progress bar, prompt glyphs) from the terminal
  buffer so the response viewer renders clean text when the JSONL
  transcript is missing.
- Add _preprocessAsciiArt(): wrap box-drawing/block-element diagrams in
  fenced code blocks (narrow trigger that excludes arrows/geometric
  shapes common in prose) so marked.js preserves their whitespace.
- Extend .rv-text rules to .response-viewer-body so fallback-rendered
  content gets the same typography, code-block, and table styling.

* refactor(web): drop duplicate _cleanTerminalBuffer/_preprocessAsciiArt

These two methods already exist on master (added in #75). This branch
re-added byte-identical copies above _sanitizeHtml; in a JS class body the
later definition wins, so the duplicates were inert dead code. Remove them,
keeping only the genuinely new work: the _renderMarkdown null-safety fix and
the response-viewer CSS overhaul.

Co-Authored-By: Claude Opus 4.8 (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.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:43:52 +02:00
Tenggan ZhangandTeigen ec38fd11bf feat(web): mobile image upload to active session via paste dialog (#101)
- Extend the keyboard accessory paste dialog with an image picker
  (camera / photo library) plus best-effort image paste, routing
  selected files through the existing _uploadAndInsertImages pipeline
- Re-encode images to standard JPEG/PNG in the browser before upload,
  so mislabeled gallery images (e.g. MIUI WebP claiming image/jpeg)
  pass the server magic-byte check; PNG keeps transparency, GIF passes
  through untouched, decode failures fall back to the original file
- Log the real byte header on the paste-image magic-mismatch branch to
  pin down any remaining format mismatches without a guessing loop
- Ignore the runtime .claude-images/ upload directory

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-06-01 19:38:48 +02:00
Tenggan ZhangandTeigen 06f9ff6d9c fix: avoid event-loop stalls from synchronous tmux/ps calls (#100)
The stats collector (~2s) and mouse-mode sync (5s) ran execSync (pgrep/ps/
list-panes, 5s timeout each) per session on the server's single thread,
blocking the event loop. With several sessions or a momentarily slow tmux this
froze port 3000 for seconds-to-tens-of-seconds while the process stayed alive
and other ports were unaffected — self-healing, so it never restarted and the
60s loopback healthcheck missed it. Convert these hot-path calls to execAsync.

Also add an always-on event-loop lag monitor (utils/event-loop-monitor.ts) that
logs stalls >=1s to the web log, so this otherwise-invisible class of incident
leaves a quantified, timestamped trace.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-06-01 19:32:24 +02:00
arkonandClaude Opus 4.7 257695ff8e chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 05:17:07 +02:00
arkonandClaude Opus 4.7 2cfccc745f docs: correct sendPendingCtrlL comment (it has no callers)
The prior wording claimed the no-op stub was kept so SSE idle/working
handlers could call it without guards, but there are no callers anywhere.
Reword to reflect that it's a vestigial, intentionally-retained guard
documenting why Ctrl+L must not be auto-sent. Comment-only; minified
build output is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:52:15 +02:00
arkonandClaude Opus 4.7 016c23934f chore: version packages
Release 0.7.0. Also syncs CLAUDE.md version line and corrects the
route-handler counts (~130 handlers, sessions 28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:19:15 +02:00
Tenggan ZhangandTeigen 896dc5b177 fix(web): stop auto-sending Ctrl+L from session selection paths (#99)
Claude Code 2.x treats Ctrl+L (\x0c) as a two-step "clear conversation"
command (first press shows the confirmation prompt, second press
clears). The frontend previously fired \x0c from three places to force
Ink to redraw stale CUP-positioned frames in the tailed buffer; if a
page refresh or SSE reconnect ran the same path twice within Claude's
confirmation window the second \x0c silently nuked the user's
conversation.

Removed the \x0c sends from:
- selectSession() — main offender, runs on every tab switch & page reload
- restoreTerminalSize() — manual "restore size" button
- sendPendingCtrlL() — dead code path (pendingCtrlL was never populated)

Trade-off: occasional stale Ink frames immediately after refresh; the
user's first keypress causes Ink to redraw and the artifact vanishes.
Losing the conversation silently is far worse than a brief cosmetic
glitch.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-26 00:12:43 +02:00
Tenggan ZhangandTeigen 196646a7ff feat(web): one-click copy button on response-viewer code blocks (#98)
Wrap every fenced code block in the response viewer with a positioned
.rv-code-wrap toolbar (outside the <pre> scroll container so buttons stay put
during horizontal scroll). All blocks get a copy button; ASCII diagrams keep
their existing line-wrap toggle alongside it.

_copyText() prefers the async Clipboard API and falls back to a hidden-textarea
+ execCommand path, so copy works over plain HTTP too. The button shows a 1.5s
✓ / ✕ feedback state after each attempt.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-26 00:00:37 +02:00
Tenggan ZhangandTeigen 1b652ceb87 test: repair route harness error rendering + stop AI-checker spawning real processes in tests (#97)
* fix(test): share route error handler with test harness + fix stale assertions

The route test harness built a bare Fastify instance without the production
global error handler (server.ts), so structured errors thrown by route helpers
(findSessionOrFail → 404, parseBody → 400) fell through to Fastify's default
handler — yielding a `{statusCode,error,message}` body instead of the
`{success:false,...}` shape, and the tests asserted the old implicit-200
behavior. 51 route tests across 7 files were red.

- Extract the handler into src/web/route-error-handler.ts; server.ts and the
  test harness now install the identical handler (single source of truth).
- Correct stale assertions across route test files: throw-based error paths
  now assert 404 (unknown session) / 400 (invalid body); genuine in-handler
  `return createErrorResponse(...)` paths (200 + success:false) left untouched.
- Reformat a few test files prettier flagged (pre-existing non-compliance).

Route suite: 307/307 passing (was 256/307). No production behavior change.

* test(respawn): mock child_process so AI checker never spawns real processes

respawn-controller.test.ts drives the AI idle checker (ai-checker-base), whose
runCheck() spawns a real `tmux new-session` running `claude -p`. The AI-enabled
tests only assert the ai_checking state transition (then cancel/stop), so the
spawn produced stray real tmux sessions and claude processes on every run — the
reason `npm test` (full suite) was unsafe to run inside a managed session.

Mock node:child_process here (mirroring ai-idle-checker.test.ts), spreading the
real module so `exec` stays intact for transitively-imported modules
(tmux-manager calls promisify(exec) at load). With this, the full non-mobile
suite runs without spawning any real tmux/claude.

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-25 23:55:57 +02:00
arkonandClaude Opus 4.7 8abf349cfc chore: version packages
Also add docs/opencode-integration.md pointer to the dual-CLI gotcha in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:32:44 +02:00
arkonandClaude Opus 4.7 ae5bcf9330 docs: archive stale findings docs (work completed)
Both findings docs described a codebase that no longer exists — their
headline 'Critical'/'P0' items (server.ts/app.js/types.ts splits, the
{WORKING_DIR} placeholder bug) are all resolved. Moved to docs/archive/
with dated banners so they read as history, not a live TODO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:31:50 +02:00
arkonandClaude Opus 4.7 78d5fcf70c docs: mark tmux-manager.ts as large file, refine CLAUDE.md accuracy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:31:50 +02:00
Tenggan ZhangandTeigen 1ff315a1e6 fix(tmux): isolate sessions on a dedicated socket + raise pane nofile limit (fixes new-session crash after tmux upgrade) (#96)
* fix: isolate codeman tmux sessions

* fix(tmux): unify all sessions onto a single dedicated socket

Remove the per-session `tmuxSocket` field that recorded which tmux server
each session lived on (default vs the `codeman` socket). That field was a
persisted cache of physical reality and could drift — causing live sessions
to be wrongly marked dead ("tab shows no session found") and spawning
duplicate "Restored:" tabs.

All Codeman sessions now live on one process-wide socket (`tmux -L codeman`,
overridable via CODEMAN_TMUX_SOCKET), exposed via TmuxManager.muxSocket on
the TerminalMultiplexer interface. reconcileSessions() collapses from a
multi-socket scan (locate / re-pin / cross-socket dedup) to a single
`list-panes` query. loadSessions() strips the obsolete field from on-disk
records so it stops being written back.

Also fix two sibling bare-`tmux` call sites the unification would otherwise
leave broken (same #80 regression class — bare tmux hits the user's default
server and never finds a session on the codeman socket):
- session.ts queryTmuxWindowSize(): add `-L <socket>` (was silently falling
  back to 120x40 on re-attach, losing scrollback)
- session-routes.ts send-key (Shift+Enter / Ctrl+Enter newline): route
  through ctx.mux.muxSocket

SSH chooser scripts (tmux-manager.sh, tmux-chooser.sh) route every tmux call
through `tmux -L $CODEMAN_TMUX_SOCKET`, matching the TS default.

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-25 23:28:58 +02:00
arkonandClaude Opus 4.7 08de6667ab chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:43:23 +02:00
Tenggan ZhangandTeigen d27f8e77f7 feat: add View all in folder modal for Resume Conversation (#94)
Drill into a single project's complete history when the homepage's
3-per-project dedup hides older conversations.

- Backend: /api/history/sessions accepts projectKey/offset/limit;
  single-folder mode bypasses the 50-cap and returns { sessions, total }.
  projectKey is validated against ^[A-Za-z0-9_-]+$ to prevent traversal.
- Frontend: detail panel adds "View all in this folder" button that
  opens a modal listing 20 sessions per page with Show more pagination.
- Modal items reuse _buildHistoryItem with showViewAll:false to avoid
  recursive entry points.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-19 16:27:42 +02:00
Tenggan ZhangandTeigen e248cd8bcf fix: drop phantom ended-tab stubs, trust server as source of truth (#93)
Previously the client cached open session tabs in localStorage and resurrected
any that the server no longer knew about as grayed-out "ended" stubs. On
multi-device use (close tab on mobile, open desktop) this left stale phantom
tabs the user had to manually dismiss.

Remove _restoreEndedTabs / _saveTabMetadata, the session._ended branch in
selectSession, the data-ended render attribute, and the matching CSS rule.
Clear the legacy localStorage key on init to purge stale entries.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-19 16:19:07 +02:00
Tenggan ZhangandTeigen 73d81afd4d fix: decode project keys with longest-match backtracking (#92)
When two sibling directories share a prefix (e.g. `diary/` and
`diary-app/`), the greedy shortest-match decoder picked the shorter
name and then failed to resolve the remainder, so the homepage Resume
Conversation list showed those workingDirs as $HOME and resume targeted
the wrong folder. Switch to recursive backtracking with longest-join-first
at each segment boundary; require every step to be a real directory.
Keep the greedy path as a fallback for deleted dirs.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-19 16:16:46 +02:00
arkon 7884a37c55 chore: version packages 2026-05-19 12:11:44 +02:00
Ark0N ad89a97106 fix(renderer): WebGL longtask fallback hardening (#91)
Closes #89.

- _disposeWebGLObserver() called from both trip path and onContextLoss (fixes the leak)
- Thresholds (200ms/3/30s/5s/7d) hoisted to WEBGL_FALLBACK in constants.js
- Pure evaluateWebGLLongTaskTrip() helper + 9 Playwright tests (test/webgl-fallback.test.ts, port 3166)
2026-05-19 11:43:36 +02:00
arkonandClaude Opus 4.7 0600b7843e docs: add image-input.js to frontend module list in CLAUDE.md
PR #84 added `src/web/public/image-input.js` (clipboard paste + drag-drop)
but the CLAUDE.md frontend module table wasn't updated. Bumps the feature
modules count from 4 to 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:40:35 +02:00
arkonandClaude Opus 4.7 6d896c781e ci: add server boot smoke test
CI was running typecheck + lint + format only, which let plugin
registration regressions reach master — the @fastify/multipart conflict
in #90 crashed the server at startup but passed CI. The boot smoke
spawns the web server on a non-default port, polls /api/status for up
to 30s, and dumps the log on failure (either early exit or no-ready).

Catches: plugin registration conflicts, route registration errors,
import cycles, and any other failure between process start and
app.listen() resolving.

Auto-installs tmux on the runner since createMultiplexer() throws
without it (mux-factory.ts:17). ubuntu-latest already ships tmux, so
the install branch is normally a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:41:42 +02:00
arkonandClaude Opus 4.7 930492058b fix(server): remove duplicate multipart parser conflicting with @fastify/multipart
#90 added @fastify/multipart, which registers its own multipart/form-data
content-type parser. Combined with the existing manual no-op parser in
setupRoutes() (originally there so /api/screenshots could read req.raw
directly), this raises "Content type parser 'multipart/form-data' already
present" at server boot and the process exits. CI did not catch it
because ci.yml runs typecheck + lint only.

@fastify/multipart's parser is a no-op marker (sets req[kMultipart] =
true and returns) and leaves the body on req.raw, so the legacy
/api/screenshots handler that reads req.raw directly keeps working
unchanged. The manual parser was redundant the moment the plugin was
registered.

Smoke-tested locally: server boots, /api/sessions/:id/paste-image
returns 200 / 403-CSRF / 415-magic-mismatch / 413-oversize / 429-rate
as designed; /api/screenshots upload still returns 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:37:26 +02:00
aakhter 101cee0cec security(paste-image): harden against 7 findings from PR #84 review (#90)
Hardens `/api/sessions/:id/paste-image` against the seven findings flagged in the dismissed security review on #84. Each commit addresses one finding.

- LOW: Collision-free filenames (`paste-${ts}-${rand4}${ext}`)
- MED: Symlink check on image dir (`lstat` + non-recursive mkdir + `O_EXCL|O_NOFOLLOW`)
- MED: Magic-byte validation (PNG/JPEG/GIF/WebP/BMP)
- HIGH: CSRF protection (Origin/Referer match req.host; non-browser clients send `X-Codeman-CSRF`)
- MED: Swap hand-rolled multipart parser to @fastify/multipart with `limits: { fileSize: 10MB, files: 1, fields: 4 }`
- MED: Rate limit (30/min per IP+session) + hourly GC of `paste-*` files older than 7d
- LOW: Use `terminal.paste(text)` instead of `sendInput(text)` so bracketed-paste markers survive

Co-authored-by: Aamer Akhter <aakhter@gmail.com>
2026-05-19 10:36:05 +02:00
arkon 7752325c90 chore: version packages 2026-05-17 06:06:42 +02:00
arkonandClaude Opus 4.7 6b284598cf security(sse): validate clientId shape and cap subscribe payload
Constrains the per-client SSE identifier introduced in #86 to
`[A-Za-z0-9_-]{8,64}` at both ingress points (`GET /api/events`
query and `POST /api/events/subscribe` body). Without this, an
authenticated attacker could:
  - Send a victim's clientId to silently evict their tab from
    sseClients (DoS — socket stays open, broadcasts stop).
  - Mutate any clientId's session filter, blackholing that tab's
    terminal stream.
  - Grow sseClientsById without bound via long IDs.

Also caps the subscribe payload to 64 session entries of ≤128 chars
each, since the previous handler accepted arbitrary-length arrays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 06:04:33 +02:00
94bcf524a2 feat: image paste (Ctrl+V) and drag-and-drop into terminal (#84)
* feat: add image paste and drag-and-drop support

Clipboard paste (Ctrl+V) and drag-and-drop of image files into the
terminal. Images are saved to {workdir}/.claude-images/ and the
absolute path is inserted into the terminal input for Claude to read.

- POST /api/sessions/:id/paste-image endpoint (hand-parsed multipart)
- image-input.js mixin with paste trap technique (works on HTTP)
- Ctrl+V intercepted at xterm keyboard level, routes through hidden
  contenteditable div to capture both image and text clipboard data
- Drag-and-drop on terminal container with visual overlay
- Session cleanup deletes .claude-images/ on destroy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security: remove SVG from paste-image allowlist

Drops .svg / image/svg+xml from the paste-image endpoint. SVGs are
served as image/svg+xml via /api/sessions/:id/file-raw, same-origin,
under a CSP that permits inline scripts — which would execute on view.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
2026-05-17 05:57:07 +02:00
98966def03 feat(sse): per-client live subscription filter (#86)
* feat(sse): per-client live subscription filter

Lets a connected client narrow its SSE stream to a single session
without forcing an EventSource reconnect. With many open sessions
(N tabs in the UI, all generating output), this cuts terminal-event
SSE traffic by roughly Nx — we only send the actively-rendered
session's bytes instead of all of them.

The existing ?sessions= query filter only worked at connect time;
narrowing or widening it required tearing down the EventSource and
losing in-flight messages. That was acceptable when filters were set
once at page load, but the UI now flips active sessions on every
tab switch.

How it works
============

- Client generates a stable per-page UUID (`_clientId`) once at
  CodemanApp construction and includes it on the SSE URL:
    GET /api/events?clientId=<uuid>&sessions=<active-id>
- Server records a `clientId -> reply` mapping in addition to the
  existing `reply -> sessionFilter` map.
- New endpoint:
    POST /api/events/subscribe { clientId, sessions: string[] | null }
  updates the in-memory filter for the matching reply. 204 on success,
  404 if the client isn't known yet (race on first selectSession after
  reconnect — the next reconnect carries the filter via the URL).
- On every selectSession the client fires a fire-and-forget POST. No
  reconnect, no re-init, no replay buffer needed.

Behavioural change to broadcast()
=================================

The per-event session filter is removed from `broadcast()`. Previously
that path filtered lifecycle/metadata events (`session:created`,
`session:updated`, `ralph:*`, `hook:*`) by extracting a `sessionId` from
the payload. With per-client narrow filters, that meant a client
subscribed to session A would never see session:created for B and the
sidebar would silently de-sync.

The new contract:
- **Lifecycle/metadata events** (low-volume, UI-correctness critical)
  broadcast to all clients regardless of filter.
- **Terminal events** (high-volume, the actual reason for filtering)
  apply the filter in `flushSessionTerminalBatch` (already there;
  unchanged).

`extractSessionId()` was only used by the old broadcast() filter and
has been removed.

Files
=====

- src/web/sse-stream-manager.ts (+34/-29): add `sseClientsById`,
  optional `clientId` arg to addClient/removeClient cleanup, new
  `updateClientFilter()`, and the broadcast() change above.
- src/web/server.ts (+22/-3): parse `clientId` on /api/events, pass
  to `addClient`, register POST /api/events/subscribe handler.
- src/web/public/app.js (+41/-1): generate `_clientId`, build the
  EventSource URL with both clientId + active session, add
  `_updateSseSubscription()`, call it on selectSession.

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

* test(sse): update operation-lightspeed to match broadcast-all contract

Lifecycle events (session:*, case:*) now reach every connected SSE
client; only session:terminal is gated by the per-client filter.
Updates the four assertions in operation-lightspeed.test.ts that
encoded the old "filter applies to all events" contract.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
2026-05-17 05:56:53 +02:00
aakhterandClaude Opus 4.6 e87b03b6c2 fix(renderer): WebGL longtask auto-fallback to canvas renderer (#83)
The xterm WebGL renderer can stall the main thread for hundreds of ms
under GPU pressure (driver hiccup, integrated-GPU memory pressure,
hardware-accelerated browser layers contending for the GPU). Symptom:
the page becomes intermittently unresponsive and Chrome eventually
shows the "Page Unresponsive" dialog. Today the only mitigation is
?nowebgl, which the user has to remember and re-apply on every load.

This patch installs a PerformanceObserver after WebGL init that
watches for sustained main-thread stalls and falls back to the DOM
renderer automatically:

- Threshold: 3 long tasks of >=200ms each within a 30-second window.
- 5-second grace period after init skips the noisy initial-load
  stalls so a slow first paint does not trip the guard.
- On trigger: dispose the WebGL addon, write a sticky disable to
  localStorage with a reason and timestamp, and refresh the terminal
  so the canvas renderer takes over without a page reload.
- Subsequent loads honor the sticky disable for 7 days, then auto-
  expire so users retry after a driver/Chrome update.
- Force re-enable any time with ?webgl=force (also clears the
  sticky entry).
- Existing ?nowebgl behaviour is unchanged.
- The same disable path is reused by the existing onContextLoss
  callback so a hard context loss also persists across reloads.

Files:
- src/web/public/app.js: _initWebGL onContextLoss now persists +
  schedules the watchdog; new _installWebGLLongTaskGuard and
  _disableWebGLSticky helpers.
- src/web/public/terminal-ui.js: WebGL init checks the sticky entry
  with 7-day expiry, honors ?webgl=force, threads sticky into
  skipWebGL alongside the existing mobile + ?nowebgl gates.

PerformanceObserver longtask is widely supported (Chromium, Edge);
the try/catch around .observe() makes Firefox/Safari (which lack the
longtask entry type) silently no-op and just keep WebGL.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 05:56:30 +02:00
aakhterandClaude Opus 4.6 edd494ec5f fix(client): multi-primitive yield for write pacing (#85)
When the data-pacing path (chunkedTerminalWrite + deferred path of
flushPendingWrites) schedules its next chunk via requestAnimationFrame
alone, terminal output stalls indefinitely if rAF is starved. Three
real-world scenarios reproduce this in Chromium:

1. Window is occluded (fully covered by another window, or on a
   monitor that has gone to sleep). rAF drops to ~0Hz.
2. Tab is idle-throttled (no user interaction for ~5 min). Chromium
   intensive-throttling clamps setTimeout to 1Hz too.
3. Tab is in a background window. Both rAF and setTimeout slow to a
   crawl.

Replace the rAF-only scheduling with a _safeYield helper that races
three primitives in parallel:

- requestAnimationFrame (primary, fires at compositor rate).
- setTimeout(50) (fallback for visible-but-occluded windows).
- Worker postMessage tick (fallback for idle-throttled and
  background tabs; Workers are not subject to main-thread throttling
  — this is the React Scheduler trick).

The first one to fire wins via a `done` guard; the others become
no-ops. The Worker is built lazily on first call (4 lines of inline
JS via Blob URL); if Worker construction throws we silently fall
back to the other two primitives.

Replaces 6 requestAnimationFrame callsites that participate in data
pacing:
- 3 flushPendingWrites scheduling sites (live + deferred paths).
- 3 chunkedTerminalWrite sites (initial chunk, next-chunk loop,
  final finish-callback).

True animation use cases (scroll loop in scrollToBottom, fit-addon
reflow) stay on plain requestAnimationFrame — they are correctly
throttled when the user is not looking, by design.

File: src/web/public/terminal-ui.js (+70/-8).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 05:56:09 +02:00
arkon 00721069e1 chore: version packages 2026-05-12 10:25:20 +02:00
arkonandClaude Opus 4.7 453a5383d2 test: cover hostname title (#82) and tmux size-query (#80)
Backfill the two regression gaps flagged on master after the recent
hostname-title and tmux-flicker fixes shipped without server-side
assertions.

* test/server-index-title.test.ts (8 tests) — exercises WebServer's
  index.html templating path: default os.hostname(), --title-hostname
  override, HTML-escape against `<script>`-style breakout, ampersand
  non-double-encoding, exact-once substitution, and byte-identical
  template-tail invariance.

* test/tmux-window-size-query.test.ts (15 tests) — mocks
  child_process.execFileSync and walks the helper through the
  browser-resize-between-attaches happy path, query-then-die race,
  zero/negative/empty/non-numeric output, plus argv-form/timeout
  assertions to lock down the no-shell-interpolation guarantee.

* src/session.ts — extracts the inline 14-line tmux size query into
  a named `queryTmuxWindowSize()` export so the test surface is a
  pure function. Behavior unchanged.

* src/web/public/notification-manager.js — Browser Notification API
  (layer 3) now uses `${this.originalTitle}: ${title}` so OS-level
  desktop pop-ups carry the same `codeman:<host>` prefix that the
  tab title and Web Push payloads already do, finishing the
  hostname plumb-through started in #82.

* CLAUDE.md, README.md — document the dual-CLI env-prefix discipline
  (CLAUDE_CODE_* vs OPENCODE_*), expand the xterm-zerolag-input
  duplication gotcha to mention the published-package side-effect,
  and note that the hostname prefix now applies uniformly to tab
  title, tab-flash, and OS notifications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:23:44 +02:00
arkonandClaude Opus 4.7 e7b95ae579 test(routes): regression coverage for stripInkRedrawBloat
The clustering rewrite of stripInkRedrawBloat() shipped silently inside
the v0.6.7 "chore: version packages" commit (dcc814f). The previous
implementation discarded everything after the first VPA escape — silently
dropping 100KB+ of legitimate streamed response text on every long
Claude turn. The fix landed without any test coverage, so a regression
back to the old shape would be invisible until users noticed missing
conversation history.

Export the function (it's a pure (string)=>string helper) and add 12
tests covering:
  - The early-out paths (empty buffer, no VPAs, fewer than 10 VPAs)
  - Small clusters preserved (< MIN_BLOAT_SIZE = 32KB span)
  - Big clusters collapsed to a single trailing VPA
  - The silent-data-loss bug: response text BETWEEN two big clusters
    is preserved (input >280KB so any "keep just the tail" approach
    would push the response text out of its window — verified locally
    that a simulated old impl fails the assertion)
  - FRAME_GAP boundary on both sides (>8KB splits clusters; <=8KB merges)
  - Mixed small + big in the same buffer
  - Big cluster at end-of-buffer keeps the last frame
  - Idempotency: a second pass is a no-op
  - Realistic 200KB+ input shrinks by an order of magnitude

Total runtime ~12ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:11:47 +02:00
arkonandClaude Opus 4.7 56c2c29009 feat(push): plumb hostname-aware prefix into Web Push notifications
Closes the Web Push gap left by #82: in-page Notification API and tab
title flash both showed `codeman:<host>` after that PR, but OS-level
notifications dispatched via the service worker — the surface that
matters most when the tab is closed and the user is reading their
system notification center across multiple Codeman instances —
still hardcoded the literal "Codeman" prefix.

Service workers run in an isolated context with no access to
document.title or any in-page state, so the hostname has to ride
along in the push payload itself.

Server (server.ts:sendPushNotifications): emit `hostTitle: this.windowTitle`
in the JSON payload alongside the existing `title` (event-specific text
like "Permission Required"). The two stay separate so the SW can compose
them — the server knows the host, the SW knows the OS context.

Service worker (sw.js): compose `${hostTitle}: ${title}` when both
present, mirroring the in-page Notification format from
notification-manager.js. Fall back to `title || hostTitle || 'Codeman'`
so older servers (which omit hostTitle) keep working — the field is
purely additive on the wire.

Tests (test/push-payload-host-title.test.ts): mock the `web-push` module
via vi.hoisted(), instantiate WebServer without binding a port, stub
the push store with one fake subscription, and verify the JSON payload
shipped to webpush.sendNotification carries the right hostTitle for
both --title-hostname overrides and the os.hostname() default. Also
mirrors the SW's title-composition logic in a small helper so any
future change to the format breaks the test instead of being caught
only by users running multiple Codeman instances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:03:59 +02:00
arkonandClaude Opus 4.7 7beec7194a fix(client): harden inline rename against CJK, mid-rename deletion, and double-fire
Three follow-up fixes to the inline rename input introduced in #81:

1. IME composition guard. Pressing Enter to confirm a Chinese pinyin
   candidate (or any IME composition) was committing the half-composed
   text as the session name. Skip the keydown handler when isComposing
   is true or when keyCode is the legacy 229 sentinel that older
   Safari/Edge versions report on the Enter that triggers compositionend.

2. Ghost tab on mid-rename deletion. If a session was deleted via SSE
   while its tab was being renamed, the render-skip flag suppressed
   _renderSessionTabs() and the orphaned <input> stayed on screen until
   blur — at which point the rename PUT 404'd against the dead session.
   Replace the boolean _inlineRenameActive with a _activeRename
   {sessionId, cancel} object so _cleanupSessionData can abort an
   in-flight rename targeting the deleted session, and finishRename
   skips the API call when the session is gone.

3. Stuck-flag risk. Move the settle-once guard into a closure-local
   `settled` boolean so blur / Enter / Escape / external cancel all
   converge to a single idempotent path. Register _activeRename only
   after the input is fully wired so a throw earlier in setup can't
   strand state.

Adds test/inline-rename.test.ts with 7 Playwright tests that drive
startInlineRename via page.evaluate() against a stubbed session and
synthetic .tab-name node — no real PTY/tmux needed, runs in ~1.3s.

Also fixes test/mobile/helpers/server.ts which imported the WebServer
via a path one directory short of the repo root, breaking the entire
mobile test suite under the main vitest config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:57:08 +02:00
arkonandClaude Opus 4.7 dcc814f40c chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:18:14 +02:00
aakhterandClaude Opus 4.6 b7e94e7068 feat: hostname-aware window title (#82)
Set the browser tab title to codeman:${hostname} instead of the bare
"Codeman" literal. Useful for users running multiple Codeman instances
across hosts (laptop, dev box, NAS) — the OS hostname disambiguates
which tab points at which backend.

Implementation:

- src/cli.ts: new --title-hostname <hostname> flag overrides the
  detected hostname (handy for cosmetic naming or when os.hostname()
  returns something noisy).
- src/web/server.ts: WebServer now accepts an optional titleHostname
  constructor arg (defaults to os.hostname()), composes
  windowTitle = codeman:${titleHostname}, and serves / and
  /index.html by templating that title into the cached index.html
  template (with HTML escaping of the title text).
- src/web/public/notification-manager.js: title-flash logic now uses
  this.originalTitle instead of the hardcoded "Codeman" literal, so
  the tab flash respects the per-host title.
- scripts/browser-comparison.mjs + test/file-link-click.test.ts:
  expectations updated from === "Codeman" to a startsWith("codeman:")
  predicate so they pass regardless of host.

The new index.html templating is intentionally narrow — it only
substitutes the <title> tag and continues to serve everything else
from the static template. No JS-side title injection, so it works
without JavaScript and shows the correct title from the very first
paint.

Note: test/file-link-click.test.ts shows ~49 prettier-reformat lines
that are not part of the feature — they are pre-existing prettier
debt that the pre-commit hook required me to clear. The single
behavioral change is the browserAvailable line.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:11:33 +02:00
aakhterandClaude Opus 4.6 eade261763 fix(client): preserve inline rename input across tab re-renders (#81)
When the inline session-rename input is open, any incoming SSE event
that triggers renderSessionTabs() (a sibling session updating, a hook
firing, a status change) destroys the input element mid-keystroke and
the user loses what they were typing.

Add a _inlineRenameActive flag that:
- guards the two render paths (renderSessionTabs and
  _fullRenderSessionTabs) so they bail out early while a rename is
  in progress;
- is set true when the inline input mounts (session-ui.js);
- is cleared in finishRename, which then explicitly calls
  renderSessionTabs to restore the normal tab structure.

Also add a re-entrance guard at the top of finishRename so the blur
event and the Enter keydown do not both fire it (was a latent
double-call).

Drive-by: replace tabName.innerHTML = "" with explicit child removal.
The preceding textContent = "" already clears the element; this avoids
an innerHTML write on a node that takes user-supplied content on the
next line.

Follow-up to the inline-rename feature cherry-picked from #60.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:10:34 +02:00
arkonandClaude Opus 4.7 41a82fcf02 chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:41:11 +02:00
aakhterandClaude Opus 4.6 eecf74c001 fix: prevent tmux flicker on restart by matching existing window size (#80)
When a PTY client re-attaches to an existing tmux session, it currently
hardcodes the PTY size to 120x40 and tmux resizes the window to match.
The xterm.js client then resizes back to its actual viewport on the
next render tick, so every restart causes a visible flicker and loses
one repaint of buffer content.

Also remove the hardcoded `-x 120 -y 40` from `tmux new-session` so
initial size adapts to the first client.

Changes:
- session.ts: query existing window size via `tmux display -p
  #{window_width} #{window_height}` before pty.spawn, fall back to
  120x40 only if tmux is unreachable.
- tmux-manager.ts: drop -x/-y from new-session args.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 22:32:17 +02:00
arkonandClaude Opus 4.7 23b4dfcd82 chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:23:34 +02:00
69 changed files with 6384 additions and 848 deletions
+26
View File
@@ -34,6 +34,32 @@ jobs:
- name: Format check
run: npm run format:check
- name: Server boot smoke test
run: |
set -u
if ! command -v tmux >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y tmux
fi
npx tsx src/index.ts web --port 3151 > /tmp/boot.log 2>&1 &
SERVER_PID=$!
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
for i in $(seq 1 30); do
if curl -fsS http://localhost:3151/api/status -o /dev/null; then
echo "Server booted in ${i}s"
exit 0
fi
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "Server exited before becoming ready. Logs:"
cat /tmp/boot.log
exit 1
fi
sleep 1
done
echo "Server did not respond on /api/status within 30s. Logs:"
cat /tmp/boot.log
exit 1
# Note: The test suite is intentionally excluded from CI.
# Tests spawn real tmux sessions and require a full system environment.
# Run tests locally with: npx vitest run test/<file>.test.ts
+3
View File
@@ -66,3 +66,6 @@ commands
todo.md
@fix_plan.md
readme-preview.mjs
# Uploaded images land here under each session working dir (runtime artifact)
.claude-images/
+169
View File
@@ -1,5 +1,174 @@
# aicodeman
## 0.8.1
### Patch Changes
- Thinking Effort now flows as a soft default the user can override in-session (PR #104, by @TeigenZhang).
Previously Codeman carried the effort setting as the `CLAUDE_CODE_EFFORT_LEVEL` env var, which Claude Code treats as a hard override — it locked effort for the whole session and rejected in-session `/effort` switching (including switching to `ultracode`). Effort is now injected at spawn time as a CLI soft default that `/effort` can still change freely in either direction:
- Regular levels (`low`/`medium`/`high`/`xhigh`/`max`) are passed via `claude --effort <level>` (the settings `effortLevel` key silently drops `max`, so the flag is used instead).
- `ultracode` (xhigh effort + standing dynamic-workflow orchestration) is passed via `claude --settings '{"ultracode":true}'`, since the `--effort` flag rejects it.
Details:
- New `effort` field on the create-session, quick-start, and Ralph-loop request schemas; threaded through `Session._effort` to both spawn paths (tmux `buildSpawnCommand` and direct-PTY `buildInteractiveArgs`), persisted in `SessionState.effort`, and restored on reboot recovery.
- `buildEffortCliArgs()` is the single, allowlist-validated source for both carriers (injection-safe).
- Settings UI adds an "Ultracode (multi-agent workflows)" option to the Thinking Effort dropdown; the frontend no longer emits `CLAUDE_CODE_EFFORT_LEVEL`.
- Legacy migration: sessions persisted with the old env var are auto-migrated into the new `effort` field, and the stale tmux env var is unset so respawned panes are no longer locked.
- Adds `test/effort-injection.test.ts` (13 cases) covering carrier mapping, injection guards, args building, and constructor migration.
## 0.8.0
### Minor Changes
- Event-loop responsiveness fix, mobile image upload, response-viewer polish, and a mobile-UI trim.
- **fix: avoid event-loop stalls from synchronous tmux/ps calls (#100):** The session manager ran `execSync` for tmux mouse-mode toggles, `list-panes`, and `ps`/`pgrep` resource-stat queries on the main thread. Under multi-session / many-pane load these blocking spawns froze Node's single event loop, stalling SSE broadcasts and PTY I/O (the ":3000 briefly unreachable, process never restarts" class of incident). Converted those calls to async `execAsync` and updated all callers to `await`. Added a lightweight `utils/event-loop-monitor.ts` that samples loop-delay and logs when a stall threshold is exceeded, started on web-server boot and stopped on shutdown — so future regressions leave a timestamped, quantified log line instead of vanishing silently.
- **feat(web): mobile image upload to active session via paste dialog (#101):** The mobile keyboard-accessory paste dialog now attaches images, not just text — via a native picker (`accept=image/*` → camera / photo library / files) plus best-effort capture of images pasted into the textarea. Both paths reuse the existing `_uploadAndInsertImages()` → `POST /api/sessions/:id/paste-image` pipeline. Images are re-encoded client-side before upload (PNG→PNG to preserve transparency, everything else→JPEG, animated GIFs passed through untouched) so the bytes always match their declared extension — fixing the Android/MIUI case where a WebP/HEIF mislabeled as `image/jpeg` passed the extension allowlist but failed the server's magic-byte check. The server logs a precise diagnostic on any remaining magic-byte mismatch.
- **feat(web): response-viewer transcript fallback + code-block rendering (#102):** A substantial response-viewer styling overhaul — proportional prose font (monospace kept for code), refined heading/code/blockquote/list styling, readable max content width, and a smoother slide-in animation; the `.rv-text` rules now also apply to `.response-viewer-body` so transcript-missing fallback content gets the same typography. Plus a `_renderMarkdown` null-safety fix (`text` → `src = text || ''`).
- **feat(web): remove /compact button from the mobile keyboard accessory bar:** Dropped `/compact` from both the simple and extended accessory-bar layouts and the associated action handling. `/clear` retains its double-tap confirmation. Verified on a touch-emulated viewport that neither layout renders a compact action.
## 0.7.1
### Patch Changes
- **fix(respawn): auto-accept now fires on plan approvals after `Worked for X` line, and on AskUserQuestion menus**
Two related blockers in the respawn controller's auto-accept path:
- Modern Claude Code emits `✻ Worked for Xm Ys` immediately before a plan-approval menu. `_detectCompletionMessage()` cancelled the auto-accept timer and `canAutoAccept()` then rejected on `completionMessageTime !== null`, so plan approvals **never** auto-accepted — the 10 s completion-confirm timer instead started a respawn cycle while the menu sat unanswered.
- The same logic in `signalElicitation()` set a hard flag that blocked auto-accept whenever Claude Code fired the `elicitation_dialog` hook, contradicting the in-UI hint ("Auto-accept presses Enter for plan approvals **and default question options**"). AskUserQuestion menus were therefore never auto-accepted either.
Fix:
- `_detectCompletionMessage()` no longer cancels the auto-accept timer; the auto-accept pre-filter is now the authoritative "is there a numbered selection menu?" gate.
- `canAutoAccept()` and the AI-plan-check callback both accept `'watching'` AND `'confirming_idle'` states (covers the single-PTY-burst case where `Worked for` and the menu arrive together — `_detectCompletionMessage` returns early before the substantial-output check can demote state back to watching). `sendAutoAcceptEnter()` self-transitions back to `'watching'` before sending Enter.
- `signalElicitation()` is now an affirmative hint that primes the auto-accept timer instead of blocking. Still gated on `config.autoAcceptPrompts` AND state ∈ {`watching`, `confirming_idle`} — never fires Enter when respawn is off or auto-accept is disabled.
- AI plan-check prompt broadened to recognize AskUserQuestion / elicitation menus as valid for auto-accept (the verdict name `PLAN_MODE` is preserved for compatibility but now means "auto-accept this selection menu").
- Removed the now-unused `elicitationDetected` field and its assignments.
Two new regression tests cover both the separate-PTY-chunk and single-PTY-chunk cases; the previously misleading "should NOT send Enter when completion message was detected" test was renamed and re-scoped to clarify it tests the **no-menu** path (which still correctly rejects via the pre-filter).
**docs(web): correct `sendPendingCtrlL` comment** — removed the stale "called by foo/bar" note from the dead-call-graph helper after #99.
## 0.7.0
### Minor Changes
- Response viewer & terminal-stability improvements, plus test/error-handling hardening.
- **Copy button on code blocks (#98):** Every fenced code block in the response viewer now has a one-click copy button pinned to its top-right, outside the `<pre>` scroll container so it stays put during horizontal scroll. ASCII diagrams keep their line-wrap toggle alongside it. Copy prefers the async Clipboard API and falls back to a hidden-textarea + `execCommand` path, so it works over plain HTTP (tunnel) too, with a brief ✓/✕ feedback state.
- **Fix: stop auto-sending Ctrl+L from session-selection paths (#99):** A fast page refresh or SSE reconnect could fire two programmatic Ctrl+L (`\x0c`) sends within Claude Code 2.x's "clear conversation" confirmation window, silently wiping the active conversation. Removed the automatic Ctrl+L sends from `selectSession()`, `restoreTerminalSize()`, and the dead `sendPendingCtrlL()` path; redraws now rely on resize/SIGWINCH. User-initiated Ctrl+L still works. Trade-off: an occasional transient stale Ink frame right after refresh that self-heals on the next keypress — far preferable to silent data loss.
- **Test & error-handling hardening (#97):** Repaired route-test harness error rendering via a dedicated `route-error-handler.ts`, and stopped the AI idle/plan checkers from spawning real processes during tests.
## 0.6.12
### Patch Changes
- Fix new-session crash after a tmux upgrade and isolate Codeman sessions on a dedicated tmux socket.
- **Pane file-descriptor limit**: raise `ulimit -Sn` before launching the CLI (in both the spawn and respawn paths) so the newer tmux + macOS launchd combination — which hands panes a low soft `nofile` limit (256) that recent Claude Code refuses to start under — no longer kills every freshly spawned session on startup.
- **Single-socket isolation**: all Codeman-owned tmux sessions now live on a dedicated socket (`tmux -L codeman`, overridable via `CODEMAN_TMUX_SOCKET`), fully separated from the user's default tmux server. The socket name is validated and shell-escaped at every call site.
- **Drop the drift-prone per-session `tmuxSocket` field**: session reconciliation collapses to a single `list-panes` query against the one socket, eliminating live sessions being wrongly marked dead ("session not found") and duplicate "Restored:" tabs. Stale per-session socket tags and duplicate records are cleaned from disk on load (dedup by `muxName`, keeping the real entry over `restored-` placeholders).
- **Route remaining bare-`tmux` call sites through the socket**: the window-size query on re-attach (previously fell back to 120×40 and lost scrollback) and the send-key route (Shift+Enter / Ctrl+Enter newline).
- **SSH chooser scripts** (`tmux-manager.sh`, `tmux-chooser.sh`) route every tmux call through the dedicated socket.
## 0.6.11
### Patch Changes
- Resume Conversation: fixes and folder drill-down.
- **fix(history)**: `decodeProjectKey()` now uses longest-join-first backtracking with on-disk validation, so sibling directories sharing a prefix (e.g. `diary/` vs `diary-app/`) resolve to the correct path. Previously the greedy shortest-match decoder picked the shorter name and bailed, surfacing `$HOME` in the Resume Conversation list and resuming into the wrong folder. Greedy decode is kept as a fallback so history for deleted projects still resolves. (#92)
- **fix(tabs)**: Drop the client-side resurrection of ended-session tabs. The old code cached open tabs in `localStorage` and rebuilt them as grayed-out stubs whenever the server no longer knew them, which left phantom tabs after closing a session on another device. The server is now the single source of truth; legacy `localStorage` keys are purged on init. Net -44 / +6 lines. (#93)
- **feat(history)**: New "View all in this folder" drill-down on Resume Conversation. `GET /api/history/sessions` accepts `projectKey` (validated against `^[A-Za-z0-9_-]+$` before any filesystem access), `offset`, and `limit`; single-folder mode bypasses the 50-cap and returns `{ sessions, total }`. Frontend adds a modal listing 20 sessions per page with a "Show more" pagination button. Modal items omit their own "View all" button to prevent recursive entry points. (#94)
## 0.6.10
### Patch Changes
- ## Security: paste-image endpoint hardening (#90)
Addresses seven findings from the dismissed review of #84. Most exposed in tunneled deployments where `CODEMAN_PASSWORD` is set but the server is reachable beyond localhost.
- **CSRF protection** on `POST /api/sessions/:id/paste-image`. Requires `Origin`/`Referer` to match `req.host`; non-browser clients (no `Origin` and no `Referer`) must send `X-Codeman-CSRF`. Defeats cross-origin `<form enctype="multipart/form-data">` submits that would otherwise plant arbitrary bytes into the victim's `.claude-images/` while their session cookie is live.
- **Magic-byte validation** on uploaded images. Sniffs the first 12 bytes against PNG/JPEG/GIF/WebP/BMP signatures and rejects 415 on mismatch. Polyglot HTML-or-SVG-with-image-MIME no longer round-trips through the endpoint.
- **Symlink-safe writes** on `.claude-images/`. `lstat` before the write, non-recursive `mkdir`, `O_EXCL|O_NOFOLLOW` on file open. A `node_modules` postinstall (or the agent itself) planting `.claude-images -> ~/.ssh/` no longer redirects pastes outside `workingDir`.
- **Multipart parser swap** to `@fastify/multipart` with `limits: { fileSize: 10MB, files: 1, fields: 4 }`. Replaces a hand-rolled boundary scanner that matched the literal boundary anywhere in the body, hard-coded `\r\n` (silently corrupting LF-only clients), and had no part-count cap.
- **Rate limit + GC**: token-bucket (30/min per IP+session) and hourly GC of `paste-*` files older than 7 days from each live session's `.claude-images/`. New `paste-image-gc.ts` started/stopped from `WebServer.start/stop`.
- **Collision-free filenames**: `paste-${Date.now()}-${randomBytes(4)}${ext}`. Two tabs pasting in the same millisecond no longer silently last-write-wins.
- **Bracketed-paste preservation**: text-only paste in `image-input.js` now goes through `terminal.paste(text)` instead of `sendInput(text)`, so xterm preserves `CSI 200~ ... CSI 201~` markers — Claude Code uses them as part of its prompt-injection defenses.
## Fix: duplicate multipart parser conflict
Removed a duplicate multipart content-type parser left behind after the swap above. The duplicate registration conflicted with `@fastify/multipart`'s own parser; uploads now flow through the plugin exclusively.
## WebGL renderer auto-fallback hardening (#91)
Follow-ups on the longtask auto-fallback shipped in #83.
- `PerformanceObserver` is now disconnected on `onContextLoss` as well as on the trip path. Previously the observer outlived its disposed addon after a context loss, holding a closure reference over every longtask the page emitted.
- Thresholds (`200ms / 3 longtasks / 30s window / 5s grace / 7d sticky-disable`) are hoisted to `WEBGL_FALLBACK` in `constants.js`. No more inline literals.
- New `evaluateWebGLLongTaskTrip()` pure helper splits the rolling-window arithmetic from the `PerformanceObserver` callback so the trip math is unit-testable. New `test/webgl-fallback.test.ts` (9 tests, port 3166): trip inside window, no-trip when spread, sub-threshold filtering, stale-entry pruning, cumulative counting across batches, observer-dispose idempotency.
## CI: server boot smoke test
GitHub Actions now boots the server as a final step after typecheck/lint/format. Catches production-only ESM/CJS regressions that `tsx` masks in dev.
## Docs
`CLAUDE.md` frontend-module table updated to include `image-input.js` (overlooked when #84 landed).
## 0.6.9
### Patch Changes
- Terminal renderer hardening, SSE bandwidth cut, image paste, and a security tightening on the new live filter:
- **Multi-primitive yield for write pacing** (#85): replaces six raw `requestAnimationFrame` callsites in the xterm.js write pipeline with a yielding helper that races `requestAnimationFrame`, `setTimeout(50)`, and a tick Worker. Keeps the terminal responsive when the tab is backgrounded or occluded — Chrome's intensive-throttling no longer stalls long writes.
- **WebGL longtask auto-fallback** (#83): a `PerformanceObserver` watches for ≥200ms WebGL frames; three within a 30s window disposes the WebGL addon and falls back to the canvas renderer. Decision is persisted in localStorage for 7 days, and `?webgl=force` clears it.
- **Per-client live SSE subscription filter** (#86): each connected client gets a stable UUID and can narrow its terminal stream to one session via `POST /api/events/subscribe` — no EventSource reconnect on tab switches. Cuts SSE bandwidth roughly N× when N sessions are open. Lifecycle/metadata events (`session:*`, `case:*`, `ralph:*`, `hook:*`) now broadcast to every client so sidebars stay in sync.
- **Image paste and drag-and-drop into the terminal** (#84): `Ctrl+V` and dropped images upload to `POST /api/sessions/:id/paste-image`, save under `${workingDir}/.claude-images/paste-${ts}.${ext}` and type the path into the terminal. Hard 10MB cap, server-generated filename (no traversal), `.svg` deliberately excluded from the allowlist to avoid a same-origin XSS path through `file-raw`.
- **SSE clientId validation**: the per-client identifier introduced in #86 is now constrained to `[A-Za-z0-9_-]{8,64}` at both ingress points. Without this, an authenticated attacker could send another tab's clientId to silently evict it from broadcasts, mutate any clientId's session filter to blackhole the victim's terminal stream, or grow `sseClientsById` unboundedly via long IDs. The subscribe payload is also capped at 64 session entries of ≤128 chars each.
## 0.6.8
### Patch Changes
- Finish the hostname-aware notification plumbing started in 0.6.7 and lock down the recent UI/runtime fixes with regression tests.
- Browser Notification API (OS-level desktop pop-ups, layer 3 of the 5-layer notification system) now uses `${originalTitle}: ${title}` instead of the hardcoded `Codeman:` literal — so multi-host users running Codeman on laptop / dev box / NAS see `codeman:<host>: <event>` consistently across tab title, tab-flash, Web Push, and OS notifications.
- Inline session rename hardened against three corner cases: IME composition commits (Chinese pinyin Enter no longer ships half-composed text as the session name), mid-rename SSE deletion (orphaned `<input>` no longer 404s on blur), and double-fire on stuck settle-once flag (closure-local `settled` boolean replaces the boolean instance flag).
- Test coverage backfilled for two prior shipped fixes:
- `<title>codeman:<host></title>` server-side templating (#82): 8 tests covering default `os.hostname()`, `--title-hostname` override, HTML-escape against `<script>`-style breakout, ampersand non-double-encoding, and template-tail byte-identical invariance.
- tmux size-query helper (#80): 15 tests covering the browser-resize-between-attaches happy path, the query-then-die race, zero/negative/empty/non-numeric output fallbacks, and argv-form/timeout assertions that lock down the no-shell-interpolation guarantee. Inline 14-line query block extracted into a named `queryTmuxWindowSize()` export in `session.ts` so the test surface is a pure function.
- Regression coverage added for `stripInkRedrawBloat` route helper.
- CLAUDE.md and README.md updated to document dual-CLI env-prefix discipline (`CLAUDE_CODE_*` vs `OPENCODE_*`), the `xterm-zerolag-input` published-package side-effect of overlay edits, and the unified hostname prefix across tab title / tab-flash / OS notifications.
## 0.6.7
### Patch Changes
- - **fix(client): preserve inline rename input across tab re-renders** (#81) — Right-click → rename on a session tab no longer loses keystrokes when SSE traffic from sibling sessions triggers a tab re-render. Adds an `_inlineRenameActive` guard at the top of `renderSessionTabs()` and `_fullRenameSessionTabs()` so the in-progress input isn't destroyed mid-typing. Also fixes a latent double-fire of `finishRename` (blur + Enter could both invoke it). Drive-by: safer DOM child clearing in place of `innerHTML = ''`.
- **feat: hostname-aware window title** (#82) — The browser tab title is now `codeman:<hostname>` instead of the bare `Codeman` literal, so users running Codeman on multiple hosts (laptop, dev box, NAS) can tell at a glance which tab points at which backend. New `--title-hostname <name>` CLI flag overrides the detected `os.hostname()` when it's noisy or you want a cosmetic name. The title is templated into the served HTML on first byte (with narrow HTML escaping), so it's correct from the first paint and works without JavaScript. Title-flash logic now respects the per-host title.
- **perf: larger terminal tail on tab switch** — `TERMINAL_TAIL_SIZE` raised from 128KB to 1MB. When switching back to a busy session tab you now get ~8× more scrollback restored immediately.
- **fix: preserve response text in Ink redraw stripping** — `stripInkRedrawBloat()` rewritten from a first-VPA approach to cluster-based detection. The previous algorithm assumed all VPA escapes after the first one belonged to a single redraw region and discarded everything in between, which silently lost 100KB+ of legitimate Claude response text once a render had occurred. The new approach groups VPAs into clusters separated by ≥8KB gaps and only collapses clusters spanning ≥32KB, so streamed response content between redraw bursts is preserved.
- **docs**: `CLAUDE.md` Additional Commands gains the `--title-hostname` row; `README.md` gets a "Hostname-Aware Window Title" subsection under Multi-Session Dashboard.
## 0.6.6
### Patch Changes
- **Terminal scrollback significantly increased** — both the xterm.js viewport and the tmux backing buffer were bottlenecking how far back you could scroll. Three changes:
- `DEFAULT_SCROLLBACK` raised from 20000 → 50000 lines (xterm.js, main terminal). The previous bump from 5000 only helped users with empty localStorage; existing users were stuck on whatever value they first picked up. The loader now treats `DEFAULT_SCROLLBACK` as a floor — if your stored value is below the new minimum, you're raised to it automatically.
- Subagent / teammate terminals (`panels-ui.js`) were stuck at 5000; now use the same `DEFAULT_SCROLLBACK` constant (50000).
- New tmux sessions now run with `history-limit 50000` (tmux defaults to 2000). This matters for hard-reload / re-attach — without it, only the last ~2000 lines survive the round-trip back into a fresh xterm.
**Tmux flicker on session re-attach fixed (PR #80 by @aakhter)**: the PTY now queries the existing tmux window size via `tmux display -p` before spawning, instead of hardcoding 120x40. Previously, every re-attach forced tmux to resize down to 120x40, causing a visible flicker and one frame of scrollback loss. The `-x 120 -y 40` flag was also dropped from `tmux new-session` so the initial size matches the first attaching client. Uses `execFileSync` (not shell) for safety and falls back to 120x40 on any error.
**Docs**: CLAUDE.md now documents two recurring foot-guns — the `xterm-zerolag-input` overlay code is duplicated between `packages/xterm-zerolag-input/src/` and inline inside `src/web/public/app.js`, so any overlay change must touch both; and the COM workflow explicitly includes a post-push `gh run watch` step to confirm CI before considering the release done.
## 0.6.5
### Patch Changes
- **Mobile fix**
- Android virtual keyboard: space character was silently dropped on touch devices using GBoard / SwiftKey / similar IMEs. Root cause: the input-event handler in `terminal-ui.js` treated any whitespace-only textarea value as proof that xterm had already processed the input. A lone space (`' '.trim() === ''`) tripped this guard, so the space was consumed but never forwarded. Now skips only when the textarea is truly empty (or whitespace from a non-space key). Reported and diagnosed by @coolk8 in #79.
**Docs**
- `CLAUDE.md`: added Zod `.optional()`-vs-`null` gotcha (recurring trap from 0.6.3 / 0.6.4 incidents) and a more visible warning against running bare `npm test` (kills the host tmux session).
- `docs/local-echo-overlay-plan.md`: marked SHIPPED, corrected xterm version reference (v5.3.0 → `@xterm/xterm` ^6.0.0).
## 0.6.4
### Patch Changes
+12 -6
View File
@@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| Type check | `tsc --noEmit` |
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
| Format | `npm run format` (check: `npm run format:check`) |
| Single test | `npm test -- test/<file>.test.ts` (or `npx vitest run --config config/vitest.config.ts 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`) — ⚠ **never** run bare `npm test`, see Testing section |
| 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` |
@@ -52,10 +52,11 @@ When user says "COM":
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`
6. **Wait for CI**: after `git push`, find the run with `gh run list -L 1 --json databaseId,headBranch -q '.[0].databaseId'` and watch it with `gh run watch <id> --exit-status`. Confirm all checks pass before considering the release done.
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.4 (must match `package.json`)
**Version**: 0.8.1 (must match `package.json`)
## Project Overview
@@ -76,6 +77,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Task | Command |
|------|---------|
| Dev with TLS | `npx tsx src/index.ts web --https` |
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) |
| Continuous typecheck | `tsc --noEmit --watch` |
| Test coverage | `npm run test:coverage` |
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
@@ -93,6 +95,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
- **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
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -104,7 +110,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|--------|-----------|-------|
| **Entry** | `src/index.ts`, `src/cli.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
@@ -115,7 +121,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (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` | |
| **Frontend** | `src/web/public/app.js` (~3.1K 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`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.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. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
@@ -176,7 +182,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~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.
~130 handlers across 15 route files in `src/web/routes/`: system (36), sessions (28), 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
@@ -211,7 +217,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
**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).
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
## Debugging
+11
View File
@@ -226,6 +226,17 @@ Run **20 parallel sessions** with full visibility — real-time xterm.js termina
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
### Hostname-Aware Window Title
Running Codeman on multiple hosts (laptop, dev box, NAS)? The browser tab title is `codeman:<hostname>` so you can tell which backend each tab points at without clicking in:
```bash
codeman web # codeman:<os.hostname()>
codeman web --title-hostname dev-box # codeman:dev-box (manual override for noisy hostnames)
```
The title is templated into the served HTML on first byte, so it's correct from the very first paint and works without JavaScript. The same hostname prefix is applied to the tab-flash format (`⚠️ (N) codeman:<host>`) and to OS-level desktop notifications (`codeman:<host>: <event>`), so cross-host alerts in the system notification center are also unambiguous.
### Smart Token Management
| Threshold | Action | Result |
@@ -1,3 +1,9 @@
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
> The headline items here were verified resolved: the P0 `{WORKING_DIR}` placeholder
> is now replaced (`plan-orchestrator.ts:431`), and the "~66 dead functions in app.js"
> are gone (app.js was modularized 15K→3K LOC). A fresh `npm run knip` sweep on
> 2026-05-21 found only a handful of unused test helpers. Do not treat this as a live TODO.
# Codebase Cleanup Findings
Compiled from parallel analysis of the entire Codeman codebase by 3 research agents (2026-02-19).
@@ -1,3 +1,9 @@
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
> The "Critical" structural items here are done: `server.ts` 6,736→2,065 LOC,
> `app.js` 15,196→3,083 LOC, `types.ts` 1,443→12 LOC (now a barrel → `src/types/`).
> The phase plans that executed this work are in `docs/archive/phase*-plan.md`.
> Do not treat this as a live TODO; see CLAUDE.md for current architecture.
# Code Structure & Quality Findings
**Date**: 2026-02-28
+6 -4
View File
@@ -1,5 +1,7 @@
# Local Echo Overlay — Implementation Plan
> **Status: SHIPPED.** Implementation lives in `packages/xterm-zerolag-input/src/` (overlay-renderer.ts, prompt-finder.ts, cell-dimensions.ts, zerolag-input-addon.ts) with the embedded copy in `src/web/public/app.js`. This document is retained as historical design context.
## Context
User accesses Codeman remotely from Thailand to Switzerland over Tailscale (~200-300ms RTT).
@@ -18,9 +20,9 @@ redraws. A DOM overlay sits in a separate rendering layer (z-index 7) and doesn'
with Ink's cursor management or screen redraws at all. When Ink redraws (server output arrives),
we simply hide the overlay.
**Why it will look indistinguishable:** We use the DOM renderer (not canvas/WebGL) in our
xterm.js v5.3.0, so both terminal text and overlay text are rendered by the same browser
font engine with identical sub-pixel rendering.
**Why it will look indistinguishable:** We use the DOM renderer (not canvas/WebGL), so both
terminal text and overlay text are rendered by the same browser font engine with identical
sub-pixel rendering. (Originally designed against xterm.js v5.3.0; project now on `@xterm/xterm` ^6.0.0 — the internal `_core._renderService.dimensions` access path still works in v6.)
## Key Technical Details (from research)
@@ -36,7 +38,7 @@ const top = cursorY * dims.css.cell.height; // CSS pixels, relative to .xterm-
- `cursorY` = `terminal.buffer.active.cursorY` (0 to terminal.rows-1, ALREADY viewport-relative)
- No scroll offset math needed
### Cell Dimensions (v5.3.0 — no public API, use internal)
### Cell Dimensions (no public API in v5/v6 — use internal; public in v7+)
```js
const dims = terminal._core._renderService.dimensions;
dims.css.cell.width // e.g., 8.4px
+2033 -2
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.6.4",
"version": "0.8.1",
"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",
@@ -52,6 +52,7 @@
"dependencies": {
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^8.0.0",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
+8 -4
View File
@@ -19,6 +19,10 @@ const PORTS = {
const results = [];
function isCodemanTitle(title) {
return typeof title === 'string' && title.startsWith('codeman:');
}
function logSection(title) {
console.log('\n' + '='.repeat(60));
console.log(` ${title}`);
@@ -88,7 +92,7 @@ async function main() {
const page = await playwrightBrowser.newPage();
await page.goto(`http://localhost:${PORTS.playwright}`);
const title = await page.title();
if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`);
if (!isCodemanTitle(title)) throw new Error(`Expected codeman:<hostname>, got ${title}`);
await page.close();
});
@@ -149,7 +153,7 @@ async function main() {
const page = await puppeteerBrowser.newPage();
await page.goto(`http://localhost:${PORTS.puppeteer}`);
const title = await page.title();
if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`);
if (!isCodemanTitle(title)) throw new Error(`Expected codeman:<hostname>, got ${title}`);
await page.close();
});
@@ -202,7 +206,7 @@ async function main() {
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
await new Promise(r => setTimeout(r, 2000));
const title = agentBrowserJson('get title');
agentBrowserAvailable = title.title === 'Codeman';
agentBrowserAvailable = isCodemanTitle(title.title);
console.log(' Browser launched');
// Test 1: Page load
@@ -210,7 +214,7 @@ async function main() {
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
await new Promise(r => setTimeout(r, 1000));
const title = agentBrowserJson('get title');
if (title.title !== 'Codeman') throw new Error(`Expected Codeman, got ${title.title}`);
if (!isCodemanTitle(title.title)) throw new Error(`Expected codeman:<hostname>, got ${title.title}`);
});
// Test 2: Element selection
+1
View File
@@ -93,6 +93,7 @@ console.log('\n[build] content-hash cache busting');
'ralph-wizard.js',
'api-client.js',
'subagent-windows.js',
'image-input.js',
'vendor/xterm-zerolag-input.js',
];
const manifest = {};
+10 -3
View File
@@ -32,6 +32,13 @@ set -e
CODEMAN_STATE="$HOME/.codeman/state.json"
CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise list-sessions would enumerate the user's default tmux server
# (missing the real Codeman sessions, surfacing unrelated ones).
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# iPhone 17 Pro portrait width (conservative)
MAX_WIDTH=44
@@ -286,7 +293,7 @@ parse_sessions() {
# Get PID from tmux
local pid
pid=$(tmux display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
pid=$("${TMUX_CMD[@]}" display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
SESSION_PIDS+=("$pid")
MUX_NAMES+=("$session_name")
@@ -314,7 +321,7 @@ parse_sessions() {
fi
i=$((i + 1))
done < <(tmux list-sessions 2>/dev/null || true)
done < <("${TMUX_CMD[@]}" list-sessions 2>/dev/null || true)
}
# ============================================================================
@@ -462,7 +469,7 @@ attach_session() {
echo -e "${D}(Ctrl+B D to detach)${R}"
sleep 0.3
tmux attach-session -t "$mux_name"
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
return 0
}
+17 -8
View File
@@ -20,6 +20,13 @@ REVERSE='\033[7m'
# Use the same path as codeman (src/tmux-manager.ts)
SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise this script would talk to the user's default tmux server and never
# see (or could mis-target) Codeman's sessions.
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# Cached data
CACHED_JSON=""
@@ -92,7 +99,7 @@ declare -A ALIVE_CACHE
check_alive() {
local mux_name=$1
if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then
if tmux has-session -t "$mux_name" 2>/dev/null; then
if "${TMUX_CMD[@]}" has-session -t "$mux_name" 2>/dev/null; then
ALIVE_CACHE[$mux_name]=1
else
ALIVE_CACHE[$mux_name]=0
@@ -111,8 +118,10 @@ kill_session() {
local mux_name=$(get_session_field $idx "muxName")
local pid=$(get_session_field $idx "pid")
# SAFETY: Never kill own tmux session
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
# SAFETY: Never kill own tmux session. Queried on the Codeman socket; if run
# from a session on a different socket this returns empty (no match), which
# is fine — you can't be "inside" a Codeman-socket session you didn't attach to.
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then
echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}"
return 1
@@ -120,7 +129,7 @@ kill_session() {
pkill -TERM -P $pid 2>/dev/null
kill -TERM -$pid 2>/dev/null
tmux kill-session -t "$mux_name" 2>/dev/null
"${TMUX_CMD[@]}" kill-session -t "$mux_name" 2>/dev/null
kill -KILL $pid 2>/dev/null
# Remove from JSON
@@ -345,7 +354,7 @@ interactive_menu() {
clear
echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}"
sleep 0.3
tmux attach-session -t "$mux_name"
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
tput civis
need_full_redraw=1
force_refresh
@@ -474,7 +483,7 @@ main() {
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; }
force_refresh
local mux_name=$(get_session_field $(($2-1)) "muxName")
check_alive "$mux_name" && tmux attach-session -t "$mux_name" || echo "Session dead or not found"
check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found"
;;
kill)
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; }
@@ -492,8 +501,8 @@ main() {
;;
kill-all)
force_refresh
# SAFETY: Never kill own tmux session
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
# SAFETY: Never kill own tmux session (queried on the Codeman socket)
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
local killed=0
for ((i=CACHED_COUNT-1; i>=0; i--)); do
local mux_name=$(get_session_field $i "muxName")
+16 -9
View File
@@ -64,20 +64,27 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
const VERDICT_PATTERN = /^\s*(PLAN_MODE|NOT_PLAN_MODE)\b/i;
/** The prompt sent to the AI plan checker */
const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a PLAN MODE APPROVAL PROMPT or not.
const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a NUMBERED SELECTION MENU that is waiting for the user to press Enter on the highlighted default option.
A plan mode approval prompt is a numbered selection menu that Claude Code shows when it wants the user to approve a plan before proceeding. It typically has these characteristics:
A qualifying menu has all of these characteristics:
- A numbered list of options (e.g., "1. Yes", "2. No", "3. Type your own")
- A selection indicator arrow (❯ or >) pointing to one of the options
- Text asking for approval like "Would you like to proceed?" or "Ready to implement?"
- The prompt appears at the BOTTOM of the output (most recent content)
- A selection indicator arrow (❯ or >) pointing to one of the options (the default)
- The menu appears at the BOTTOM of the output (most recent content)
- It is asking the user to choose, not just displaying numbered information
NOT a plan mode prompt:
This includes BOTH:
- Plan-mode approval prompts ("Would you like to proceed?" / "Ready to implement?")
- AskUserQuestion / elicitation dialogs (Claude Code's numbered question menus)
NOT a qualifying menu:
- Claude actively working (spinners, "Thinking", tool execution)
- A completed response with no selection menu
- An AskUserQuestion/elicitation dialog (different format, free-text input)
- A completed response with no selection menu visible
- A free-text input field with no numbered options
- A numbered LIST in the assistant's prose with no selection arrow
- Network lag or mid-output pause
- Any state without a visible numbered selection menu
- Any state without a visible selector arrow on a numbered option
The verdict name PLAN_MODE is historical — it now means "auto-accept this selection menu by pressing Enter on the default".
Terminal output (most recent at bottom):
---
+3 -1
View File
@@ -485,16 +485,18 @@ program
.description('Start the web interface')
.option('-p, --port <port>', 'Port to listen on', '3000')
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
.action(async (options) => {
const { startWebServer } = await import('./web/server.js');
const port = parseInt(options.port, 10);
const https = !!options.https;
const titleHostname = options.titleHostname;
const protocol = https ? 'https' : 'http';
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
try {
const server = await startWebServer(port, https);
const server = await startWebServer(port, https, false, titleHostname);
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
if (https) {
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
+9 -1
View File
@@ -14,6 +14,7 @@ import type {
ClaudeMode,
SessionMode,
OpenCodeConfig,
EffortLevel,
} from './types.js';
/**
@@ -63,8 +64,10 @@ 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. */
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel;
}
/** Options for respawning a dead pane. */
@@ -81,6 +84,8 @@ export interface RespawnPaneOptions {
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
envOverrides?: Record<string, string>;
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel;
}
/**
@@ -98,6 +103,9 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
readonly backend: 'tmux';
/** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */
readonly muxSocket: string;
// ========== Lifecycle ==========
/**
+33 -25
View File
@@ -513,7 +513,7 @@ const DEFAULT_CONFIG: RespawnConfig = {
sendInit: true, // send /init after /clear
completionConfirmMs: 10000, // 10 seconds of silence after completion message
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
autoAcceptPrompts: true, // auto-accept numbered selection menus (plan approvals + question dialogs)
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
aiIdleCheckEnabled: true, // use AI to confirm idle state
aiIdleCheckModel: AI_CHECK_MODEL,
@@ -623,9 +623,6 @@ export class RespawnController extends EventEmitter {
/** Whether any terminal output has been received since start/last-auto-accept */
private hasReceivedOutput: boolean = false;
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
private elicitationDetected: boolean = false;
// ========== Hook-Based Detection State (Layer 0 - Highest Priority) ==========
/** Whether a Stop hook was received (definitive idle signal from Claude Code) */
@@ -1369,7 +1366,12 @@ export class RespawnController extends EventEmitter {
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
// Don't cancel the auto-accept timer here — modern Claude Code emits "Worked for X"
// immediately before a plan-approval menu, and the auto-accept pre-filter is
// responsible for distinguishing menu-present from menu-absent. Cancelling here
// would silently block auto-accept for every plan approval and AskUserQuestion
// dialog. If no menu is in the buffer, the pre-filter rejects and the
// completion-confirm timer (started below) drives the normal idle flow.
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// In watching state, start completion confirmation timer
@@ -1417,7 +1419,6 @@ export class RespawnController extends EventEmitter {
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;
@@ -2222,11 +2223,11 @@ export class RespawnController extends EventEmitter {
* @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;
// Allow auto-accept from 'watching' AND 'confirming_idle'. The latter is reached
// when "Worked for X" was detected — which Claude Code now emits in the same PTY
// burst as a plan-approval menu. `sendAutoAcceptEnter()` self-transitions back to
// 'watching' before sending Enter. Reject any other state (respawn cycle, etc.).
if (this._state !== 'watching' && this._state !== 'confirming_idle') return false;
// Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return false;
@@ -2234,15 +2235,15 @@ export class RespawnController extends EventEmitter {
// 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;
}
// Note: completionMessageTime and elicitationDetected used to block here, but both
// legitimately co-occur with selection menus (Claude Code emits "Worked for X"
// before plan approvals, and AskUserQuestion fires the elicitation hook). The
// pre-filter below is the authoritative gate for "is there a numbered menu?".
// Stage 1: Pre-filter — check if buffer looks like plan mode
// Stage 1: Pre-filter — check if buffer looks like a numbered selection menu
// (covers both plan-mode approvals and AskUserQuestion dialogs)
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
this.log('Skipping auto-accept: pre-filter did not match selection-menu patterns');
return false;
}
@@ -2308,8 +2309,10 @@ export class RespawnController extends EventEmitter {
}
if (result.verdict === 'PLAN_MODE') {
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
if (this._state !== 'watching') {
// Don't send Enter if state moved into a respawn cycle while the check ran.
// 'watching' and 'confirming_idle' are both valid — sendAutoAcceptEnter()
// self-transitions to 'watching' before sending.
if (this._state !== 'watching' && this._state !== 'confirming_idle') {
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
return;
}
@@ -2368,13 +2371,18 @@ export class RespawnController extends EventEmitter {
/**
* Signal that an elicitation dialog (AskUserQuestion) was detected via hook.
* This prevents auto-accept from firing, since the user needs to make a selection.
* The flag is cleared when working patterns are detected (new turn starts).
* Used as a positive hint that a numbered selection menu is about to render —
* we restart the auto-accept timer so the pre-filter gets a fresh shot at it
* once the menu finishes drawing. The actual gate is `isPlanModePreFilterMatch()`
* plus (optionally) the AI plan check; this hook just primes the timer.
* No-op if respawn isn't `'watching'`/`'confirming_idle'` or `autoAcceptPrompts`
* is off, so this can never fire Enter when the user has disabled auto-accept.
*/
signalElicitation(): void {
this.elicitationDetected = true;
this.cancelAutoAcceptTimer();
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle');
this.log('Elicitation dialog signaled - auto-accept will trigger if pre-filter matches');
if (this.config.autoAcceptPrompts && (this._state === 'watching' || this._state === 'confirming_idle')) {
this.startAutoAcceptTimer();
}
}
/**
+23 -2
View File
@@ -8,7 +8,8 @@
* @module session-cli-builder
*/
import type { ClaudeMode } from './types.js';
import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
/**
@@ -31,6 +32,23 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str
}
}
/**
* Build the CLI args carrying the effort level as a SOFT default (switchable
* in-session via /effort). The CLAUDE_CODE_EFFORT_LEVEL env var is deliberately
* avoided — it hard-locks effort and blocks in-session `/effort` switching.
*
* Two carriers are needed because neither covers all levels:
* - regular levels (incl. `max`) → `--effort <level>` (the settings `effortLevel`
* key is enum(["low","medium","high","xhigh"]) with .catch(undefined), so `max`
* would be SILENTLY dropped there)
* - `ultracode` → `--settings '{"ultracode":true}'` (its own boolean settings key,
* claude >= 2.1.154; rejected by the --effort flag)
*/
export function buildEffortCliArgs(effort?: EffortLevel): string[] {
if (!effort || !isEffortLevel(effort)) return [];
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
}
/**
* Build args for an interactive Claude CLI session (direct PTY, non-mux fallback).
*
@@ -38,16 +56,19 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str
* @param claudeMode - Permission mode for the CLI
* @param model - Optional model override (e.g., 'opus', 'sonnet')
* @param allowedTools - Optional comma-separated allowed tools list
* @param effort - Optional effort level, injected via --settings (overridable in-session)
* @returns Array of CLI arguments
*/
export function buildInteractiveArgs(
sessionId: string,
claudeMode: ClaudeMode,
model?: string,
allowedTools?: string
allowedTools?: string,
effort?: EffortLevel
): string[] {
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
if (model) args.push('--model', model);
args.push(...buildEffortCliArgs(effort));
return args;
}
+70 -8
View File
@@ -29,7 +29,7 @@
*/
import { EventEmitter } from 'node:events';
import { execSync } from 'node:child_process';
import { execSync, execFileSync } from 'node:child_process';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
import {
@@ -42,9 +42,11 @@ import {
NiceConfig,
DEFAULT_NICE_CONFIG,
getErrorMessage,
isEffortLevel,
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
type EffortLevel,
} from './types.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
@@ -121,6 +123,44 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
const DEFAULT_PTY_COLS = 120;
const DEFAULT_PTY_ROWS = 40;
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
/**
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
* client can spawn at the same size and avoid the resize-flicker / scrollback
* loss documented in #80. Returns `{ cols: 120, rows: 40 }` on any failure
* (tmux dead, muxName unknown, malformed output) — caller never has to
* differentiate "tmux unreachable" from "size 120x40".
*
* `socket` MUST be the same dedicated socket the session lives on (`mux.muxSocket`);
* querying the default server would never find the session and silently fall back.
*
* Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so
* a hostile session name can't inject options.
*/
export function queryTmuxWindowSize(muxName: string, socket: string): { cols: number; rows: number } {
try {
const sizeStr = execFileSync(
'tmux',
['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'],
{
timeout: TMUX_DISPLAY_TIMEOUT_MS,
encoding: 'utf8',
}
).trim();
const [w, h] = sizeStr.split(' ').map(Number);
if (w > 0 && h > 0) {
return { cols: w, rows: h };
}
} catch {
/* fall back below */
}
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
}
/**
* Represents a JSON message from Claude CLI's stream-json output format.
* Messages are newline-delimited JSON objects parsed from PTY output.
@@ -273,10 +313,15 @@ 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.
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
// at spawn, preserved across respawns via persisted state. Not written to .claude/settings.local.json.
private _envOverrides: Record<string, string> | undefined;
// Claude CLI effort level — injected as a `--settings` soft default at spawn so the
// user can still switch in-session via /effort (incl. ultracode). Never carried as
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
private _effort: EffortLevel | undefined;
// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';
@@ -338,6 +383,8 @@ export class Session extends EventEmitter {
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
}
) {
super();
@@ -385,9 +432,19 @@ export class Session extends EventEmitter {
this._openCodeConfig = config.openCodeConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk)
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
// and never export it as an env var again. Explicit config.effort wins over legacy.
if (config.envOverrides && Object.keys(config.envOverrides).length > 0) {
this._envOverrides = { ...config.envOverrides };
const { CLAUDE_CODE_EFFORT_LEVEL: legacyEffort, ...restOverrides } = config.envOverrides;
this._envOverrides = Object.keys(restOverrides).length > 0 ? restOverrides : undefined;
if (legacyEffort && isEffortLevel(legacyEffort)) {
this._effort = legacyEffort;
}
}
if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort;
}
// Initialize task tracker and forward events (store handlers for cleanup)
@@ -809,6 +866,7 @@ export class Session extends EventEmitter {
cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// 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
@@ -946,11 +1004,13 @@ export class Session extends EventEmitter {
}
// Attach to the mux session via PTY
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color',
cols: 120,
rows: 40,
cols: ptyCols,
rows: ptyRows,
cwd: this.workingDir,
env: buildMuxAttachEnv(),
});
@@ -998,6 +1058,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
},
createSessionOptions: {
sessionId: this.id,
@@ -1011,6 +1072,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
},
spawnErrLabel: 'mux attachment',
});
@@ -1080,7 +1142,7 @@ export class Session extends EventEmitter {
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools);
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
this.ptyProcess = pty.spawn('claude', args, {
name: 'xterm-256color',
cols: 120,
+227 -80
View File
@@ -39,7 +39,9 @@ import {
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
type EffortLevel,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
import type {
TerminalMultiplexer,
@@ -71,6 +73,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
/**
* SAFETY: Test mode detection.
* When running under vitest (VITEST env var is set automatically),
@@ -98,6 +103,12 @@ 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+)$/;
/** Dedicated tmux socket for new Codeman-owned sessions. */
const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman';
/** Regex to validate tmux socket names passed to `tmux -L`. */
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
/**
* Separator used in `tmux list-panes -F` output between session name and pid.
*
@@ -114,6 +125,19 @@ 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}`;
/**
* 构建 pane 启动前的 nofile 修复命令。
*
* macOS launchd/tmux 组合有时会让 pane 继承 256 的 soft nofile;
* 新版 Claude Code 会在这种环境下直接退出。这里避免使用 $变量
* 或命令替换,因为 fullCmd 目前经由双引号 bash -c 传递,外层
* shell 会提前展开它们。
*/
export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT): string {
const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT;
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
}
/**
* 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.
@@ -161,6 +185,31 @@ function isValidPath(path: string): boolean {
return SAFE_PATH_PATTERN.test(path);
}
// ===========================================================================
// Single-socket architecture: ALL Codeman sessions live on one dedicated tmux
// socket (`tmux -L codeman`), isolated from the user's default tmux server.
// The socket name is a process-wide constant (env-overridable for test/multi-
// instance isolation) — it is never stored per-session, so it cannot drift.
// ===========================================================================
/**
* Resolve the process-wide Codeman tmux socket name. Always returns a valid
* name: `CODEMAN_TMUX_SOCKET` env override if safe, else the built-in default.
*/
function resolveConfiguredTmuxSocket(): string {
const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
return DEFAULT_CODEMAN_TMUX_SOCKET;
}
return raw;
}
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
function tmuxCommand(socket: string): string {
return `tmux -L ${shellescape(socket)}`;
}
/**
* Build Claude CLI permission flags for the tmux command string.
* Validates allowedTools to prevent command injection.
@@ -212,6 +261,20 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string {
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
*/
/**
* Build the shell fragment carrying the effort level as a SOFT default
* (see buildEffortCliArgs — `--effort <level>` for regular levels incl. max,
* `--settings '{"ultracode":true}'` for ultracode; deliberately not the
* CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching).
*
* Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside
* buildEffortCliArgs, so the single-quoted values contain no user-controlled characters.
*/
function buildEffortSettingsFlag(effort?: EffortLevel): string {
const [flag, value] = buildEffortCliArgs(effort);
return flag && value ? ` ${flag} '${value}'` : '';
}
function buildSpawnCommand(options: {
mode: SessionMode;
sessionId: string;
@@ -220,11 +283,13 @@ function buildSpawnCommand(options: {
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
resumeSessionId?: string;
effort?: EffortLevel;
}): 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 effortFlag = buildEffortSettingsFlag(options.effort);
// 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.
@@ -232,11 +297,11 @@ function buildSpawnCommand(options: {
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
if (safeResumeId) {
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
return `${resumeCmd} || ${fallbackCmd}`;
}
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
}
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
@@ -248,7 +313,7 @@ function buildSpawnCommand(options: {
* Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history.
*/
function setOpenCodeEnvVars(muxName: string): void {
function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
for (const key of sensitiveVars) {
const val = process.env[key];
@@ -256,7 +321,7 @@ function setOpenCodeEnvVars(muxName: string): void {
// Shell-escape: wrap in single quotes, escape any inner single quotes
const escaped = val.replace(/'/g, "'\\''");
try {
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -272,7 +337,7 @@ function setOpenCodeEnvVars(muxName: string): void {
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
*/
function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): void {
function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void {
if (!config) return;
let jsonContent: string | undefined;
@@ -303,7 +368,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
if (jsonContent) {
const escaped = jsonContent.replace(/'/g, "'\\''");
try {
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -336,6 +401,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
readonly backend = 'tmux' as const;
private sessions: Map<string, MuxSession> = new Map();
private readonly tmuxSocket = resolveConfiguredTmuxSocket();
private statsInterval: NodeJS.Timeout | null = null;
private mouseSyncInterval: NodeJS.Timeout | null = null;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
@@ -351,6 +417,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */
get muxSocket(): string {
return this.tmuxSocket;
}
private tmux(): string {
return tmuxCommand(this.tmuxSocket);
}
// Load saved sessions from disk (NEVER called in test mode)
private loadSessions(): void {
if (IS_TEST_MODE) return;
@@ -360,8 +435,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
const data = JSON.parse(content);
if (Array.isArray(data)) {
// Dedup by muxName: one live tmux session must map to exactly one
// tracked entry. A per-session socket-tag mismatch could historically
// let the same session be tracked twice — once under its real UUID and
// once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
// Single-socket unification removed that failure mode; this pass stays
// to clean any stale duplicates already on disk. Keep the real (UUID)
// entry and drop placeholder twins.
let dropped = 0;
const keptByMuxName = new Map<string, string>(); // muxName -> kept sessionId
for (const session of data) {
// Strip the obsolete per-session tmuxSocket tag (now a process-wide
// constant). Left in place it would be written back by saveSessions()
// and linger on disk as a zombie field forever.
delete (session as { tmuxSocket?: unknown }).tmuxSocket;
const muxName: string | undefined = session.muxName;
const priorId = muxName ? keptByMuxName.get(muxName) : undefined;
if (priorId) {
const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-');
const priorIsPlaceholder = priorId.startsWith('restored-');
// Drop the incoming unless it's the real twin of a placeholder we kept.
if (incomingIsPlaceholder || !priorIsPlaceholder) {
dropped++;
continue;
}
this.sessions.delete(priorId);
dropped++;
}
this.sessions.set(session.sessionId, session);
if (muxName) keptByMuxName.set(muxName, session.sessionId);
}
// Persist the cleaned list so the stale duplicates don't reload.
if (dropped > 0) {
console.log(`[TmuxManager] Dropped ${dropped} duplicate mux session record(s) on load`);
this.saveSessions();
}
}
}
@@ -429,6 +536,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* shell-metachar injection even if upstream schema check is bypassed.
*/
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): void {
// Legacy cleanup: pre-0.7.2 set CLAUDE_CODE_EFFORT_LEVEL via setenv, which persists
// on the tmux session and hard-locks /effort switching in every respawned pane.
// Effort now flows as a `--settings` soft default (see buildEffortSettingsFlag),
// so unconditionally unset the stale var before applying current overrides.
try {
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} -u CLAUDE_CODE_EFFORT_LEVEL`, {
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical — var may not exist */
}
if (!envOverrides) return;
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
for (const [key, value] of Object.entries(envOverrides)) {
@@ -438,7 +557,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
continue;
}
try {
execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
@@ -471,8 +590,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* (not visible in ps output or tmux history, inherited by panes).
*/
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
const tmuxCmd = this.tmux();
setOpenCodeEnvVars(tmuxCmd, muxName);
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
}
/**
@@ -492,6 +612,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig,
resumeSessionId,
envOverrides,
effort,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
@@ -538,6 +659,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
resumeSessionId,
effort,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -545,7 +667,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// Build the full command to run inside tmux
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
// Create tmux session in three steps to handle cold-start (no server running)
// and avoid the race where the command exits before remain-on-exit is set:
@@ -556,7 +678,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ...process.env };
delete cleanEnv.TMUX;
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, {
execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
cwd: workingDir,
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
@@ -565,7 +687,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Set remain-on-exit now that the server is running — must be before respawn-pane
try {
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
@@ -584,7 +706,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
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)}`, {
execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
@@ -598,23 +720,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// It gets enabled dynamically when panes are split (agent teams).
const configPromises: Promise<void>[] = [
// Disable tmux status bar — Codeman's web UI provides session info
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {})
.catch(() => {
/* Non-critical — session still works with status bar */
}),
// Override global remain-on-exit with session-level setting
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {})
.catch(() => {
/* Already set globally as fallback */
}),
// Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Matches the xterm-side default in constants.js.
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {})
.catch(() => {
/* Non-critical — falls back to tmux default */
}),
];
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
if (!this.trueColorConfigured) {
configPromises.push(
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {
this.trueColorConfigured = true;
})
@@ -671,7 +800,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
@@ -697,7 +826,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false;
try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
@@ -724,6 +853,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig,
resumeSessionId,
envOverrides,
effort,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -744,10 +874,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
resumeSessionId,
effort,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
@@ -758,7 +889,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// 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)}`, {
await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
});
// Wait for the respawned process to start
@@ -776,7 +907,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false;
try {
execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, {
execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -916,7 +1047,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Strategy 3: Kill tmux session by name
try {
execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -981,26 +1112,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dead: string[] = [];
const discovered: string[] = [];
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls)
let activeSessions = new Map<string, number>();
// Single batched query against the one socket Codeman owns. With a single
// socket a session's location is a constant, so there is no per-session
// socket tag to reconcile and no cross-socket ambiguity that could mark a
// live session dead (the root cause of vanished/duplicate tabs).
let active: Map<string, number>;
try {
const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
activeSessions = parsePaneList(output);
active = parsePaneList(output);
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
active = new Map();
}
// Check known sessions against the batch result (O(1) map lookup instead of subprocess per session)
// Check tracked sessions against the live pane list.
for (const [sessionId, session] of this.sessions) {
const pid = activeSessions.get(session.muxName);
const pid = active.get(session.muxName);
if (pid !== undefined) {
alive.push(sessionId);
if (pid !== session.pid) {
session.pid = pid;
}
if (pid !== session.pid) session.pid = pid;
} else {
dead.push(sessionId);
this.sessions.delete(sessionId);
@@ -1008,13 +1141,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Discover unknown codeman/claudeman sessions from the same batch result
// Discover untracked codeman/claudeman sessions on our socket. Dedup by
// muxName (globally unique) so a name we already track never spawns a
// second "Restored:" entry.
const knownMuxNames = new Set<string>();
for (const session of this.sessions.values()) {
knownMuxNames.add(session.muxName);
}
for (const [sessionName, pid] of activeSessions) {
for (const [sessionName, pid] of active) {
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
if (knownMuxNames.has(sessionName)) continue;
@@ -1031,6 +1166,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
name: `Restored: ${sessionName}`,
};
this.sessions.set(sessionId, session);
knownMuxNames.add(sessionName);
discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
}
@@ -1051,19 +1187,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const psOutput = (
await execAsync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
let childCount = 0;
try {
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const childOutput = (
await execAsync(`pgrep -P ${session.pid} | wc -l`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
childCount = parseInt(childOutput, 10) || 0;
} catch {
// No children or command failed
@@ -1100,13 +1240,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 1: Get descendant PIDs
const descendantMap = new Map<number, number[]>();
const pgrepOutput = execSync(
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
).trim();
const pgrepOutput = (
await execAsync(
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
)
).stdout.trim();
for (const line of pgrepOutput.split('\n')) {
const [pidStr, childrenStr] = line.split(':');
@@ -1131,10 +1273,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 3: Single ps call
const pidArray = Array.from(allPids);
if (pidArray.length > 0) {
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const psOutput = (
await execAsync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
const processStats = new Map<number, { rss: number; cpu: number }>();
for (const line of psOutput.split('\n')) {
@@ -1222,11 +1366,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
clearInterval(this.mouseSyncInterval);
}
this.mouseSyncInterval = setInterval(() => {
this.mouseSyncInterval = setInterval(async () => {
if (IS_TEST_MODE) return;
for (const session of this.sessions.values()) {
const panes = this.listPanes(session.muxName);
const panes = await this.listPanes(session.muxName);
const count = panes.length;
if (count === 0) continue;
@@ -1235,12 +1379,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Pane count changed — toggle mouse mode
if (count > 1) {
if (this.enableMouseMode(session.muxName)) {
if (await this.enableMouseMode(session.muxName)) {
this.lastPaneCount.set(session.muxName, count);
}
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
} else {
if (this.disableMouseMode(session.muxName)) {
if (await this.disableMouseMode(session.muxName)) {
this.lastPaneCount.set(session.muxName, count);
}
}
@@ -1338,21 +1482,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission.
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
// Text only, no Enter
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (hasCarriageReturn) {
// Enter only
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
}
@@ -1371,7 +1515,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Allows clicking to select panes in agent team split-pane layouts.
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
*/
enableMouseMode(muxName: string): boolean {
async enableMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
@@ -1379,7 +1523,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`tmux set-option -t "${muxName}" mouse on`, {
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1395,7 +1539,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Disable mouse mode for an existing tmux session.
* Restores native xterm.js text selection and browser clipboard copy.
*/
disableMouseMode(muxName: string): boolean {
async disableMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
@@ -1403,7 +1547,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`tmux set-option -t "${muxName}" mouse off`, {
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1420,9 +1564,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Called by TeamWatcher when teammates spawn/despawn panes.
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
*/
syncMouseMode(muxName: string): boolean {
async syncMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
const panes = this.listPanes(muxName);
const panes = await this.listPanes(muxName);
if (panes.length > 1) {
return this.enableMouseMode(muxName);
} else {
@@ -1434,7 +1578,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* List all panes in a tmux session.
* Returns structured info for each pane.
*/
listPanes(muxName: string): PaneInfo[] {
async listPanes(muxName: string): Promise<PaneInfo[]> {
if (IS_TEST_MODE) return [];
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
@@ -1442,10 +1586,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const output = execSync(
`tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const output = (
await execAsync(
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
).stdout.trim();
return output
.split('\n')
@@ -1482,27 +1628,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
const tmux = this.tmux();
try {
const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1533,7 +1680,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1565,7 +1712,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1593,7 +1740,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1609,7 +1756,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
getAttachArgs(muxName: string): string[] {
return ['attach-session', '-t', muxName];
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
}
isAvailable(): boolean {
+17
View File
@@ -40,6 +40,21 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedToo
/** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode';
/**
* Valid Claude CLI effort levels (claude >= 2.1.154).
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
* separate `ultracode` settings key rather than an `effortLevel` value.
*/
export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] as const;
/** Claude CLI effort level for new sessions (soft default, switchable via /effort in-session) */
export type EffortLevel = (typeof EFFORT_LEVELS)[number];
/** Type guard: is the string a valid EffortLevel? */
export function isEffortLevel(value: string | undefined): value is EffortLevel {
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
}
/** OpenCode session configuration */
export interface OpenCodeConfig {
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
@@ -145,6 +160,8 @@ export interface SessionState {
openCodeConfig?: OpenCodeConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
}
/**
+54
View File
@@ -0,0 +1,54 @@
/**
* @fileoverview Event-loop lag monitor.
*
* Node is single-threaded: any synchronous work (e.g. a blocking `execSync`)
* freezes the whole event loop, so the HTTP server stops answering on its port
* while the process stays alive and other ports are unaffected. Such stalls
* self-heal and never restart the process, so a periodic loopback healthcheck
* misses them entirely — they leave no trace.
*
* This monitor samples how late a fixed-interval timer actually fires versus when
* it was scheduled; the excess is time the loop was blocked. When that exceeds a
* threshold it logs the measured stall, turning otherwise-invisible "port briefly
* unreachable" incidents into a timestamped, quantified log line.
*
* @module utils/event-loop-monitor
*/
export interface EventLoopMonitorHandle {
stop(): void;
}
/**
* Start sampling event-loop lag.
*
* @param sampleMs How often to sample (and the baseline interval lag is measured against).
* @param thresholdMs Only stalls at or above this many ms are logged (noise floor).
* @param log Sink for stall reports; defaults to console.warn (lands in the web log).
*/
export function startEventLoopMonitor(
sampleMs = 1000,
thresholdMs = 1000,
log: (msg: string) => void = (m) => console.warn(m)
): EventLoopMonitorHandle {
let last = performance.now();
const timer = setInterval(() => {
const now = performance.now();
// Lag = elapsed beyond the scheduled interval = time the loop was blocked.
const lag = Math.round(now - last - sampleMs);
if (lag >= thresholdMs) {
log(`[EventLoopLag] event loop blocked ~${lag}ms (at ${new Date().toISOString()})`);
}
last = now;
}, sampleMs);
// Never keep the process alive solely for this monitor.
timer.unref?.();
return {
stop() {
clearInterval(timer);
},
};
}
+2
View File
@@ -9,6 +9,8 @@
export { BufferAccumulator } from './buffer-accumulator.js';
export { CleanupManager } from './cleanup-manager.js';
export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { startEventLoopMonitor } from './event-loop-monitor.js';
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
+69
View File
@@ -0,0 +1,69 @@
/**
* @fileoverview Periodic GC for paste-image files.
*
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
* under {workingDir}/.claude-images/. The route only triggers cleanup on
* killMux=true session deletion, so long-lived sessions can fill disk under
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
* older than MAX_AGE_MS from each live session's image dir on an interval.
*
* Conservative defaults — only files matching the `paste-` prefix are
* considered, and we lstat (not stat) so a planted symlink cannot escape the
* image dir.
*/
import fs from 'node:fs/promises';
import { join } from 'node:path';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
export async function sweepPasteImagesOnce(
ctx: Pick<SessionPort, 'sessions'>,
now: number = Date.now()
): Promise<{ scanned: number; deleted: number }> {
const cutoff = now - MAX_AGE_MS;
let scanned = 0;
let deleted = 0;
for (const session of ctx.sessions.values()) {
const dir = join(session.workingDir, '.claude-images');
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
continue; // dir absent — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
} catch {
// best-effort: skip permission/race errors silently
}
}
}
return { scanned, deleted };
}
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
const initial = setTimeout(() => {
void sweepPasteImagesOnce(ctx);
}, INITIAL_DELAY_MS);
const interval = setInterval(() => {
void sweepPasteImagesOnce(ctx);
}, SWEEP_INTERVAL_MS);
if (typeof initial.unref === 'function') initial.unref();
if (typeof interval.unref === 'function') interval.unref();
return (): void => {
clearTimeout(initial);
clearInterval(interval);
};
}
+192 -69
View File
@@ -286,6 +286,12 @@ class CodemanApp {
this.totalTokens = 0;
this.globalStats = null; // Global token/cost stats across all sessions
this.eventSource = null;
// Stable per-page client ID — lets the server target this connection
// for live filter updates (POST /api/events/subscribe) without forcing
// an SSE reconnect on session switches.
this._clientId = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null;
@@ -617,14 +623,66 @@ class CodemanApp {
this._webglAddon = new WebglAddon.WebglAddon();
this._webglAddon.onContextLoss(() => {
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
this._webglAddon.dispose();
_crashDiag.log('WEBGL_LOST');
this._disableWebGLSticky('context-lost');
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
});
this.terminal.loadAddon(this._webglAddon);
console.log('[CRASH-DIAG] WebGL renderer enabled');
this._installWebGLLongTaskGuard();
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
}
/**
* Watch for sustained main-thread stalls that indicate WebGL/GPU trouble.
* After WEBGL_FALLBACK.LONGTASK_COUNT long tasks (>=LONGTASK_MS each) within
* WINDOW_MS, dispose the WebGL addon and persist a sticky disable so
* subsequent reloads also use the DOM renderer. GRACE_MS skips initial-load
* stalls. Force-re-enable: ?webgl=force.
*/
_installWebGLLongTaskGuard() {
if (typeof PerformanceObserver === 'undefined' || this._webglLongTaskObserver) return;
const installedAt = performance.now();
const recent = [];
try {
this._webglLongTaskObserver = new PerformanceObserver((list) => {
if (!this._webglAddon) return;
const now = performance.now();
if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return;
if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) {
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/${WEBGL_FALLBACK.WINDOW_MS}ms) — falling back to canvas renderer`);
_crashDiag.log(`WEBGL_FALLBACK: ${recent.length}`);
this._disableWebGLSticky('long-tasks');
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
}
});
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
} catch { /* longtask not supported */ }
}
/**
* Disconnect the WebGL longtask observer. Idempotent. Called from the trip
* path, the onContextLoss handler, and any future terminal-teardown path —
* the observer outlives its addon otherwise, holding a closure reference
* over `this` for every long task the page emits.
*/
_disposeWebGLObserver() {
if (!this._webglLongTaskObserver) return;
try { this._webglLongTaskObserver.disconnect(); } catch {}
this._webglLongTaskObserver = null;
}
_disableWebGLSticky(reason) {
try {
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
} catch {}
}
// ═══════════════════════════════════════════════════════════════
// Event Listeners (Keyboard Shortcuts, Resize, Beforeunload)
// ═══════════════════════════════════════════════════════════════
@@ -696,6 +754,28 @@ class CodemanApp {
// SSE Connection
// ═══════════════════════════════════════════════════════════════
/**
* POST a live subscription update so the server filters terminal events
* to the given session(s) for this client. Fire-and-forget — failures
* are non-fatal because we'll still get every event we don't want
* (just at higher cost), and the next reconnect carries the filter via
* the SSE query string.
*/
_updateSseSubscription(sessionId) {
try {
const body = JSON.stringify({
clientId: this._clientId,
sessions: sessionId ? [sessionId] : null,
});
fetch('/api/events/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch(() => { /* non-fatal */ });
} catch { /* non-fatal */ }
}
connectSSE() {
// Check if browser is offline
if (!navigator.onLine) {
@@ -725,7 +805,13 @@ class CodemanApp {
this.setConnectionStatus('reconnecting');
}
this.eventSource = new EventSource('/api/events');
// Build URL with stable client ID and (if known) the active-session
// filter so the server only streams session:terminal events for the
// session we're rendering. Lifecycle/metadata events are sent globally
// regardless of filter (server side).
const _sseParams = new URLSearchParams({ clientId: this._clientId });
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
// Store all event listeners for cleanup on reconnect
const listeners = [];
@@ -1024,9 +1110,10 @@ class CodemanApp {
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
_renderMarkdown(text) {
const src = text || '';
if (typeof marked !== 'undefined' && marked.parse) {
try {
const prepared = this._preprocessAsciiArt(text);
const prepared = this._preprocessAsciiArt(src);
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
// Wrap tables in a horizontal-scroll container so they overflow gracefully
// on mobile without collapsing into block-level cells.
@@ -1042,29 +1129,47 @@ class CodemanApp {
const DIAGRAM_CHAR = /[─-╿▀-▟]/;
const tmpl = document.createElement('template');
tmpl.innerHTML = html;
// Every fenced code block gets a positioned wrapper with an action
// toolbar pinned to its top-right corner. The toolbar lives OUTSIDE the
// <pre> scroll container so its buttons stay put during horizontal
// scroll. All blocks get a one-click copy button; ASCII diagrams keep
// the additional line-wrap toggle.
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
const pre = code.parentElement;
pre.classList.add('rv-diagram');
const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
const wrap = document.createElement('div');
wrap.className = 'rv-diagram-wrap';
wrap.className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap';
const btn = document.createElement('button');
btn.className = 'rv-wrap-toggle';
btn.type = 'button';
btn.setAttribute('aria-label', 'Toggle line wrapping');
btn.setAttribute('title', 'Toggle line wrapping');
const actions = document.createElement('div');
actions.className = 'rv-code-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'rv-copy-btn';
copyBtn.type = 'button';
copyBtn.setAttribute('aria-label', 'Copy code');
copyBtn.setAttribute('title', 'Copy code');
actions.appendChild(copyBtn);
if (isDiagram) {
pre.classList.add('rv-diagram');
const toggle = document.createElement('button');
toggle.className = 'rv-wrap-toggle';
toggle.type = 'button';
toggle.setAttribute('aria-label', 'Toggle line wrapping');
toggle.setAttribute('title', 'Toggle line wrapping');
actions.appendChild(toggle);
}
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(btn);
wrap.appendChild(actions);
wrap.appendChild(pre);
});
return tmpl.innerHTML;
} catch { /* fall through */ }
}
// Fallback: escape HTML and preserve whitespace
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escaped = src.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
}
@@ -1076,7 +1181,23 @@ class CodemanApp {
_bindResponseViewerInteractions(body) {
if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1';
body.addEventListener('click', (ev) => {
body.addEventListener('click', async (ev) => {
// One-click copy: lift the raw source from the sibling <pre><code>.
const copyBtn = ev.target.closest('.rv-copy-btn');
if (copyBtn) {
ev.preventDefault();
ev.stopPropagation();
const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
const ok = code ? await this._copyText(code.textContent || '') : false;
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
clearTimeout(copyBtn._resetTimer);
copyBtn._resetTimer = setTimeout(() => {
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
}, 1500);
return;
}
const btn = ev.target.closest('.rv-wrap-toggle');
if (!btn) return;
ev.preventDefault();
@@ -1089,6 +1210,34 @@ class CodemanApp {
});
}
/**
* Copy text to the clipboard. Prefers the async Clipboard API (secure
* contexts); falls back to a hidden-textarea + execCommand path so copy
* still works over plain HTTP. Returns true on success.
*/
async _copyText(text) {
if (!text) return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* secure-context write failed — try the legacy path */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
async toggleResponseViewer() {
const viewer = document.getElementById('responseViewer');
const backdrop = document.getElementById('responseViewerBackdrop');
@@ -1708,8 +1857,10 @@ class CodemanApp {
}
});
// Restore tabs that were open before refresh but are no longer on the server
this._restoreEndedTabs();
// Server is source of truth for open sessions — don't resurrect stale tabs
// from localStorage (would show phantom "ended" tabs when a session was closed
// on another device).
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
@@ -1844,6 +1995,8 @@ class CodemanApp {
// ═══════════════════════════════════════════════════════════════
renderSessionTabs() {
// Don't re-render while user is typing in the inline rename input
if (this._activeRename) return;
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
}
@@ -1988,6 +2141,7 @@ class CodemanApp {
}
_fullRenderSessionTabs() {
if (this._activeRename) return;
const container = this.$('sessionTabs');
// Clean up any orphaned dropdowns before re-rendering
@@ -2028,8 +2182,7 @@ class CodemanApp {
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
const endedAttr = session._ended ? ' data-ended="1"' : '';
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
@@ -2049,9 +2202,6 @@ class CodemanApp {
container.innerHTML = parts.join('');
// Persist tab metadata for refresh recovery
this._saveTabMetadata();
// Set up drag-and-drop handlers for tab reordering
this.setupTabDragHandlers();
@@ -2151,33 +2301,6 @@ class CodemanApp {
}
}
// Save tab metadata to localStorage so ended sessions can be restored after refresh
_saveTabMetadata() {
try {
const meta = {};
for (const [id, s] of this.sessions) {
if (s._ended) continue; // Don't persist ended stubs back
meta[id] = { id, name: s.name || '', workingDir: s.workingDir || '', mode: s.mode || 'claude', color: s.color || 'default' };
}
localStorage.setItem('codeman-tab-meta', JSON.stringify(meta));
} catch { /* ignore */ }
}
// Restore tabs that were open before refresh but are no longer on the server
_restoreEndedTabs() {
try {
const saved = localStorage.getItem('codeman-tab-meta');
if (!saved) return;
const meta = JSON.parse(saved);
for (const [id, info] of Object.entries(meta)) {
if (!this.sessions.has(id)) {
// Add a stub session so the tab renders
this.sessions.set(id, { id, name: info.name, workingDir: info.workingDir, mode: info.mode, color: info.color, status: 'ended', _ended: true });
}
}
} catch { /* ignore */ }
}
// Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() {
const container = this.$('sessionTabs');
@@ -2421,6 +2544,12 @@ class CodemanApp {
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
// Narrow SSE filter to the active session — server stops streaming
// session:terminal events for other sessions to this client. Cuts
// SSE traffic ~Nx for N concurrent sessions. Fire-and-forget; on the
// rare race where server doesn't know our clientId yet, the next
// selectSession or reconnect catches up.
this._updateSseSubscription(sessionId);
this.hideWelcome();
// Clear idle hooks on view, but keep action hooks until user interacts
this.clearPendingHooks(sessionId, 'idle_prompt');
@@ -2451,16 +2580,9 @@ class CodemanApp {
// Check if this is a restored session that needs to be attached
const session = this.sessions.get(sessionId);
// Ended tabs (restored from localStorage, no longer on server) — show message, skip buffer load
if (session?._ended) {
this.terminal.clear();
this.terminal.write('\r\n \x1b[2mSession ended. Close tab or click to reopen.\x1b[0m\r\n');
return;
}
// Track working directory for path normalization in Project Insights
this.currentSessionWorkingDir = session?.workingDir || null;
if (session && session.pid === null && !session._ended) {
if (session && session.pid === null) {
// Session has no PTY attached — either restored after server restart
// or detached for some other reason. Re-attach regardless of status.
try {
@@ -2584,19 +2706,15 @@ class CodemanApp {
});
}
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
// triggers a full Ink redraw which overwrites all stale frame content.
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
this.sendResize(sessionId).then(() => {
if (selectGen !== this._selectGeneration) return;
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' })
}).catch(() => {});
});
// Fire-and-forget resize to nudge Ink via SIGWINCH on real size changes.
// Previously we also sent Ctrl+L (\x0c) here to force a full Ink redraw,
// but Claude Code 2.x treats Ctrl+L as a two-step "clear conversation"
// command — if a page refresh or SSE reconnect ran selectSession twice
// within Claude's confirmation window, the second \x0c silently wiped the
// conversation. Stale Ink frames in the tailed buffer are a cosmetic
// annoyance that disappear on the user's next keypress; data loss is not
// acceptable. Do NOT re-introduce Ctrl+L here.
this.sendResize(sessionId);
// Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible.
@@ -2691,6 +2809,11 @@ class CodemanApp {
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) {
// If the deleted session is currently being renamed, abort the rename
// so the inline <input> doesn't ghost as a stale tab on screen.
if (this._activeRename?.sessionId === sessionId) {
this._activeRename.cancel();
}
this.sessions.delete(sessionId);
// Remove from tab order
const orderIndex = this.sessionOrder.indexOf(sessionId);
+48 -2
View File
@@ -43,7 +43,7 @@ function urlBase64ToUint8Array(base64String) {
// ═══════════════════════════════════════════════════════════════
// Default terminal scrollback (can be changed via settings)
const DEFAULT_SCROLLBACK = 20000;
const DEFAULT_SCROLLBACK = 50000;
// Timing constants
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
@@ -54,7 +54,7 @@ const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notificati
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 128 * 1024; // 128KB tail for initial load
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
@@ -71,6 +71,52 @@ const WINDOW_MIN_WIDTH_PX = 200;
const WINDOW_MIN_HEIGHT_PX = 200;
const WINDOW_DEFAULT_WIDTH_PX = 300;
// WebGL renderer auto-fallback thresholds.
// _installWebGLLongTaskGuard() observes longtask entries and disables WebGL
// after LONGTASK_COUNT stalls of >= LONGTASK_MS within WINDOW_MS. GRACE_MS
// suppresses the noisy initial-load stalls. STICKY_EXPIRY_MS is how long
// localStorage's webgl-disabled marker survives before we retry WebGL on a
// fresh load (driver/Chrome may have been updated).
const WEBGL_FALLBACK = {
LONGTASK_MS: 200,
LONGTASK_COUNT: 3,
WINDOW_MS: 30000,
GRACE_MS: 5000,
STICKY_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000,
};
/**
* Pure rolling-window trip evaluator for the WebGL longtask guard.
* Mutates `recent` in place (prunes entries older than `now - WINDOW_MS`)
* and appends each new duration's startTime that meets the threshold.
* Returns true when the count inside the window reaches `LONGTASK_COUNT`.
*
* Exposed on `window` for unit testing — the production guard in app.js
* inlines this same logic in its PerformanceObserver callback. Splitting it
* out keeps the threshold math testable without a real PerformanceObserver.
*
* @param {number[]} recent - mutable array of startTimes inside the window
* @param {{startTime: number, duration: number}[]} entries - new longtask entries
* @param {number} now - performance.now() at evaluation time
* @param {typeof WEBGL_FALLBACK} [config=WEBGL_FALLBACK] - thresholds
* @returns {boolean} true if the rolling window has reached the trip count
*/
function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK) {
for (const entry of entries) {
if (entry.duration >= config.LONGTASK_MS) recent.push(entry.startTime);
}
while (recent.length && now - recent[0] > config.WINDOW_MS) recent.shift();
return recent.length >= config.LONGTASK_COUNT;
}
// Expose for tests. `const` declarations at the top of a non-module script
// are global lexical bindings but not `window` properties, so explicit
// assignment is the test-visible API surface.
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
}
// Scheduler API — prioritize terminal writes over background UI updates.
// scheduler.postTask('background') defers non-critical work (connection lines, panel renders)
// so the main thread stays free for terminal rendering at 60fps.
+200
View File
@@ -0,0 +1,200 @@
/**
* Image Input Mixin - Clipboard paste and drag-and-drop image support
*
* For paste: intercepts Ctrl+V at the xterm keyboard level, creates a temporary
* hidden contenteditable div ("paste trap"), lets the browser's native paste fill
* it, then checks for image data. This works on HTTP (no secure context needed).
*
* For drag-and-drop: listens on the terminal container for file drops.
*
* @dependency app.js (uses global `app` for sendInput, activeSessionId, showToast)
* @dependency panels-ui.js (provides showToast)
*/
Object.assign(CodemanApp.prototype, {
initImageInput() {
// Drag-and-drop handlers on terminal container
const container = document.getElementById('terminalContainer');
if (!container) return;
container.addEventListener('dragover', (e) => {
e.preventDefault();
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
container.classList.add('drag-active');
}
});
container.addEventListener('dragleave', (e) => {
if (!container.contains(e.relatedTarget)) {
container.classList.remove('drag-active');
}
});
container.addEventListener('drop', (e) => {
e.preventDefault();
container.classList.remove('drag-active');
if (!this.activeSessionId) return;
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) {
this.showToast('Only image files are supported', 'error');
return;
}
this._uploadAndInsertImages(imageFiles);
});
},
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
// Creates a hidden paste trap, lets the browser paste into it, then inspects
// the result for images. Works on plain HTTP (no Clipboard API needed).
_handleImagePaste() {
const self = this;
// Create a hidden contenteditable div to receive the paste
const trap = document.createElement('div');
trap.contentEditable = 'true';
trap.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;overflow:hidden';
document.body.appendChild(trap);
trap.focus();
// Listen for the paste event on our trap
trap.addEventListener('paste', function(e) {
e.stopPropagation();
// Check for images in clipboard items
var imageFiles = [];
var items = e.clipboardData && e.clipboardData.items;
if (items) {
for (var i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
var blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
}
}
// Clean up the trap
setTimeout(function() {
if (trap.parentNode) trap.parentNode.removeChild(trap);
// Refocus the terminal
if (self.terminal) self.terminal.focus();
}, 0);
if (imageFiles.length > 0) {
e.preventDefault();
self._uploadAndInsertImages(imageFiles);
} else {
// No image -- route text through xterm's paste() so bracketed-paste
// markers (CSI 200~ ... CSI 201~) survive when the inner application
// has enabled bracketed-paste mode (Claude Code does). Sending text
// via raw sendInput() strips those markers and makes pasted input
// indistinguishable from typed input, weakening the CLI's
// prompt-injection defenses.
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
e.preventDefault();
if (text && self.terminal) self.terminal.paste(text);
}
});
// Trigger the browser's native paste via execCommand
// (this fires the paste event on our focused trap element)
document.execCommand('paste');
},
async _uploadAndInsertImages(files) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
const paths = [];
for (const file of files) {
try {
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
// MIME claim "image/jpeg", which passes the server's extension allowlist
// but fails its magic-byte check ("bytes do not match declared type").
// Decoding through the browser and re-encoding guarantees the bytes
// match the extension we send.
const normalized = await this._normalizeImageForUpload(file);
const path = await this._uploadPasteImage(sessionId, normalized);
paths.push(path);
} catch (err) {
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
}
}
if (paths.length > 0) {
const pathStr = paths.join(' ');
await this.sendInput(pathStr);
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
}
},
async _uploadPasteImage(sessionId, file) {
const form = new FormData();
form.append('image', file);
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
method: 'POST',
body: form,
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || 'HTTP ' + resp.status);
}
const data = await resp.json();
return data.path;
},
// Decode an image File through the browser and re-encode it to a format the
// server accepts, so the uploaded bytes always match their declared
// extension. PNG is re-encoded as PNG (preserves transparency); everything
// else (JPEG, WebP, HEIF, unknown) becomes JPEG. Animated GIFs are passed
// through untouched since a canvas would flatten them to one frame. On any
// decode/encode failure the original file is returned unchanged so the server
// still gets a chance (and logs a precise diagnostic).
async _normalizeImageForUpload(file) {
if (file.type === 'image/gif') return file;
const toPng = file.type === 'image/png';
const url = URL.createObjectURL(file);
try {
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('decode failed'));
img.src = url;
});
const width = img.naturalWidth;
const height = img.naturalHeight;
if (!width || !height) return file;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return file;
ctx.drawImage(img, 0, 0);
const mime = toPng ? 'image/png' : 'image/jpeg';
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
if (!blob) return file;
const baseName = (file.name || 'image').replace(/\.[^.]+$/, '') || 'image';
return new File([blob], baseName + (toPng ? '.png' : '.jpg'), { type: mime });
} catch (err) {
console.warn('Image re-encode failed, uploading original:', err);
return file;
} finally {
URL.revokeObjectURL(url);
}
},
});
+3 -1
View File
@@ -1104,8 +1104,9 @@
<option value="high">High</option>
<option value="xhigh">XHigh</option>
<option value="max">Max</option>
<option value="ultracode">Ultracode (multi-agent workflows)</option>
</select>
<span class="form-hint">Set CLAUDE_CODE_EFFORT_LEVEL for all new sessions (default = no override)</span>
<span class="form-hint">Default effort for new Claude sessions — soft default, switchable anytime in-session via /effort (e.g. /effort ultracode)</span>
</div>
<!-- Nice Priority Section -->
<div class="form-section-header">Nice Priority</div>
@@ -1804,5 +1805,6 @@
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
<script defer src="image-input.js"></script>
</body>
</html>
+62 -17
View File
@@ -4,8 +4,10 @@
* Defines two exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* keyboard on mobile: arrow up/down, /init, /clear, paste, and dismiss.
* The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
*
@@ -49,7 +51,6 @@ const KeyboardAccessoryBar = {
</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
@@ -98,7 +99,6 @@ const KeyboardAccessoryBar = {
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
@@ -128,7 +128,7 @@ const KeyboardAccessoryBar = {
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
if (refocusActions.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
(action === 'clear' && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
}
@@ -191,13 +191,11 @@ const KeyboardAccessoryBar = {
case 'init':
this.sendCommand('/init');
break;
case 'clear':
case 'compact': {
case 'clear': {
// Require double-tap: first tap turns amber, second tap within 2s sends
const cmd = action === 'clear' ? '/clear' : '/compact';
if (this._confirmAction === action && this._confirmTimer) {
this.clearConfirm();
this.sendCommand(cmd);
this.sendCommand('/clear');
} else {
this.setConfirm(action, btn);
}
@@ -264,8 +262,17 @@ const KeyboardAccessoryBar = {
}).catch(() => {});
},
/** Read clipboard and send contents as input */
/** Show a paste overlay with a textarea for iOS compatibility */
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker
* (accept=image/* → camera / photo library / files), the most reliable
* way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty
* on mobile, so it is a best-effort enhancement layered on the picker.
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
@@ -274,23 +281,61 @@ const KeyboardAccessoryBar = {
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press here and tap Paste"></textarea>
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
<div class="paste-actions">
<button class="paste-image">🖼 Image</button>
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
</div>
`;
const textarea = overlay.querySelector('.paste-textarea');
const send = () => {
const fileInput = overlay.querySelector('.paste-file-input');
const close = () => overlay.remove();
const sendText = () => {
const text = textarea.value;
overlay.remove();
close();
if (text) app.sendInput(text);
};
overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove());
overlay.querySelector('.paste-send').addEventListener('click', send);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Filter to images, close the dialog, and hand off to the shared
// upload+insert pipeline. Returns true if any image was handled.
const handleImages = (files) => {
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
if (images.length === 0) return false;
close();
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
return true;
};
// Image picker (camera / photo library) — the reliable mobile path.
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => handleImages(fileInput.files));
// Best-effort: capture images pasted straight into the textarea.
textarea.addEventListener('paste', (e) => {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
const imageFiles = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
handleImages(imageFiles);
}
});
overlay.querySelector('.paste-cancel').addEventListener('click', close);
overlay.querySelector('.paste-send').addEventListener('click', sendText);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.body.appendChild(overlay);
textarea.focus();
+9 -1
View File
@@ -1043,7 +1043,7 @@ html.mobile-init .file-browser-panel {
margin-top: 10px;
}
.paste-cancel, .paste-new, .paste-send {
.paste-cancel, .paste-new, .paste-send, .paste-image {
padding: 8px 18px;
border: none;
border-radius: 8px;
@@ -1051,6 +1051,14 @@ html.mobile-init .file-browser-panel {
cursor: pointer;
}
/* Image attach button — left-aligned, accent outline */
.paste-image {
margin-right: auto;
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-cancel {
background: var(--bg-tertiary, #333);
color: var(--text-secondary, #aaa);
+4 -4
View File
@@ -3,7 +3,7 @@
*
* The NotificationManager class implements five notification layers:
* 1. In-app notification drawer (slide-out panel with grouped notifications)
* 2. Tab title flash (alternating "(*) Codeman" when tab is hidden)
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
* 3. Browser Notification API (desktop push with auto-close after 8s)
* 4. Web Push via service worker (OS-level notifications when tab is closed)
* 5. Audio alerts (Web Audio API beep, user-opt-in)
@@ -291,11 +291,11 @@ class NotificationManager {
this.titleFlashInterval = setInterval(() => {
this.titleFlashState = !this.titleFlashState;
document.title = this.titleFlashState
? `\u26A0\uFE0F (${this.unreadCount}) Codeman`
? `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`
: this.originalTitle;
}, TITLE_FLASH_INTERVAL_MS);
// Set immediately
document.title = `\u26A0\uFE0F (${this.unreadCount}) Codeman`;
document.title = `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`;
}
}
}
@@ -330,7 +330,7 @@ class NotificationManager {
if (now - this.lastBrowserNotifTime < BROWSER_NOTIF_RATE_LIMIT_MS) return;
this.lastBrowserNotifTime = now;
const notif = new Notification(`Codeman: ${title}`, {
const notif = new Notification(`${this.originalTitle}: ${title}`, {
body,
tag, // Groups same-tag notifications
icon: '/favicon.ico',
+1 -1
View File
@@ -1575,7 +1575,7 @@ Object.assign(CodemanApp.prototype, {
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 5000,
scrollback: DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
});
+4 -4
View File
@@ -1032,10 +1032,9 @@ Object.assign(CodemanApp.prototype, {
const enabledItems = config.generatedPlan?.filter(i => i.enabled);
try {
const envOverrides = this.buildEnvOverrides(
this.getCaseSettings(config.caseName),
this.loadAppSettingsFromStorage()
);
const ralphGlobalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
const effort = this.getEffortSetting(ralphGlobalSettings);
const res = await fetch('/api/ralph-loop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1047,6 +1046,7 @@ Object.assign(CodemanApp.prototype, {
enableRespawn: config.enableRespawn,
planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
}),
});
const data = await res.json();
+55 -14
View File
@@ -22,12 +22,24 @@ Object.assign(CodemanApp.prototype, {
if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) {
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
}
if (globalSettings?.thinkingEffort) {
env.CLAUDE_CODE_EFFORT_LEVEL = globalSettings.thinkingEffort;
}
// NOTE: thinkingEffort is intentionally NOT emitted as CLAUDE_CODE_EFFORT_LEVEL —
// the env var hard-locks effort and blocks in-session /effort switching (e.g.,
// ultracode). It flows as the dedicated `effort` payload field instead, which the
// backend injects as a `--settings` soft default. See getEffortSetting().
return env;
},
/**
* Resolve the effort level for new sessions from global settings.
* Returns a valid effort string or undefined (= no override, CLI default).
* Sent as the `effort` payload field — backend turns it into `claude --settings ...`.
*/
getEffortSetting(globalSettings) {
const effort = globalSettings?.thinkingEffort;
const valid = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
return valid.includes(effort) ? effort : undefined;
},
// ═══════════════════════════════════════════════════════════════
// Quick Start
// ═══════════════════════════════════════════════════════════════
@@ -337,6 +349,7 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
const modelOverride = useOpus1m ? 'opus[1m]' : '';
@@ -349,6 +362,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
})
}).then(r => r.json())
@@ -538,7 +552,8 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Quick-start with opencode mode (auto-allow tools by default)
// Quick-start with opencode mode (auto-allow tools by default).
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
@@ -912,11 +927,16 @@ Object.assign(CodemanApp.prototype, {
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
if (!tabName) return;
// If a previous rename somehow leaked (shouldn't happen, but defends against
// future code paths that throw before cleanup), abort it before starting fresh.
if (this._activeRename) this._activeRename.cancel();
const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name);
const originalContent = tabName.textContent;
// Clear existing content to make room for the input element
tabName.textContent = '';
tabName.innerHTML = '';
while (tabName.firstChild) tabName.removeChild(tabName.firstChild);
// If prefix detected, show it as non-editable label
if (parsed) {
@@ -937,17 +957,26 @@ Object.assign(CodemanApp.prototype, {
input.focus();
input.select();
const finishRename = async () => {
const suffix = input.value.trim();
let fullName;
if (parsed) {
fullName = parsed.prefix + (suffix ? ': ' + suffix : '');
} else {
fullName = suffix;
let settled = false;
const finishRename = async ({ commit }) => {
if (settled) return;
settled = true;
this._activeRename = null;
// Aborted (e.g. session was deleted mid-rename): just re-render so any
// ghost DOM left behind is replaced with the canonical tab list.
if (!commit) {
this.renderSessionTabs();
return;
}
const suffix = input.value.trim();
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
tabName.textContent = fullName || originalContent;
if (fullName !== session.name) {
// Skip the API call if the session vanished between focus and blur.
const stillExists = this.sessions.has(sessionId);
if (stillExists && fullName !== session.name) {
try {
await fetch(`/api/sessions/${sessionId}/name`, {
method: 'PUT',
@@ -959,10 +988,22 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Failed to rename', 'error');
}
}
// Re-render tabs to restore full tab structure
this.renderSessionTabs();
};
input.addEventListener('blur', finishRename);
// Register only after the input is wired so a throw above can't strand state.
this._activeRename = {
sessionId,
cancel: () => finishRename({ commit: false }),
};
input.addEventListener('blur', () => finishRename({ commit: true }));
input.addEventListener('keydown', (e) => {
// Enter/Escape during IME composition belong to the IME (e.g. confirming
// a Chinese pinyin candidate). keyCode 229 is the legacy signal for the
// same condition on browsers that don't set isComposing reliably.
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
+260 -60
View File
@@ -293,7 +293,6 @@ body {
}
.session-tab .tab-status.error { background: var(--red); }
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
.session-tab[data-ended] { opacity: 0.55; }
/* Session color coding - left border indicator */
.session-tab[data-color="red"] { border-left: 3px solid var(--session-red); }
@@ -2370,6 +2369,56 @@ body {
color: var(--text);
}
.history-detail-actions {
margin-top: 0.5rem;
}
.history-view-all-btn {
width: 100%;
padding: 0.45rem 0.75rem;
background: rgba(99, 179, 237, 0.08);
border: 1px solid rgba(99, 179, 237, 0.25);
border-radius: 6px;
color: rgba(99, 179, 237, 0.95);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-smooth), border-color var(--transition-smooth);
}
.history-view-all-btn:hover {
background: rgba(99, 179, 237, 0.15);
border-color: rgba(99, 179, 237, 0.5);
}
/* Folder history modal */
.folder-history-modal .modal-body {
padding: 0.75rem 1rem 1rem;
}
.folder-history-subtitle {
color: var(--text-muted);
font-size: 0.78rem;
font-family: 'SF Mono', Menlo, Consolas, monospace;
word-break: break-all;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.folder-history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.folder-history-empty {
padding: 2rem 0.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
.welcome-hint {
color: var(--text-muted);
font-size: 0.8rem;
@@ -7922,14 +7971,15 @@ kbd {
bottom: 0;
left: 0;
right: 0;
max-height: 85vh;
background: #1a1a2e;
border-top: 1px solid #333;
border-radius: 12px 12px 0 0;
max-height: 88vh;
background: #14141f;
border-top: 1px solid #2a2a3a;
border-radius: 14px 14px 0 0;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.45);
z-index: 5000;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.25s ease-out;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.response-viewer.visible {
@@ -7941,12 +7991,13 @@ kbd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #333;
padding: 14px 20px;
border-bottom: 1px solid #2a2a3a;
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
color: #e8e8ec;
letter-spacing: 0.2px;
}
.response-viewer-actions {
@@ -8029,42 +8080,80 @@ kbd {
background: rgba(109, 219, 127, 0.12);
}
/* Markdown rendered content inside response viewer */
.rv-text {
word-break: break-word;
line-height: 1.6;
/* Markdown rendered content inside response viewer.
Prose uses a proportional font for readability; code keeps monospace. */
.rv-text,
.response-viewer-body > :not(.rv-message) {
word-break: normal;
overflow-wrap: anywhere;
line-height: 1.7;
}
.rv-text p {
margin: 0 0 0.6em;
.rv-text p,
.response-viewer-body > p {
margin: 0 0 0.85em;
}
.rv-text p:last-child {
.rv-text p:last-child,
.response-viewer-body > p:last-child {
margin-bottom: 0;
}
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
color: #e0e0e0;
margin: 1em 0 0.4em;
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4,
.response-viewer-body > h1, .response-viewer-body > h2,
.response-viewer-body > h3, .response-viewer-body > h4 {
color: #f2f2f6;
margin: 1.4em 0 0.5em;
line-height: 1.3;
font-weight: 700;
letter-spacing: -0.01em;
}
.rv-text h1 { font-size: 1.3em; }
.rv-text h2 { font-size: 1.15em; }
.rv-text h3 { font-size: 1.05em; }
.rv-text h1:first-child, .rv-text h2:first-child,
.response-viewer-body > h1:first-child, .response-viewer-body > h2:first-child {
margin-top: 0;
}
.rv-text code {
background: #2a2a3e;
padding: 1px 5px;
border-radius: 3px;
.rv-text h1, .response-viewer-body > h1 {
font-size: 1.55em;
padding-bottom: 0.3em;
border-bottom: 1px solid #2d2d40;
}
.rv-text h2, .response-viewer-body > h2 {
font-size: 1.3em;
color: #ffd27a;
}
.rv-text h3, .response-viewer-body > h3 {
font-size: 1.13em;
color: #bfc8ff;
}
.rv-text h4, .response-viewer-body > h4 {
font-size: 1em;
color: #c9c9d5;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rv-text code,
.response-viewer-body > :not(pre) code {
background: #262638;
color: #ffb4a2;
padding: 1px 6px;
border-radius: 4px;
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
font-size: 0.9em;
}
.rv-text pre {
background: #1e1e2e;
border: 1px solid #333;
border-radius: 6px;
padding: 10px 12px;
/* Descendant (not child) combinator: code blocks are wrapped in .rv-code-wrap,
so the latest-response view (markdown rendered straight into the body) nests
<pre> one level deeper than a direct child. The historical .rv-text path
already matched via descendant; keep both in lockstep. */
.rv-text pre,
.response-viewer-body pre {
background: #0f0f1a;
border: 1px solid #2a2a3d;
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
margin: 1em 0;
-webkit-overflow-scrolling: touch;
@@ -8076,7 +8165,7 @@ kbd {
Preserve indentation (pre-wrap) but allow breaks inside long tokens
(URLs, paths, identifiers) so they don't overflow. */
.rv-text pre code,
.response-viewer-body > pre code {
.response-viewer-body pre code {
background: none;
color: #e6e6f0;
padding: 0;
@@ -8174,41 +8263,114 @@ kbd {
content: '⤢';
}
/* ── Code block one-click copy ──────────────────────────────────────────────
Every fenced code block is wrapped in .rv-code-wrap with an action toolbar
pinned to its top-right. Regular blocks get the relative positioning here;
ASCII diagrams already get it from .rv-diagram-wrap (don't clobber its
centering margins). */
.rv-text .rv-code-wrap:not(.rv-diagram-wrap),
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) {
position: relative;
margin: 1em 0;
}
.rv-text .rv-code-wrap:not(.rv-diagram-wrap) > pre,
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) > pre {
margin: 0;
padding-right: 44px; /* reserve room for the copy button */
}
/* Diagrams carry two buttons (copy + wrap toggle) — widen the reserve. */
.rv-text .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram,
.response-viewer-body .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram {
padding-right: 76px;
}
.rv-code-actions {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
gap: 4px;
z-index: 2;
}
/* Inside the flex toolbar the wrap toggle flows normally — drop its own pin. */
.rv-code-actions .rv-wrap-toggle {
position: static;
top: auto;
right: auto;
}
.rv-copy-btn {
width: 28px;
height: 24px;
padding: 0;
border: 1px solid #2f2f45;
border-radius: 5px;
background: rgba(20, 20, 32, 0.92);
color: #8b8b97;
font-size: 13px;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
}
.rv-copy-btn:hover,
.rv-copy-btn:active {
color: #e0e0ec;
border-color: #4a4a65;
}
.rv-copy-btn::before { content: '\2398'; } /* ⎘ — matches file-preview copy */
.rv-copy-btn.rv-copied { color: #9ece6a; border-color: #3a5a3a; }
.rv-copy-btn.rv-copied::before { content: '\2713'; } /* ✓ */
.rv-copy-btn.rv-copy-failed { color: #f7768e; border-color: #5a3a3a; }
.rv-copy-btn.rv-copy-failed::before { content: '\2715'; } /* ✕ */
.rv-text ul, .rv-text ol,
.response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0;
padding-left: 1.5em;
}
.rv-text pre code {
background: none;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
.rv-text li,
.response-viewer-body > ul > li, .response-viewer-body > ol > li {
margin-bottom: 0.3em;
}
.rv-text ul, .rv-text ol {
margin: 0.4em 0;
padding-left: 1.4em;
.rv-text li > p { margin: 0.2em 0; }
.rv-text blockquote,
.response-viewer-body > blockquote {
border-left: 3px solid #5c7cfa;
background: rgba(92, 124, 250, 0.06);
margin: 0.8em 0;
padding: 0.5em 14px;
color: #b8b8c8;
border-radius: 0 6px 6px 0;
}
.rv-text li {
margin-bottom: 0.2em;
.rv-text strong,
.response-viewer-body > p strong,
.response-viewer-body > li strong {
color: #ffffff;
font-weight: 700;
}
.rv-text blockquote {
border-left: 3px solid #444;
margin: 0.6em 0;
padding: 0.3em 0 0.3em 12px;
color: #999;
.rv-text em,
.response-viewer-body em {
color: #e0e0ec;
}
.rv-text strong {
color: #f0f0f0;
}
.rv-text a {
color: #5c7cfa;
.rv-text a,
.response-viewer-body a {
color: #7aa2ff;
text-decoration: none;
border-bottom: 1px solid rgba(122, 162, 255, 0.35);
}
.rv-text a:hover,
@@ -8286,19 +8448,37 @@ kbd {
.rv-text hr,
.response-viewer-body > hr {
border: none;
border-top: 1px solid #333;
margin: 1em 0;
border-top: 1px solid #2d2d40;
margin: 1.5em 0;
}
.response-viewer-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
overscroll-behavior: contain;
padding: 20px 22px 28px;
/* Proportional font for prose — monospace only for code/pre */
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC',
'Hiragino Sans GB', 'Segoe UI', 'Helvetica Neue', Helvetica, Arial,
'Noto Sans CJK SC', sans-serif;
font-size: 15px;
line-height: 1.7;
color: #d8d8e0;
/* Comfortable reading width on wider viewports */
--rv-content-max: 720px;
}
/* Constrain content width for readability; code blocks can still scroll horizontally */
.response-viewer-body > * {
max-width: var(--rv-content-max);
margin-left: auto;
margin-right: auto;
}
.response-viewer-body > pre,
.response-viewer-body > table,
.response-viewer-body > .rv-message {
max-width: var(--rv-content-max);
}
.response-viewer-body:empty::after {
@@ -8591,3 +8771,23 @@ kbd {
margin-top: 4px;
font-size: 0.7rem;
}
/* Image drag-and-drop overlay */
#terminalContainer.drag-active {
outline: 2px dashed #4a9eff;
outline-offset: -2px;
position: relative;
}
#terminalContainer.drag-active::after {
content: 'Drop image here';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(74, 158, 255, 0.08);
color: #4a9eff;
font-size: 1.2rem;
pointer-events: none;
z-index: 100;
}
+9 -2
View File
@@ -110,7 +110,7 @@ self.addEventListener('push', (event) => {
return;
}
const { title, body, tag, sessionId, urgency, actions } = payload;
const { title, hostTitle, body, tag, sessionId, urgency, actions } = payload;
const options = {
body: body || '',
@@ -126,8 +126,15 @@ self.addEventListener('push', (event) => {
options.actions = actions;
}
// Match the in-page Notification format: "codeman:<host>: <event title>".
// hostTitle is sent by servers >= the hostname-aware push payload change;
// older servers omit it and we fall back to the bare title.
const displayTitle = hostTitle && title
? `${hostTitle}: ${title}`
: (title || hostTitle || 'Codeman');
event.waitUntil(
self.registration.showNotification(title || 'Codeman', options)
self.registration.showNotification(displayTitle, options)
);
});
+297 -46
View File
@@ -18,8 +18,10 @@ Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
initTerminal() {
// Load scrollback setting from localStorage (default 5000)
const scrollback = parseInt(localStorage.getItem('codeman-scrollback')) || DEFAULT_SCROLLBACK;
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
const stored = parseInt(localStorage.getItem('codeman-scrollback'));
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
this.terminal = new Terminal({
theme: {
@@ -81,6 +83,15 @@ Object.assign(CodemanApp.prototype, {
// Let Alt+digit pass through to browser (tab switching)
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
// Route through our paste trap which handles both images and text.
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
if (this.activeSessionId && this._handleImagePaste) {
this._handleImagePaste();
}
return false;
}
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
@@ -156,7 +167,7 @@ Object.assign(CodemanApp.prototype, {
Promise.resolve().then(() => {
// If xterm cleared the textarea, it processed the input -- skip.
const val = xtermTextarea.value;
if (!val || val.trim() === '') return;
if (!val || (val.trim() === '' && data !== ' ')) return;
// xterm didn't process it -- forward to terminal as if typed.
// Emit via onData path by writing to terminal's input handler.
this.terminal._core.coreService.triggerDataEvent(data, true);
@@ -172,10 +183,37 @@ Object.assign(CodemanApp.prototype, {
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
// oversized terminal.write() calls that triggered the stalls.
// Disable with ?nowebgl URL param if GPU issues return.
// Auto-fallback: _initWebGL installs a long-task watchdog that disables
// WebGL sticky in localStorage after repeated GPU stalls (see app.js).
// Force re-enable after sticky disable with ?webgl=force.
// Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile).
this._webglAddon = null;
const skipWebGL = MobileDetection.getDeviceType() !== 'desktop';
if (!skipWebGL && !new URLSearchParams(location.search).has('nowebgl')) {
const _params = new URLSearchParams(location.search);
if (_params.get('webgl') === 'force') {
try { localStorage.removeItem('codeman-webgl-disabled'); } catch {}
}
const _stickyDisabled = (() => {
try {
const raw = localStorage.getItem('codeman-webgl-disabled');
if (!raw) return false;
const { at } = JSON.parse(raw);
// Auto-expire after WEBGL_FALLBACK.STICKY_EXPIRY_MS so we retry
// (driver/Chrome may have been updated).
if (Date.now() - at > WEBGL_FALLBACK.STICKY_EXPIRY_MS) {
localStorage.removeItem('codeman-webgl-disabled');
return false;
}
return true;
} catch { return false; }
})();
const skipWebGL =
MobileDetection.getDeviceType() !== 'desktop' ||
_params.has('nowebgl') ||
_stickyDisabled;
if (_stickyDisabled) {
console.log('[CRASH-DIAG] WebGL sticky-disabled from prior stalls — DOM renderer in use. Re-enable: ?webgl=force');
}
if (!skipWebGL) {
if (typeof WebglAddon !== 'undefined') {
this._initWebGL();
} else {
@@ -339,6 +377,9 @@ Object.assign(CodemanApp.prototype, {
// Welcome message
this.showWelcome();
// Image paste and drag-and-drop support
this.initImageInput();
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
this._chunkedWriteGen = 0;
@@ -403,7 +444,6 @@ Object.assign(CodemanApp.prototype, {
if (
activeResizeSession &&
activeResizeSession.mode !== 'shell' &&
!activeResizeSession._ended &&
this.terminal &&
this.isTerminalAtBottom()
) {
@@ -877,8 +917,15 @@ Object.assign(CodemanApp.prototype, {
.replace(/^\/Users\/[^/]+\//, '~/');
},
/** Build a single history item DOM element */
_buildHistoryItem(s, cases) {
/**
* Build a single history item DOM element.
* @param {object} s session record
* @param {Array} cases linked cases (for #caseName label)
* @param {object} [options]
* @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel
*/
_buildHistoryItem(s, cases, options) {
const showViewAll = options?.showViewAll !== false;
const size =
s.sizeBytes < 1024
? `${s.sizeBytes}B`
@@ -960,6 +1007,21 @@ Object.assign(CodemanApp.prototype, {
detail.append(promptRow, pathRow, metaRow);
if (showViewAll && s.projectKey) {
const actionRow = document.createElement('div');
actionRow.className = 'history-detail-row history-detail-actions';
const viewAllBtn = document.createElement('button');
viewAllBtn.type = 'button';
viewAllBtn.className = 'history-view-all-btn';
viewAllBtn.textContent = 'View all in this folder';
viewAllBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
this.openFolderHistoryModal(s.projectKey, s.workingDir, cases);
});
actionRow.appendChild(viewAllBtn);
detail.appendChild(actionRow);
}
expandBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const expanded = item.classList.toggle('expanded');
@@ -1023,9 +1085,144 @@ Object.assign(CodemanApp.prototype, {
}
},
/** Page size for the folder history modal */
_FOLDER_HISTORY_PAGE_SIZE: 20,
/**
* Open a modal showing all history sessions in a single folder.
* Paginated by FOLDER_HISTORY_PAGE_SIZE; "Show more" loads next page.
*/
openFolderHistoryModal(projectKey, workingDir, cases) {
// Close any existing instance first
this._closeFolderHistoryModal();
const modal = document.createElement('div');
modal.className = 'modal active folder-history-modal';
modal.id = 'folderHistoryModal';
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.addEventListener('click', () => this._closeFolderHistoryModal());
const content = document.createElement('div');
content.className = 'modal-content modal-lg';
const header = document.createElement('div');
header.className = 'modal-header';
const title = document.createElement('h3');
title.textContent = 'Folder History';
const subtitle = document.createElement('div');
subtitle.className = 'folder-history-subtitle';
subtitle.textContent = this._shortenHomePath(workingDir);
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', () => this._closeFolderHistoryModal());
header.append(title, closeBtn);
const body = document.createElement('div');
body.className = 'modal-body';
const list = document.createElement('div');
list.className = 'folder-history-list';
list.setAttribute('data-loading', 'true');
list.textContent = 'Loading...';
body.append(subtitle, list);
content.append(header, body);
modal.append(backdrop, content);
document.body.appendChild(modal);
// Track state for pagination
this._folderHistoryState = {
projectKey,
workingDir,
cases: cases || [],
offset: 0,
total: null,
list,
};
// ESC to close
this._folderHistoryEscHandler = (ev) => {
if (ev.key === 'Escape') this._closeFolderHistoryModal();
};
document.addEventListener('keydown', this._folderHistoryEscHandler);
this._loadFolderHistoryPage();
},
async _loadFolderHistoryPage() {
const state = this._folderHistoryState;
if (!state) return;
const { projectKey, cases, list } = state;
const limit = this._FOLDER_HISTORY_PAGE_SIZE;
const offset = state.offset;
// Remove existing "Show more" button while loading
const existingMore = list.querySelector('.folder-history-more');
if (existingMore) existingMore.remove();
// First page: clear loading placeholder
if (offset === 0) {
list.replaceChildren();
list.removeAttribute('data-loading');
}
try {
const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`;
const res = await fetch(url);
const data = await res.json();
const sessions = data.sessions || [];
state.total = typeof data.total === 'number' ? data.total : sessions.length + offset;
if (offset === 0 && sessions.length === 0) {
const empty = document.createElement('div');
empty.className = 'folder-history-empty';
empty.textContent = 'No conversations found in this folder.';
list.appendChild(empty);
return;
}
for (const s of sessions) {
list.appendChild(this._buildHistoryItem(s, cases, { showViewAll: false }));
}
state.offset = offset + sessions.length;
// Add "Show more" if there are more sessions
if (state.offset < state.total) {
const remaining = state.total - state.offset;
const moreBtn = document.createElement('button');
moreBtn.className = 'history-show-more folder-history-more';
moreBtn.textContent = `Show ${Math.min(limit, remaining)} more (${remaining} remaining)`;
moreBtn.addEventListener('click', () => this._loadFolderHistoryPage());
list.appendChild(moreBtn);
}
} catch (err) {
console.error('[loadFolderHistoryPage]', err);
const errorEl = document.createElement('div');
errorEl.className = 'folder-history-empty';
errorEl.textContent = 'Failed to load folder history.';
list.appendChild(errorEl);
}
},
_closeFolderHistoryModal() {
const modal = document.getElementById('folderHistoryModal');
if (modal) modal.remove();
if (this._folderHistoryEscHandler) {
document.removeEventListener('keydown', this._folderHistoryEscHandler);
this._folderHistoryEscHandler = null;
}
this._folderHistoryState = null;
},
async resumeHistorySession(sessionId, workingDir) {
// Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active');
// Close folder history modal if open
this._closeFolderHistoryModal();
try {
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
@@ -1047,7 +1244,9 @@ Object.assign(CodemanApp.prototype, {
// Match by path (not basename) so linked/renamed cases still resolve correctly.
const matchingCase = (this.cases || []).find((c) => c.path === workingDir);
const caseName = matchingCase?.name || workingDir.split('/').pop() || '';
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const effort = this.getEffortSetting(globalSettings);
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1056,6 +1255,7 @@ Object.assign(CodemanApp.prototype, {
name,
resumeSessionId: sessionId,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
}),
});
const createData = await createRes.json();
@@ -1149,7 +1349,7 @@ Object.assign(CodemanApp.prototype, {
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this._safeYield(() => {
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
// content between 2026h/2026l and renders atomically. No need for
// client-side incomplete-block detection; just flush every frame.
@@ -1174,7 +1374,7 @@ Object.assign(CodemanApp.prototype, {
// Trigger a normal flush
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this._safeYield(() => {
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
@@ -1262,7 +1462,7 @@ Object.assign(CodemanApp.prototype, {
deferred = true;
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this._safeYield(() => {
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
@@ -1334,9 +1534,70 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Schedule cb via THREE racing primitives so data-pacing makes progress
* regardless of which scheduling primitive Chrome is throttling:
* 1. requestAnimationFrame — primary, fires at compositor rate
* (may be 0Hz when window is occluded / on backgrounded monitor).
* 2. setTimeout(50) — fallback for occluded-but-visible windows
* (clamped to 1Hz by Chrome's intensive wake-up throttling
* after ~5 min of no user interaction).
* 3. Worker postMessage — bypasses intensive throttling entirely;
* Workers are not subject to background-tab / idle-tab throttling
* (the React Scheduler trick).
* Whichever fires first wins; the others are no-ops thanks to the
* `done` guard. Without all three, chunkedTerminalWrite and the deferred
* path of flushPendingWrites stall indefinitely when the substrate is
* degraded (visible-but-occluded window, OR idle-throttled tab, OR
* background tab on a different monitor).
*/
_safeYield(cb) {
let done = false;
const wrapped = () => {
if (done) return;
done = true;
cb();
};
requestAnimationFrame(wrapped);
setTimeout(wrapped, 50);
this._workerYield(wrapped);
},
/**
* Lazy-init a tiny "tick" worker whose only job is to postMessage back to
* us as fast as possible, escaping main-thread throttling. The worker's
* setTimeout(0) is not subject to Chrome's intensive wake-up throttling
* even when the parent tab is idle.
*/
_workerYield(cb) {
try {
if (this._yieldWorker === undefined) {
// First call: build the worker (or mark unavailable). Each
// postMessage in produces exactly one postMessage out — we count on
// FIFO 1:1 to drain queue entries.
const src = "onmessage=()=>setTimeout(()=>postMessage(0),0);";
const blob = new Blob([src], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
this._yieldWorker = new Worker(url);
URL.revokeObjectURL(url);
this._yieldQueue = [];
this._yieldWorker.onmessage = () => {
const fn = this._yieldQueue.shift();
if (fn) fn();
};
}
if (!this._yieldWorker) return;
this._yieldQueue.push(cb);
this._yieldWorker.postMessage(0);
} catch {
this._yieldWorker = null; // mark unavailable, future calls skip
}
},
/**
* Write large buffer to terminal in chunks to avoid UI jank.
* Uses requestAnimationFrame to spread work across frames.
* Uses _safeYield to spread work across frames; falls back to setTimeout
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
* @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
@@ -1395,7 +1656,7 @@ Object.assign(CodemanApp.prototype, {
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
);
// Wait one more frame for xterm to finish rendering before resolving
requestAnimationFrame(finish);
this._safeYield(finish);
return;
}
@@ -1410,12 +1671,13 @@ Object.assign(CodemanApp.prototype, {
);
offset += chunkSize;
// Schedule next chunk on next frame
requestAnimationFrame(writeChunk);
// Schedule next chunk; rAF if possible, else setTimeout/Worker
// fallback so progress doesn't stall on occluded/unfocused windows.
this._safeYield(writeChunk);
};
// Start writing
requestAnimationFrame(writeChunk);
this._safeYield(writeChunk);
});
},
@@ -1445,7 +1707,8 @@ Object.assign(CodemanApp.prototype, {
/**
* Restore terminal size to match web UI dimensions.
* Use this after mobile screen attachment has squeezed the terminal.
* Sends resize to PTY and Ctrl+L to trigger Claude to redraw.
* Sends only resize — SIGWINCH triggers Ink redraw on real dimension changes.
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
*/
async restoreTerminalSize() {
if (!this.activeSessionId) {
@@ -1460,16 +1723,10 @@ Object.assign(CodemanApp.prototype, {
}
try {
// Send resize to restore proper dimensions (with minimum enforcement)
// Send resize to restore proper dimensions (with minimum enforcement).
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw.
await this.sendResize(this.activeSessionId);
// Send Ctrl+L to trigger Claude to redraw at new size
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }),
});
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
} catch (err) {
console.error('Failed to restore terminal size:', err);
@@ -1477,26 +1734,20 @@ Object.assign(CodemanApp.prototype, {
}
},
// Send Ctrl+L to fix display for newly created sessions once Claude is running
sendPendingCtrlL(sessionId) {
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) {
return;
}
this.pendingCtrlL.delete(sessionId);
// Only send if this is the active session
if (sessionId !== this.activeSessionId) {
return;
}
// Send resize + Ctrl+L to fix the display (with minimum dimension enforcement)
this.sendResize(sessionId).then(() => {
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }),
});
});
// Vestigial no-op: this method has no callers today. It's kept (not deleted)
// as a documented guard so the Ctrl+L behavior below isn't reintroduced.
//
// Originally this sent Ctrl+L (\x0c) when a flagged session first reached
// idle/working to scrub mux-init junk from the screen. Two problems:
// 1. `pendingCtrlL` was never actually populated anywhere (dead path).
// 2. Claude Code 2.x interprets Ctrl+L as a two-step "clear conversation"
// command — sending it from background flows risked nuking the user's
// conversation if it coincided with another Ctrl+L (e.g. from
// selectSession on page reload).
// If a per-session display-fix is ever needed again, do it via sendResize
// or an Ink-safe control sequence, NOT \x0c.
sendPendingCtrlL(_sessionId) {
// intentionally empty
},
async copyTerminal() {
+29
View File
@@ -0,0 +1,29 @@
/**
* @fileoverview Shared Fastify error handler for Codeman's HTTP routes.
*
* Route helpers (`findSessionOrFail`, `parseBody` in route-helpers.ts) throw
* structured errors carrying `{ statusCode, body }`. This handler renders them
* into the proper HTTP response. It is installed by BOTH the production server
* and the route test harness so test behavior matches production exactly —
* without it, thrown errors fall through to Fastify's default handler and the
* response body is `{statusCode,error,message}` instead of `{success:false,...}`.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../types.js';
/**
* Install the global error handler that renders structured route errors.
* Errors thrown with a `statusCode`/`body` (see route-helpers.ts) are sent
* verbatim at that status; anything else becomes a 500 OPERATION_FAILED response.
*/
export function installRouteErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
}
});
}
+11 -2
View File
@@ -268,8 +268,16 @@ export function registerRalphRoutes(
);
}
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems, envOverrides } =
parseBody(RalphLoopStartSchema, req.body);
const {
caseName,
taskDescription,
completionPhrase,
maxIterations,
enableRespawn,
planItems,
envOverrides,
effort,
} = parseBody(RalphLoopStartSchema, req.body);
const casePath = join(CASES_DIR, caseName);
@@ -315,6 +323,7 @@ export function registerRalphRoutes(
claudeMode: rlClaudeModeConfig.claudeMode,
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
});
// Configure Ralph tracker
+436 -135
View File
@@ -5,11 +5,12 @@
*/
import { FastifyInstance } from 'fastify';
import { join, dirname } from 'node:path';
import { join, dirname, extname } from 'node:path';
import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import {
ApiErrorCode,
createErrorResponse,
@@ -66,48 +67,130 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
/**
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA, CSI n;m H = CUP)
* to animate the spinner and update the status bar. During long thinking phases, these frames
* accumulate to 500KB+ of repeated overwrites to the same rows. When the buffer is tailed,
* only spinner frames are returned, making the terminal appear empty.
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
* the spinner and update the status bar. During long thinking phases, these frames
* accumulate to 500KB+ of repeated overwrites to the same rows.
*
* Strategy: find where absolute-positioned redraws begin (first VPA sequence), then keep
* only the last ~4KB of redraw frames (the final visual state) and discard the rest.
* Strategy: detect "redraw clusters" — dense runs of VPA escapes where each is within
* FRAME_GAP bytes of the previous (i.e. continuous rerendering of the same UI region).
* Collapse each big cluster down to just the bytes from its last VPA onwards (the final
* frame). Content *between* clusters (Claude's streamed response text) is preserved.
*
* Without clustering, a single first-VPA-finds-all approach would discard the entire
* conversation after Claude's first render — losing 100KB+ of legitimate scrollback.
*/
function stripInkRedrawBloat(buffer: string): string {
// Find where Ink's absolute-positioned redraws start (first CSI n d = VPA)
export function stripInkRedrawBloat(buffer: string): string {
// eslint-disable-next-line no-control-regex
const firstVPA = buffer.search(/\x1b\[\d+d/);
if (firstVPA === -1) return buffer; // No Ink redraws
const contentPart = buffer.slice(0, firstVPA);
const redrawPart = buffer.slice(firstVPA);
// If the redraw section is small (<16KB), not worth stripping
if (redrawPart.length < 16384) return buffer;
// Find the last complete Ink frame by searching for where the VPA row
// number drops (cursor jumps back to viewport top for a new render cycle).
// Search the last 64KB — a single Ink frame with response content can be
// 10-20KB, so 4KB was too small and caused partial frames (blank gap).
const searchLen = Math.min(redrawPart.length, 65536);
const searchWindow = redrawPart.slice(-searchLen);
// eslint-disable-next-line no-control-regex
const vpaRe = /\x1b\[(\d+)d/g;
let lastFrameStart = 0;
let prevRow = -1;
let match;
while ((match = vpaRe.exec(searchWindow)) !== null) {
const row = parseInt(match[1], 10);
// Row number dropped significantly — Ink started a new frame
if (prevRow > 0 && row < prevRow - 5) {
lastFrameStart = match.index;
}
prevRow = row;
const vpaRe = /\x1b\[\d+d/g;
const positions: number[] = [];
let m: RegExpExecArray | null;
while ((m = vpaRe.exec(buffer)) !== null) {
positions.push(m.index);
}
if (positions.length < 10) return buffer; // Too few VPAs to be bloat
return contentPart + searchWindow.slice(lastFrameStart);
// Group consecutive VPAs into clusters separated by gaps > FRAME_GAP.
// Within a cluster, VPAs are close together (continuous rerenders).
// Between clusters, real terminal output (response text) lives.
const FRAME_GAP = 8 * 1024; // 8KB — one Ink frame is typically 1-4KB
const MIN_BLOAT_SIZE = 32 * 1024; // Only collapse clusters spanning >= 32KB
const clusters: { start: number; end: number }[] = [];
let cs = positions[0];
let ce = positions[0];
for (let i = 1; i < positions.length; i++) {
if (positions[i] - ce <= FRAME_GAP) {
ce = positions[i];
} else {
clusters.push({ start: cs, end: ce });
cs = positions[i];
ce = positions[i];
}
}
clusters.push({ start: cs, end: ce });
// For each big cluster, replace [start..end] with the bytes from `end` onwards
// (which contains the last frame's content up to where the next cluster, or
// post-cluster content, begins).
const parts: string[] = [];
let cursor = 0;
for (const cl of clusters) {
if (cl.end - cl.start < MIN_BLOAT_SIZE) continue;
parts.push(buffer.slice(cursor, cl.start));
cursor = cl.end;
}
parts.push(buffer.slice(cursor));
return parts.join('');
}
/**
* Validate image bytes against a declared extension. Sniffs the first ~12 bytes
* for a known magic-number signature. Defends against polyglots (e.g. HTML or
* SVG disguised under a `Content-Type: image/png` header) and against simple
* extension-only spoofing — both the multipart filename and the Content-Type
* are attacker-controlled, the raw bytes are not.
*
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
*/
export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
if (data.length < 12) return false;
const u32be = (off: number): number => data.readUInt32BE(off);
switch (ext) {
case '.png':
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
case '.jpg':
case '.jpeg':
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
case '.gif':
return (
data[0] === 0x47 &&
data[1] === 0x49 &&
data[2] === 0x46 &&
data[3] === 0x38 &&
(data[4] === 0x37 || data[4] === 0x39) &&
data[5] === 0x61
);
case '.webp':
// RIFF....WEBP
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
case '.bmp':
return data[0] === 0x42 && data[1] === 0x4d;
default:
return false;
}
}
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
// Bucket map entries are pruned when they drift > 1h stale to bound memory
// against a flood of unique IP keys.
const PASTE_RATE_TOKENS = 30;
const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
const PASTE_BUCKET_GC_THRESHOLD = 1000;
const pasteRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
for (const [k, b] of pasteRateBuckets) {
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) pasteRateBuckets.delete(k);
}
}
let b = pasteRateBuckets.get(key);
if (!b) {
b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
pasteRateBuckets.set(key, b);
}
const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
b.lastRefill = now;
if (b.tokens < 1) return false;
b.tokens -= 1;
return true;
}
// Test hook: reset between runs.
export function _resetPasteRateBuckets(): void {
pasteRateBuckets.clear();
}
export function registerSessionRoutes(
@@ -252,6 +335,7 @@ export function registerSessionRoutes(
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
});
ctx.addSession(session);
@@ -569,11 +653,18 @@ export function registerSessionRoutes(
}
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
await new Promise<void>((resolve, reject) => {
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
if (err) reject(err);
else resolve();
});
execFile(
'tmux',
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
{ timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
});
} catch (err) {
console.error('[Server] send-key failed:', err);
@@ -1016,6 +1107,7 @@ export function registerSessionRoutes(
mode = 'claude',
openCodeConfig,
envOverrides,
effort,
} = parseBody(QuickStartSchema, req.body);
// Check OpenCode availability if requested
@@ -1096,6 +1188,7 @@ export function registerSessionRoutes(
allowedTools: qsClaudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
envOverrides,
effort,
});
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
@@ -1241,42 +1334,76 @@ export function registerSessionRoutes(
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
*
* Strategy: look-ahead matching. At each '-', try consuming multiple segments
* joined by '_' or '-' to find an existing child directory, then recurse.
* E.g. for segments [AI, project, Mirror] inside /Workspace:
* try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror]
* Strategy: recursive backtracking with longest-match-first preference.
* At each segment boundary, try joining as many segments as possible (with '_'
* or '-') into a single existing directory name. If a shorter match leads to a
* dead end, backtrack and try the next-shorter candidate.
*
* Why backtracking: when both `diary/` and `diary-app/` exist as siblings, the
* naive shortest-match would pick `diary` and then fail to find `app` inside,
* leaving the rest of the key unresolved. Longest-first picks `diary-app`.
*/
async function decodeProjectKey(projKey: string): Promise<string> {
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
const segments = encoded.split('-');
const isDir = async (p: string): Promise<boolean> =>
fs
const isDirCache = new Map<string, boolean>();
const isDir = async (p: string): Promise<boolean> => {
const cached = isDirCache.get(p);
if (cached !== undefined) return cached;
const result = await fs
.stat(p)
.then((s) => s.isDirectory())
.catch(() => false);
isDirCache.set(p, result);
return result;
};
// Recursive backtracking: returns the deepest valid path that consumes all
// segments. Tries the longest segment-join first at each step so that
// dash-containing directory names win over shorter same-prefix siblings.
async function tryDecode(idx: number, current: string): Promise<string | null> {
if (idx >= segments.length) return current;
const maxLook = Math.min(idx + 4, segments.length);
// Longest first: end = maxLook-1 down to idx
for (let end = maxLook - 1; end >= idx; end--) {
const candidates: string[] = [];
if (end === idx) {
candidates.push(segments[idx]);
} else {
candidates.push(segments.slice(idx, end + 1).join('-'));
candidates.push(segments.slice(idx, end + 1).join('_'));
}
for (const child of candidates) {
const candidate = current + '/' + child;
if (await isDir(candidate)) {
const result = await tryDecode(end + 1, candidate);
if (result) return result;
}
}
}
return null;
}
const decoded = await tryDecode(0, '');
if (decoded) return decoded;
// Fallback: greedy shortest-match (original behavior) — best effort when
// no fully-valid path exists (e.g. directory was deleted after the
// conversation was recorded).
let current = '';
let i = 0;
while (i < segments.length) {
// Try progressively longer child names by joining segments with '_' or '-'
let matched = false;
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
const maxLook = Math.min(i + 4, segments.length);
for (let end = i; end < maxLook; end++) {
// Build candidate child name from segments[i..end]
// Try all separator combinations: for 2+ segments, try '_' first then '-'
const candidates: string[] = [];
if (end === i) {
candidates.push(segments[i]);
} else {
// Build with underscores between joined segments
candidates.push(segments.slice(i, end + 1).join('_'));
// Build with dashes (literal)
candidates.push(segments.slice(i, end + 1).join('-'));
}
for (const child of candidates) {
const candidate = current + '/' + child;
if (await isDir(candidate)) {
@@ -1289,12 +1416,10 @@ export function registerSessionRoutes(
if (matched) break;
}
if (!matched) {
// No directory match found — append as-is and move on
current = current + '/' + segments[i];
i++;
}
}
const finalExists = await fs
.access(current)
.then(() => true)
@@ -1333,96 +1458,272 @@ export function registerSessionRoutes(
}
}
app.get('/api/history/sessions', async () => {
type HistorySession = {
sessionId: string;
workingDir: string;
projectKey: string;
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
};
// Scan a single project directory and return all valid history sessions in it.
// Reused by both the global overview and the single-folder drill-down.
async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise<HistorySession[]> {
const out: HistorySession[] = [];
const stat = await fs.stat(projPath).catch(() => null);
if (!stat?.isDirectory()) return out;
const workingDir = await decodeProjectKey(projDir);
const entries = await fs.readdir(projPath).catch(() => [] as string[]);
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
const filePath = join(projPath, entry);
const fileStat = await fs.stat(filePath).catch(() => null);
if (!fileStat) continue;
if (fileStat.size < 4000) continue;
let firstPrompt: string | undefined;
const head = await readFileHead(filePath, headBuf);
const hasConversation = (text: string) =>
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
let foundContent = head ? hasConversation(head) : false;
let tail: string | null = null;
if (!foundContent && fileStat.size > 16384) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (tail) foundContent = hasConversation(tail);
}
if (!foundContent) continue;
if (head) firstPrompt = extractFirstUserPrompt(head);
if (!firstPrompt && fileStat.size > 65536) {
if (!tail) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
out.push({
sessionId,
workingDir,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
});
}
return out;
}
app.get('/api/history/sessions', async (req) => {
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const results: Array<{
sessionId: string;
workingDir: string;
projectKey: string;
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
}> = [];
const headBuf = Buffer.alloc(16384);
// Single-folder drill-down: when projectKey is provided, scan only that
// directory, bypass the 50-cap, and honor offset/limit pagination.
if (query.projectKey) {
// Validate projectKey format to prevent path traversal
if (!/^[A-Za-z0-9_-]+$/.test(query.projectKey)) {
return { sessions: [], total: 0 };
}
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
const projPath = join(projectsDir, query.projectKey);
const all = await scanProjectDir(projPath, query.projectKey, headBuf);
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
return { sessions: all.slice(offset, offset + limit), total: all.length };
}
// Global overview: scan all projects, return up to 50 most-recent sessions.
const results: HistorySession[] = [];
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const projPath = join(projectsDir, projDir);
const stat = await fs.stat(projPath).catch(() => null);
if (!stat?.isDirectory()) continue;
// Decode project key to working dir. Claude CLI encodes '/' as '-',
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
// Use recursive backtracking: try each '-' as either '/' or literal '-',
// verify which decoded path actually exists on disk.
const workingDir = await decodeProjectKey(projDir);
const entries = await fs.readdir(projPath);
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
// Only valid UUIDs
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
const filePath = join(projPath, entry);
const fileStat = await fs.stat(filePath).catch(() => null);
if (!fileStat) continue;
// Skip files too small to contain real conversation (metadata-only sessions
// like file-history-snapshot entries are typically < 4KB)
if (fileStat.size < 4000) continue;
// Quick content check: verify actual conversation data exists.
// Sessions with only file-history-snapshot or hook_progress entries have
// no "user"/"assistant" messages and will fail claude --resume.
// Read first 16KB to check content and extract first user prompt.
let firstPrompt: string | undefined;
const head = await readFileHead(filePath, headBuf);
const hasConversation = (text: string) =>
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
let foundContent = head ? hasConversation(head) : false;
// For large files, head may not contain user messages (e.g. /init followed
// by large system entries). Check the tail as well.
let tail: string | null = null;
if (!foundContent && fileStat.size > 16384) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (tail) foundContent = hasConversation(tail);
}
if (!foundContent) continue; // No conversation content — skip
if (head) firstPrompt = extractFirstUserPrompt(head);
// If head scan found no usable prompt (e.g. session started with /init),
// try reading the tail for a recent user message.
if (!firstPrompt && fileStat.size > 65536) {
if (!tail) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
results.push({
sessionId,
workingDir,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
});
}
const list = await scanProjectDir(projPath, projDir, headBuf);
results.push(...list);
}
} catch {
// Projects dir may not exist
}
// Sort by lastModified descending
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
return { sessions: results.slice(0, 50) };
});
// ═══════════════════════════════════════════════════════════════
// Paste Image (clipboard / drag-drop upload)
// ═══════════════════════════════════════════════════════════════
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
// The 10MB size cap is enforced by @fastify/multipart (registered in server.ts).
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
// CSRF defense: state-changing routes must come from same origin.
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
// (no preflight), so a cross-origin <form enctype="multipart/form-data">
// submit attaches the session cookie unimpeded. Reject unless Origin/Referer
// matches req.host. Non-browser clients (no Origin AND no Referer) must
// supply X-Codeman-CSRF — a header browsers cannot add cross-origin without
// a preflight, which our CORS config does not allow from other origins.
const reqHost = req.headers.host;
const origin = req.headers.origin;
const referer = req.headers.referer;
let csrfOk = false;
if (origin) {
try {
csrfOk = new URL(origin).host === reqHost;
} catch {
/* invalid Origin → not ok */
}
} else if (referer) {
try {
csrfOk = new URL(referer).host === reqHost;
} catch {
/* invalid Referer → not ok */
}
} else {
csrfOk = !!req.headers['x-codeman-csrf'];
}
if (!csrfOk) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
}
const { id } = req.params as { id: string };
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
// — even an authenticated attacker can otherwise loop 10MB POSTs.
if (!consumePasteToken(`${req.ip}:${id}`)) {
reply.code(429);
reply.header('Retry-After', '60');
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
}
const session = findSessionOrFail(ctx, id);
if (!req.isMultipart()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
}
// Read the single file part. @fastify/multipart enforces the 10MB size cap
// and the 1-file/4-field count limits (server.ts), replacing a hand-rolled
// boundary scanner with several bugs: literal boundary matches anywhere in
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
// offsets), no part-count cap.
let part: import('@fastify/multipart').MultipartFile | undefined;
try {
part = await req.file();
} catch (err: unknown) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload');
}
if (!part) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No image uploaded');
}
if (part.fieldname !== 'image') {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
}
let imageBytes: Buffer;
try {
imageBytes = await part.toBuffer();
} catch (err: unknown) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'File too large (max 10MB)');
}
if (imageBytes.length === 0) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
}
// Determine extension from filename or Content-Type.
let ext = '.png';
if (part.filename) {
const origExt = extname(part.filename).toLowerCase();
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
}
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/);
if (mimeMatch) {
const map: Record<string, string> = {
png: '.png',
jpeg: '.jpg',
jpg: '.jpg',
webp: '.webp',
gif: '.gif',
bmp: '.bmp',
};
ext = map[mimeMatch[1]] ?? ext;
}
if (!ALLOWED_IMAGE_EXTS.has(ext)) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Unsupported image type: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTS].join(', ')}`
);
}
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
if (!imageMagicMatchesExt(imageBytes, ext)) {
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
// mislabeled as image/jpeg, so the declared ext passes the allowlist but
// the magic bytes do not. Log the real header so format mismatches can be
// pinned down without a reproduce-and-guess loop. The client now
// re-encodes images to JPEG/PNG before upload, so this should be rare.
console.warn(
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
);
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
// Save to {workingDir}/.claude-images/
// Refuse symlinks at imageDir — an agent or postinstall script could plant
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
// We lstat (not stat) so we see the symlink itself. Use mkdir without
// `recursive` so the leaf creation does not follow a symlink either, and
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
const imageDir = join(session.workingDir, '.claude-images');
try {
const dirStat = await fs.lstat(imageDir);
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// Non-recursive mkdir: errors on EEXIST and does not follow symlinks for
// the leaf. session.workingDir is guaranteed to exist (live session).
await fs.mkdir(imageDir);
}
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
const filepath = join(imageDir, filename);
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
const fh = await fs.open(
filepath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
);
try {
await fh.writeFile(imageBytes);
} finally {
await fh.close();
}
return { success: true, path: filepath, filename };
});
}
+15
View File
@@ -80,6 +80,15 @@ const safeEnvOverridesSchema = z
}
);
// ========== Effort Level ==========
/**
* Claude CLI effort level for new sessions. Injected as a `--settings` soft default
* (NOT the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session and
* block in-session `/effort` switching). `ultracode` enables dynamic workflow orchestration.
*/
const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional();
// ========== Session Routes ==========
/**
@@ -124,6 +133,8 @@ export const CreateSessionSchema = z.object({
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
openCodeConfig: OpenCodeConfigSchema,
@@ -179,6 +190,8 @@ export const QuickStartSchema = z.object({
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
openCodeConfig: OpenCodeConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
});
// ========== Hook Events ==========
@@ -543,6 +556,8 @@ export const RalphLoopStartSchema = z.object({
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
enableRespawn: z.boolean().default(false),
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
planItems: z
.array(
z.object({
+118 -25
View File
@@ -32,12 +32,14 @@ import fastifyCompress from '@fastify/compress';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { startPasteImageGc } from './paste-image-gc.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { homedir } from 'node:os';
import { homedir, hostname as getHostname } from 'node:os';
import { EventEmitter } from 'node:events';
import { Session, type BackgroundTask } from '../session.js';
import type { ClaudeMode, SessionState } from '../types.js';
@@ -87,18 +89,18 @@ const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json');
import {
getErrorMessage,
ApiErrorCode,
createErrorResponse,
type PersistedRespawnConfig,
type NiceConfig,
type ImageDetectedEvent,
DEFAULT_NICE_CONFIG,
} from '../types.js';
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
import { CleanupManager, KeyedDebouncer, StaleExpirationMap, startEventLoopMonitor } from '../utils/index.js';
import type { EventLoopMonitorHandle } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import {
registerPushRoutes,
registerTeamRoutes,
@@ -119,6 +121,15 @@ import {
const __dirname = dirname(fileURLToPath(import.meta.url));
// Bounded, predictable shape for SSE client identifiers: alphanumerics, `_`, `-`.
// Length range covers crypto.randomUUID() (36 chars) plus any short stable IDs,
// while capping growth of `sseClientsById` and blocking pathological inputs.
const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
function escapeHtmlText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
import {
SESSIONS_LIST_CACHE_TTL,
SCHEDULED_CLEANUP_INTERVAL,
@@ -220,18 +231,26 @@ export class WebServer extends EventEmitter {
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
teamUpdated: (config: unknown) => void;
teamRemoved: (config: unknown) => void;
taskUpdated: (data: unknown) => void;
} | null = null;
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
private readonly titleHostname: string;
private readonly windowTitle: string;
private readonly indexHtmlTemplate: string;
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false, titleHostname?: string) {
super();
this.setMaxListeners(0);
this.port = port;
this.https = https;
this.testMode = testMode;
this.titleHostname = titleHostname || getHostname();
this.windowTitle = `codeman:${this.titleHostname}`;
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
if (https) {
const { key, cert } = getOrCreateSelfSignedCert();
@@ -497,11 +516,10 @@ export class WebServer extends EventEmitter {
}
private async setupRoutes(): Promise<void> {
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
// so the route handler can read the raw stream directly.
this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => {
done(null);
});
// multipart/form-data: parser is provided by @fastify/multipart (registered
// below). Its parser is a no-op marker that leaves the body on req.raw, so
// legacy routes that read the raw stream directly (e.g. /api/screenshots)
// continue to work alongside routes that use req.file() (e.g. paste-image).
// Enable gzip/brotli compression for all responses.
// Massive win: 793KB uncompressed → ~120KB compressed for static assets.
@@ -524,8 +542,26 @@ export class WebServer extends EventEmitter {
// WebSocket support (terminal I/O — low-latency bidirectional channel)
await this.app.register(fastifyWebsocket);
// Multipart parsing (used by paste-image). Replaces a hand-rolled
// boundary scanner that had several edge-case bugs: literal boundary
// anywhere in body was a match, LF-only clients silently corrupted the
// last byte (hard-coded \r\n offsets), and there was no part-count cap.
await this.app.register(fastifyMultipart, {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB per file
files: 1, // paste-image only ever sends one file
fields: 4, // small headroom for accompanying form fields
},
});
// Security headers + CORS
registerSecurityHeaders(this.app, this.https);
this.app.get('/', async (_req, reply) => {
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
});
this.app.get('/index.html', async (_req, reply) => {
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
});
// Service worker must never be cached — browsers check for SW updates on navigation
this.app.get('/sw.js', async (_req, reply) => {
return reply
@@ -563,9 +599,11 @@ export class WebServer extends EventEmitter {
}
// Parse optional session subscription filter from query parameter.
// /api/events?sessions=id1,id2 — client only receives events for those sessions.
// /api/events (no param) — client receives all events (backwards-compatible).
const query = req.query as { sessions?: string };
// /api/events?sessions=id1,id2 — client only receives session:terminal
// events for those sessions (other events broadcast to all clients).
// /api/events?clientId=<uuid> — enables live filter updates via
// POST /api/events/subscribe without reconnecting.
const query = req.query as { sessions?: string; clientId?: string };
let sessionFilter: Set<string> | null = null;
if (query.sessions) {
const ids = query.sessions
@@ -576,6 +614,8 @@ export class WebServer extends EventEmitter {
sessionFilter = new Set(ids);
}
}
const clientId =
typeof query.clientId === 'string' && SSE_CLIENT_ID_RE.test(query.clientId) ? query.clientId : undefined;
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
@@ -587,7 +627,7 @@ export class WebServer extends EventEmitter {
// Track tunnel clients — cloudflared proxies locally so req.ip is always
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
const isRemote = !!req.headers['cf-connecting-ip'];
this.sse.addClient(reply, sessionFilter, isRemote);
this.sse.addClient(reply, sessionFilter, isRemote, clientId);
// Send initial state
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
@@ -602,17 +642,26 @@ export class WebServer extends EventEmitter {
});
});
// Global error handler for structured errors thrown by findSessionOrFail
this.app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
// Live subscription update — change a connected client's session filter
// without forcing an SSE reconnect. Body: { clientId, sessions: string[] | null }
// Empty/null sessions array = remove filter (receive all session:terminal events).
this.app.post('/api/events/subscribe', (req, reply) => {
const body = (req.body || {}) as { clientId?: string; sessions?: string[] | null };
if (typeof body.clientId !== 'string' || !SSE_CLIENT_ID_RE.test(body.clientId)) {
reply.code(400).send({ error: 'clientId required' });
return;
}
const sessions = Array.isArray(body.sessions)
? body.sessions.filter((s) => typeof s === 'string' && s.length > 0 && s.length <= 128).slice(0, 64)
: null;
const updated = this.sse.updateClientFilter(body.clientId, sessions);
reply.code(updated ? 204 : 404).send();
});
// Global error handler for structured errors thrown by findSessionOrFail /
// parseBody. Shared with the route test harness so test behavior matches prod.
installRouteErrorHandler(this.app);
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
let _crashBreadcrumbs = '';
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
@@ -910,6 +959,15 @@ export class WebServer extends EventEmitter {
fileStreamManager.closeSessionStreams(sessionId);
// Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId);
// Clean up pasted images directory for this session
if (killMux && session.workingDir) {
const pasteImageDir = join(session.workingDir, '.claude-images');
try {
rmSync(pasteImageDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
}
await session.stop(killMux);
this.sessions.delete(sessionId);
// Only remove from state.json if we're also killing the mux session.
@@ -922,6 +980,13 @@ export class WebServer extends EventEmitter {
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
}
private renderIndexHtml(): string {
return this.indexHtmlTemplate.replace(
'<title>Codeman</title>',
`<title>${escapeHtmlText(this.windowTitle)}</title>`
);
}
private async setupSessionListeners(session: Session): Promise<void> {
// Create run summary tracker for this session
const summaryTracker = new RunSummaryTracker(session.id, session.name);
@@ -1414,6 +1479,10 @@ export class WebServer extends EventEmitter {
const payload = JSON.stringify({
title: template.title,
// Hostname-aware prefix so OS-level notifications from multiple Codeman
// instances (laptop / dev box / NAS) are unambiguous in the system tray.
// Mirrors the in-page Notification format in notification-manager.js.
hostTitle: this.windowTitle,
body,
tag: `codeman-${event}-${sessionId}`,
sessionId,
@@ -1480,6 +1549,16 @@ export class WebServer extends EventEmitter {
// Clean up stale sessions from state file that don't have active mux sessions
this.cleanupStaleSessions();
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) {
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
// Surface event-loop stalls (e.g. a slow synchronous tmux/ps call) so the
// intermittent ":3000 briefly unreachable, process never restarts" class of
// incident leaves a quantified log line instead of vanishing silently.
this._eventLoopMonitor = startEventLoopMonitor();
}
await this.app.listen({ port: this.port, host: '0.0.0.0' });
const protocol = this.https ? 'https' : 'http';
console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`);
@@ -1637,6 +1716,8 @@ export class WebServer extends EventEmitter {
const recoveryClaudeMode = await this.getClaudeModeConfig();
// Recover envOverrides from the internal __envOverrides field written by
// session-manager (see updateSessionState). Cast to read the non-public field.
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
// by the Session constructor (env var would hard-lock /effort switching).
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
const session = new Session({
id: muxSession.sessionId, // Preserve the original session ID
@@ -1649,6 +1730,7 @@ export class WebServer extends EventEmitter {
claudeMode: recoveryClaudeMode.claudeMode,
allowedTools: recoveryClaudeMode.allowedTools,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
});
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
@@ -1833,6 +1915,16 @@ export class WebServer extends EventEmitter {
// Set stopping flag to prevent new timer creation during shutdown
this.sse.setStopping();
if (this._pasteImageGcStop) {
this._pasteImageGcStop();
this._pasteImageGcStop = null;
}
if (this._eventLoopMonitor) {
this._eventLoopMonitor.stop();
this._eventLoopMonitor = null;
}
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
@@ -1970,9 +2062,10 @@ export class WebServer extends EventEmitter {
export async function startWebServer(
port: number = 3000,
https: boolean = false,
testMode: boolean = false
testMode: boolean = false,
titleHostname?: string
): Promise<WebServer> {
const server = new WebServer(port, https, testMode);
const server = new WebServer(port, https, testMode, titleHostname);
await server.start();
return server;
}
+37 -26
View File
@@ -48,6 +48,8 @@ export class SseStreamManager {
* or `null` meaning "receive all events" (backwards-compatible default).
*/
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
/** Optional client-supplied IDs → reply, for live filter updates without reconnecting */
private sseClientsById: Map<string, FastifyReply> = new Map();
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
private remoteSseClients: Set<FastifyReply> = new Set();
/** Clients with backpressure — skip writes until 'drain' fires */
@@ -103,17 +105,43 @@ export class SseStreamManager {
this._isTunnelActive = active;
}
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean): void {
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean, clientId?: string): void {
this.sseClients.set(reply, sessionFilter);
if (isRemote) {
this.remoteSseClients.add(reply);
}
if (clientId) {
// If a previous reply registered the same id (reconnect), drop the old one.
const prev = this.sseClientsById.get(clientId);
if (prev && prev !== reply) {
this.sseClients.delete(prev);
this.remoteSseClients.delete(prev);
this.backpressuredClients.delete(prev);
}
this.sseClientsById.set(clientId, reply);
}
}
removeClient(reply: FastifyReply): void {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
// Clear any clientId mappings pointing at this reply
for (const [id, r] of this.sseClientsById) {
if (r === reply) this.sseClientsById.delete(id);
}
}
/**
* Update an existing client's session subscription filter without forcing
* an SSE reconnect. Returns true if the client was found and updated.
*/
updateClientFilter(clientId: string, sessions: string[] | null): boolean {
const reply = this.sseClientsById.get(clientId);
if (!reply || !this.sseClients.has(reply)) return false;
const filter = sessions && sessions.length > 0 ? new Set(sessions) : null;
this.sseClients.set(reply, filter);
return true;
}
/** Send a single SSE event to a specific client. */
@@ -188,35 +216,18 @@ export class SseStreamManager {
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
return;
}
// Extract sessionId from event data for subscription filtering.
const eventSessionId = this.extractSessionId(event, data);
for (const [client, filter] of this.sseClients) {
// No filter (null) = receive everything. Otherwise, skip if event is
// session-scoped and the session isn't in the client's subscription set.
if (filter && eventSessionId && !filter.has(eventSessionId)) continue;
// Subscription filtering is intentionally NOT applied here. The
// `?sessions=` filter is intended to suppress only the high-volume
// terminal stream — lifecycle/metadata events (session:created,
// session:updated, ralph:*, hook:*, etc.) are needed for correct UI
// state across all sessions even when the client subscribes to a single
// active session's terminal output. Terminal events bypass this method
// entirely (see flushSessionTerminalBatch — it applies the filter).
for (const [client] of this.sseClients) {
this.sendSSEPreformatted(client, message);
}
}
/**
* Extract the session ID from an event's data payload for subscription filtering.
* Returns the sessionId string if the event is session-scoped, or null for global events.
*/
private extractSessionId(event: string, data: unknown): string | null {
if (data == null || typeof data !== 'object') return null;
const record = data as Record<string, unknown>;
// Most session-scoped events use `sessionId`
if (typeof record.sessionId === 'string') return record.sessionId;
// Session lifecycle events (session:*) use `id` from the session state object
if (typeof record.id === 'string' && event.startsWith('session:')) return record.id;
// No session ID found — treat as global event (sent to all clients)
return null;
}
// ========== Terminal Data Batching ==========
// Batch terminal data for better performance (60fps)
+122
View File
@@ -0,0 +1,122 @@
/**
* @fileoverview Tests for Claude CLI effort level injection.
*
* Effort must flow as a `--settings` SOFT default (overridable in-session via
* /effort, incl. ultracode) — never as the CLAUDE_CODE_EFFORT_LEVEL env var,
* which hard-locks the session. Also covers the legacy migration path: old
* persisted sessions carried effort inside __envOverrides.
*/
import { describe, it, expect } from 'vitest';
import { buildEffortCliArgs, buildInteractiveArgs } from '../src/session-cli-builder.js';
import { isEffortLevel, EFFORT_LEVELS } from '../src/types.js';
import { Session } from '../src/session.js';
describe('buildEffortCliArgs', () => {
it('maps regular levels (incl. max) to the --effort flag', () => {
// NOT the settings effortLevel key: its enum lacks "max" and silently drops it
expect(buildEffortCliArgs('low')).toEqual(['--effort', 'low']);
expect(buildEffortCliArgs('high')).toEqual(['--effort', 'high']);
expect(buildEffortCliArgs('xhigh')).toEqual(['--effort', 'xhigh']);
expect(buildEffortCliArgs('max')).toEqual(['--effort', 'max']);
});
it('maps ultracode to its dedicated --settings boolean key', () => {
// The --effort flag rejects ultracode; only the settings key enables it at spawn
expect(buildEffortCliArgs('ultracode')).toEqual(['--settings', '{"ultracode":true}']);
});
it('returns empty args for missing or invalid values', () => {
expect(buildEffortCliArgs(undefined)).toEqual([]);
// Invalid strings must not reach the shell command (injection guard)
expect(buildEffortCliArgs('"; rm -rf /' as never)).toEqual([]);
expect(buildEffortCliArgs('turbo' as never)).toEqual([]);
});
it('produces a flag/value pair for every allowed level', () => {
for (const level of EFFORT_LEVELS) {
const args = buildEffortCliArgs(level);
expect(args).toHaveLength(2);
expect(args[0]).toMatch(/^--(effort|settings)$/);
}
});
});
describe('isEffortLevel', () => {
it('accepts all defined levels and rejects everything else', () => {
for (const level of EFFORT_LEVELS) {
expect(isEffortLevel(level)).toBe(true);
}
expect(isEffortLevel(undefined)).toBe(false);
expect(isEffortLevel('')).toBe(false);
expect(isEffortLevel('ULTRACODE')).toBe(false);
});
});
describe('buildInteractiveArgs with effort', () => {
it('appends --settings for ultracode', () => {
const args = buildInteractiveArgs('sid-123', 'dangerously-skip-permissions', undefined, undefined, 'ultracode');
const idx = args.indexOf('--settings');
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('{"ultracode":true}');
});
it('appends --effort for max', () => {
const args = buildInteractiveArgs('sid-123', 'dangerously-skip-permissions', undefined, undefined, 'max');
const idx = args.indexOf('--effort');
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('max');
});
it('omits effort args when effort is absent', () => {
const args = buildInteractiveArgs('sid-123', 'dangerously-skip-permissions');
expect(args).not.toContain('--settings');
expect(args).not.toContain('--effort');
});
});
describe('Session effort handling', () => {
it('stores explicit effort and exposes it in toState()', () => {
const session = new Session({ workingDir: '/tmp', effort: 'ultracode' });
expect(session.toState().effort).toBe('ultracode');
});
it('migrates legacy CLAUDE_CODE_EFFORT_LEVEL out of envOverrides', () => {
const session = new Session({
workingDir: '/tmp',
envOverrides: {
CLAUDE_CODE_EFFORT_LEVEL: 'high',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
},
});
// Legacy env var becomes the soft-default effort...
expect(session.toState().effort).toBe('high');
// ...and is never persisted (or exported) as an env var again
expect(session.getEnvOverridesForPersist()).toEqual({
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
});
});
it('drops an invalid legacy effort value instead of forwarding it', () => {
const session = new Session({
workingDir: '/tmp',
envOverrides: { CLAUDE_CODE_EFFORT_LEVEL: 'bogus-value' },
});
expect(session.toState().effort).toBeUndefined();
expect(session.getEnvOverridesForPersist()).toBeUndefined();
});
it('prefers explicit effort over the legacy env var', () => {
const session = new Session({
workingDir: '/tmp',
effort: 'ultracode',
envOverrides: { CLAUDE_CODE_EFFORT_LEVEL: 'low' },
});
expect(session.toState().effort).toBe('ultracode');
});
it('leaves effort undefined when nothing is configured', () => {
const session = new Session({ workingDir: '/tmp' });
expect(session.toState().effort).toBeUndefined();
});
});
+30 -20
View File
@@ -49,8 +49,10 @@ async function waitForElement(selector: string, timeout = 10000): Promise<boolea
try {
const count = browserJson<{ count: number }>(`get count "${selector}"`);
if (count.count > 0) return true;
} catch { /* retry */ }
await new Promise(r => setTimeout(r, 500));
} catch {
/* retry */
}
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
@@ -74,7 +76,9 @@ function isVisible(selector: string): boolean {
function closeBrowser() {
try {
browser('close');
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
describe('File Link Click Tests', () => {
@@ -95,14 +99,14 @@ describe('File Link Click Tests', () => {
server = new WebServer(TEST_PORT, false, true);
await server.start();
await new Promise(r => setTimeout(r, 1000));
await new Promise((r) => setTimeout(r, 1000));
// Test if browser is available
try {
browser(`open ${baseUrl}`);
await new Promise(r => setTimeout(r, 2000));
await new Promise((r) => setTimeout(r, 2000));
const title = browserJson<{ title: string }>('get title');
browserAvailable = title.title === 'Codeman';
browserAvailable = title.title.startsWith('codeman:');
} catch (e) {
console.warn('Browser not available, skipping browser tests:', (e as Error).message);
browserAvailable = false;
@@ -114,14 +118,18 @@ describe('File Link Click Tests', () => {
for (const sessionId of createdSessions) {
try {
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
await server.stop();
// Cleanup test directory
try {
rmSync(testDir, { recursive: true, force: true });
} catch { /* ignore */ }
} catch {
/* ignore */
}
}, 60000);
it('should create shell session and display terminal output', async () => {
@@ -142,7 +150,7 @@ describe('File Link Click Tests', () => {
createdSessions.push(data.session.id);
// Wait for session to appear in UI
await new Promise(r => setTimeout(r, 2000));
await new Promise((r) => setTimeout(r, 2000));
// Check that terminal is visible
const terminalExists = await waitForElement('.xterm-screen', 5000);
@@ -167,7 +175,7 @@ describe('File Link Click Tests', () => {
body: JSON.stringify({ input: command + '\r' }),
});
await new Promise(r => setTimeout(r, 2000));
await new Promise((r) => setTimeout(r, 2000));
// Check if xterm contains the file path
// The xterm link provider should detect "tail -f /path/to/file" pattern
@@ -204,7 +212,7 @@ describe('File Link Click Tests', () => {
// Click somewhere in the terminal where the tail -f line should be
// This is approximate - the link detection works on hover
browser('click ".xterm-screen"');
await new Promise(r => setTimeout(r, 500));
await new Promise((r) => setTimeout(r, 500));
} catch (e) {
console.log('Click failed:', e);
}
@@ -243,10 +251,10 @@ describe('File Link Click Tests', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: `echo "${pattern}"\r` }),
});
await new Promise(r => setTimeout(r, 500));
await new Promise((r) => setTimeout(r, 500));
}
await new Promise(r => setTimeout(r, 1000));
await new Promise((r) => setTimeout(r, 1000));
// Verify patterns appear in terminal
const terminalText = getText('.xterm-screen');
@@ -255,11 +263,12 @@ describe('File Link Click Tests', () => {
}
}, 60000);
it('should match file paths with various command patterns', () => {
it('should match file paths with various command patterns', () => {
// Unit test for pattern matching logic - runs without browser
// Pattern matches: tail -f /path, grep pattern /path, cat -n /path
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
const extPattern =
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
// Test cmdPattern
@@ -309,13 +318,14 @@ it('should match file paths with various command patterns', () => {
});
it('should NOT match invalid or unsafe paths', () => {
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
const extPattern =
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
const invalidCases = [
'This is just text without paths',
'./relative/path.log', // relative path
'C:\\Windows\\path.log', // windows path
'/usr/bin/something.log', // /usr not in allowed prefixes
'./relative/path.log', // relative path
'C:\\Windows\\path.log', // windows path
'/usr/bin/something.log', // /usr not in allowed prefixes
];
for (const line of invalidCases) {
@@ -337,7 +347,7 @@ it('should match file paths with various command patterns', () => {
});
const data = await response.json();
expect(data.success).toBe(true);
sessionId = data.sessionId; // quick-start returns sessionId directly
sessionId = data.sessionId; // quick-start returns sessionId directly
createdSessions.push(sessionId);
}
+281
View File
@@ -0,0 +1,281 @@
/**
* Inline rename input tests.
*
* Covers the three fixes shipped after the audit of #81:
* 1. CJK composition guard — Enter/Escape during IME composition belong to
* the IME and must not commit/cancel the rename.
* 2. Ghost tab cleanup — when a session is deleted while its tab is being
* renamed, _cleanupSessionData() must cancel the rename so the inline
* <input> doesn't ghost on screen.
* 3. Settle-once — cancel()/blur convergence is idempotent and reliably
* clears _activeRename, even on repeated invocation.
*
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
*
* Port: 3164 (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3164;
const BASE_URL = `http://localhost:${PORT}`;
describe('Inline rename input', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true); // testMode = true
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
// Wait for app.js to expose window.app and finish constructor init.
await page.waitForFunction(
() =>
typeof (window as { app?: unknown }).app !== 'undefined' &&
!!(window as { app?: { sessions?: Map<string, unknown> } }).app?.sessions
);
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
// Reset state between tests so each starts from a clean slate.
async function resetState(): Promise<void> {
await page.evaluate(() => {
const app = (
window as unknown as { app: { _activeRename: { cancel: () => void } | null; sessions: Map<string, unknown> } }
).app;
if (app._activeRename) app._activeRename.cancel();
app.sessions.clear();
document.querySelectorAll('[data-test-tab]').forEach((n) => n.remove());
});
// Allow any cancel-triggered renderSessionTabs to settle.
await page.waitForTimeout(20);
}
// Helper: stub a session + tab-name DOM node, then start rename.
// Returns whether the rename input was successfully created.
async function startRename(sessionId: string, name: string): Promise<boolean> {
return page.evaluate(
({ id, name }) => {
const app = (
window as unknown as {
app: {
sessions: Map<string, { id: string; name: string }>;
startInlineRename: (id: string) => void;
};
}
).app;
app.sessions.set(id, { id, name });
const wrap = document.createElement('div');
wrap.setAttribute('data-test-tab', '1');
const tabName = document.createElement('span');
tabName.className = 'tab-name';
tabName.setAttribute('data-session-id', id);
tabName.textContent = name;
wrap.appendChild(tabName);
document.body.appendChild(wrap);
app.startInlineRename(id);
return !!tabName.querySelector('input.tab-rename-input');
},
{ id: sessionId, name }
);
}
it('CJK guard: Enter with isComposing=true does not commit', async () => {
await resetState();
expect(await startRename('cjk-isc', 'OldName')).toBe(true);
const result = await page.evaluate(() => {
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
const input = document.querySelector('input.tab-rename-input') as HTMLInputElement;
input.value = 'partial-pinyin';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', isComposing: true, bubbles: true }));
return {
inputStillInDom: document.body.contains(input),
renameStillActive: !!app._activeRename,
};
});
expect(result.inputStillInDom).toBe(true);
expect(result.renameStillActive).toBe(true);
});
it('CJK guard: Enter with legacy keyCode 229 does not commit', async () => {
await resetState();
expect(await startRename('cjk-229', 'OldName')).toBe(true);
const renameStillActive = await page.evaluate(() => {
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
const input = document.querySelector('input.tab-rename-input') as HTMLInputElement;
// Some Safari/Edge versions report keyCode 229 with isComposing=false on the
// Enter that triggers compositionend — the legacy guard catches that case.
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, bubbles: true }));
return !!app._activeRename;
});
expect(renameStillActive).toBe(true);
});
it('CJK guard: regular Enter (no IME) DOES commit', async () => {
await resetState();
expect(await startRename('regular-enter', 'OldName')).toBe(true);
// Stub fetch so the commit doesn't hit the real API.
const result = await page.evaluate(async () => {
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
let fetchUrl: string | null = null;
const origFetch = window.fetch;
window.fetch = (async (input: RequestInfo | URL) => {
fetchUrl = String(input);
return new Response('{"success":true}', { status: 200 });
}) as typeof window.fetch;
const inputEl = document.querySelector('input.tab-rename-input') as HTMLInputElement;
inputEl.value = 'NewName';
inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
// Enter calls input.blur() which fires the async finishRename. Wait for it.
await new Promise((r) => setTimeout(r, 30));
window.fetch = origFetch;
return { fetchUrl, renameActive: !!app._activeRename };
});
expect(result.fetchUrl).toContain('/api/sessions/regular-enter/name');
expect(result.renameActive).toBe(false);
});
it('Ghost tab: _cleanupSessionData cancels rename for the deleted session', async () => {
await resetState();
expect(await startRename('ghost-id', 'OldName')).toBe(true);
const result = await page.evaluate(async () => {
const app = (
window as unknown as {
app: {
_activeRename: { sessionId: string } | null;
sessions: Map<string, unknown>;
_cleanupSessionData: (id: string) => void;
};
}
).app;
let fetchFired = false;
const origFetch = window.fetch;
window.fetch = (async (input: RequestInfo | URL) => {
if (String(input).includes('/api/sessions/ghost-id/name')) fetchFired = true;
return new Response('{}', { status: 200 });
}) as typeof window.fetch;
const matchedBefore = app._activeRename?.sessionId === 'ghost-id';
app._cleanupSessionData('ghost-id');
// Cancel triggers async renderSessionTabs; allow it to settle.
await new Promise((r) => setTimeout(r, 50));
window.fetch = origFetch;
return {
matchedBefore,
renameActiveAfter: !!app._activeRename,
sessionGone: !app.sessions.has('ghost-id'),
fetchFired,
};
});
expect(result.matchedBefore).toBe(true);
expect(result.renameActiveAfter).toBe(false);
expect(result.sessionGone).toBe(true);
// Cancel path skips the API call — deleting a session shouldn't trigger a stale rename PUT.
expect(result.fetchFired).toBe(false);
});
it('Ghost tab: _cleanupSessionData for a DIFFERENT session does NOT cancel rename', async () => {
await resetState();
expect(await startRename('keep-rename', 'OldName')).toBe(true);
const result = await page.evaluate(() => {
const app = (
window as unknown as {
app: {
_activeRename: unknown;
sessions: Map<string, { id: string; name: string }>;
_cleanupSessionData: (id: string) => void;
};
}
).app;
// Add an unrelated session and delete it — the rename for keep-rename must survive.
app.sessions.set('unrelated', { id: 'unrelated', name: 'X' });
app._cleanupSessionData('unrelated');
return { renameStillActive: !!app._activeRename };
});
expect(result.renameStillActive).toBe(true);
});
it('Settle-once: cancel() is idempotent and clears _activeRename', async () => {
await resetState();
expect(await startRename('idempotent-id', 'OldName')).toBe(true);
const result = await page.evaluate(async () => {
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
const cancelFn = app._activeRename!.cancel;
cancelFn();
const afterFirst = app._activeRename;
let threw = false;
try {
cancelFn();
} catch {
threw = true;
}
// Allow any async re-renders to settle.
await new Promise((r) => setTimeout(r, 30));
const afterSecond = app._activeRename;
return { afterFirstNull: afterFirst === null, afterSecondNull: afterSecond === null, threw };
});
expect(result.afterFirstNull).toBe(true);
expect(result.afterSecondNull).toBe(true);
expect(result.threw).toBe(false);
});
it('Re-entry: starting rename while one is active aborts the previous one', async () => {
await resetState();
expect(await startRename('first-id', 'First')).toBe(true);
const result = await page.evaluate(() => {
const app = (
window as unknown as {
app: {
_activeRename: { sessionId: string } | null;
sessions: Map<string, { id: string; name: string }>;
startInlineRename: (id: string) => void;
};
}
).app;
const firstActive = app._activeRename?.sessionId;
// Start a second rename without cancelling — startInlineRename should
// pre-emptively cancel the previous one so state never gets stuck on the dead session.
app.sessions.set('second-id', { id: 'second-id', name: 'Second' });
const wrap = document.createElement('div');
wrap.setAttribute('data-test-tab', '1');
const tabName = document.createElement('span');
tabName.className = 'tab-name';
tabName.setAttribute('data-session-id', 'second-id');
tabName.textContent = 'Second';
wrap.appendChild(tabName);
document.body.appendChild(wrap);
app.startInlineRename('second-id');
return { firstActive, secondActive: app._activeRename?.sessionId };
});
expect(result.firstActive).toBe('first-id');
expect(result.secondActive).toBe('second-id');
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { WebServer } from '../../src/web/server.js';
import { WebServer } from '../../../src/web/server.js';
let servers: Map<number, WebServer> = new Map();
+1
View File
@@ -90,6 +90,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
// -- InfraPort --
mux: {
muxSocket: 'codeman',
createSession: vi.fn(),
killSession: vi.fn(),
listSessions: vi.fn(() => []),
+28 -27
View File
@@ -3,7 +3,8 @@
*
* Covers:
* - SSE subscription filter edge cases (empty params, whitespace, duplicates)
* - extractSessionId logic (sessionId vs id field, global events)
* - Lifecycle-event broadcast contract (session:*, case:* fan out to all clients;
* only session:terminal is filtered by subscription)
* - Tab switching: terminal buffer loading, session creation + switch
* - Terminal data cap / backpressure recovery
* - Lazy teammate terminal lifecycle
@@ -254,11 +255,11 @@ describe('Operation Lightspeed', () => {
});
// ═══════════════════════════════════════════════════════════════
// extractSessionId — Event Classification
// Lifecycle Event Broadcast — Event Classification
// ═══════════════════════════════════════════════════════════════
describe('extractSessionId via SSE Filtering', () => {
it('should route session:updated events by id field', async () => {
describe('Lifecycle Event Broadcast Contract', () => {
it('should deliver session:updated events to all clients regardless of filter', async () => {
// Create two sessions
const session1 = await createSession(baseUrl);
const session2 = await createSession(baseUrl);
@@ -310,21 +311,23 @@ describe('Operation Lightspeed', () => {
const events = parseSSEEvents(receivedData);
// Should receive session:updated for session1 only
// New contract: session:updated is a lifecycle event that broadcasts to ALL clients.
// The subscription filter only applies to session:terminal.
const updatedEvents = events.filter((e) => e.event === 'session:updated');
const session1Updated = updatedEvents.find((e) => (e.data as any).id === session1);
const session2Updated = updatedEvents.find((e) => (e.data as any).id === session2);
expect(session1Updated).toBeDefined();
expect(session2Updated).toBeUndefined();
expect(session2Updated).toBeDefined();
// Cleanup
await deleteSession(baseUrl, session1);
await deleteSession(baseUrl, session2);
});
it('should filter session:deleted by session ID (sessionId extraction from id field)', async () => {
// Tests extractSessionId's fallback path: session:* events use `id` not `sessionId`
it('should deliver session:deleted events to all clients regardless of filter', async () => {
// New contract: lifecycle events (session:*) broadcast to every connected client;
// the per-client filter no longer gates them. Only session:terminal is filtered.
const target = await createSession(baseUrl);
const other = await createSession(baseUrl);
@@ -367,13 +370,12 @@ describe('Operation Lightspeed', () => {
const events = parseSSEEvents(receivedData);
// Target deletion should arrive (extractSessionId matches `id` field for session:* events)
// Both deletions arrive regardless of the per-client filter
const targetDeleted = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === target);
expect(targetDeleted).toBeDefined();
// Other deletion should NOT arrive
const otherDeleted = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === other);
expect(otherDeleted).toBeUndefined();
expect(otherDeleted).toBeDefined();
});
});
@@ -488,13 +490,13 @@ describe('Operation Lightspeed', () => {
expect(events.find((e) => e.event === 'init')).toBeDefined();
});
it('should handle multiple SSE clients with different filters', async () => {
it('should fan lifecycle events out to all SSE clients regardless of filter', async () => {
const session1 = await createSession(baseUrl);
const session2 = await createSession(baseUrl);
// Client A: subscribes to session1
// Client B: subscribes to session2
// Client C: no filter (all events)
// Client A: subscribes to session1, Client B: subscribes to session2, Client C: no filter.
// Under the broadcast contract, all three see every session:deleted event — the filter
// only narrows session:terminal traffic.
const controllerA = new AbortController();
const controllerB = new AbortController();
const controllerC = new AbortController();
@@ -585,15 +587,13 @@ describe('Operation Lightspeed', () => {
const eventsB = parseSSEEvents(dataB);
const eventsC = parseSSEEvents(dataC);
// Client A: sees session1 deleted, not session2
// Every client sees both deletions — lifecycle events are not filter-gated.
expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined();
expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeUndefined();
expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined();
// Client B: sees session2 deleted, not session1
expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined();
expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined();
expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeUndefined();
// Client C: sees both
expect(eventsC.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined();
expect(eventsC.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined();
});
@@ -991,13 +991,13 @@ describe('Operation Lightspeed', () => {
});
// ═══════════════════════════════════════════════════════════════
// extractSessionId — Additional Edge Cases
// Lifecycle Event Broadcast — Additional Edge Cases
// ═══════════════════════════════════════════════════════════════
describe('extractSessionId — Edge Cases via SSE', () => {
describe('Lifecycle Event Broadcast — Edge Cases via SSE', () => {
it('should treat non-session: events with id field as global (not filtered)', async () => {
// Events like case:created have an `id` field but aren't session:* events.
// extractSessionId should NOT use the `id` field for non-session:* events.
// Under the broadcast contract they reach every connected client.
const controller = new AbortController();
let receivedData = '';
@@ -1052,8 +1052,9 @@ describe('Operation Lightspeed', () => {
}
});
it('should deliver session:created for a newly created session to unfiltered client but not mismatched filter', async () => {
// session:created uses `id` field and starts with `session:` — extractSessionId should match it
it('should deliver session:created to every client, even those with a mismatched filter', async () => {
// Under the broadcast contract, lifecycle events ignore the per-client filter.
// A client subscribed only to `existing` still receives `session:created` for `newSession`.
const existing = await createSession(baseUrl);
// Subscribe to existing session only
@@ -1091,9 +1092,9 @@ describe('Operation Lightspeed', () => {
}
const events = parseSSEEvents(receivedData);
// session:created for newSession should be filtered OUT (id doesn't match our filter)
// session:created reaches the filtered client even though its id doesn't match the filter.
const createdEvent = events.find((e) => e.event === 'session:created' && (e.data as any).id === newSession);
expect(createdEvent).toBeUndefined();
expect(createdEvent).toBeDefined();
await Promise.all([deleteSession(baseUrl, existing), deleteSession(baseUrl, newSession)]);
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Verifies that web-push payloads include the hostname-aware `hostTitle`
* field so service-worker OS notifications can disambiguate Codeman
* instances on multiple machines (laptop / dev box / NAS).
*
* The in-page Notification path (notification-manager.js) prefixes with
* `${originalTitle}: ${title}` reading from `document.title`. The service
* worker has no access to document.title, so the server must ship the
* prefix in the push payload itself.
*
* Strategy: mock the `web-push` module, instantiate WebServer (no port
* binding — start() is never called), stub the push store with one fake
* subscription, then call the private sendPushNotifications and inspect
* the JSON payload handed to webpush.sendNotification.
*
* Port: N/A (no server start)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// vi.mock is hoisted to the top of the file, so factory captures must use
// vi.hoisted() to be initialized before the mocked import is evaluated.
const { sendNotification, setVapidDetails, generateVAPIDKeys } = vi.hoisted(() => ({
sendNotification: vi.fn(async () => undefined),
setVapidDetails: vi.fn(),
generateVAPIDKeys: vi.fn(() => ({
publicKey: 'test-public-key',
privateKey: 'test-private-key',
})),
}));
vi.mock('web-push', () => ({
default: { sendNotification, setVapidDetails, generateVAPIDKeys },
}));
import { WebServer } from '../src/web/server.js';
interface PushPayload {
title: string;
hostTitle?: string;
body: string;
tag: string;
sessionId: string;
urgency: string;
actions?: Array<{ action: string; title: string }>;
}
function makeServerWithHost(host: string): WebServer {
// Constructor only assigns fields — no network/disk activity until start().
const server = new WebServer(0, false, true, host);
// Stub push store: one subscription with all events enabled.
const fakeSub = {
endpoint: 'https://push.example.com/abc',
keys: { p256dh: 'k1', auth: 'k2' },
pushPreferences: {} as Record<string, boolean>,
};
const stubStore = {
getAll: () => [fakeSub],
getVapidKeys: () => ({ publicKey: 'pub', privateKey: 'priv', generatedAt: 0 }),
removeByEndpoint: vi.fn(),
};
(server as unknown as { pushStore: typeof stubStore }).pushStore = stubStore;
return server;
}
function lastPayload(): PushPayload {
expect(sendNotification).toHaveBeenCalled();
const call = sendNotification.mock.calls[sendNotification.mock.calls.length - 1];
return JSON.parse(call[1] as string) as PushPayload;
}
describe('push payload hostTitle (Web Push hostname plumbing)', () => {
beforeEach(() => {
sendNotification.mockClear();
setVapidDetails.mockClear();
});
it('includes hostTitle = codeman:<titleHostname> in the payload', () => {
const server = makeServerWithHost('laptop');
(
server as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
}
).sendPushNotifications('hook:idle_prompt', {
sessionId: 's-1',
sessionName: 'mysession',
});
const payload = lastPayload();
expect(payload.hostTitle).toBe('codeman:laptop');
// The bare event title is preserved separately so the SW can compose them.
expect(payload.title).toBe('Waiting for Input');
});
it('falls back to os.hostname() when --title-hostname is not provided', () => {
const server = makeServerWithHost(''); // empty -> constructor uses getHostname()
(
server as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
}
).sendPushNotifications('hook:permission_prompt', {
sessionId: 's-2',
sessionName: 'sess',
tool_name: 'Bash',
});
const payload = lastPayload();
expect(payload.hostTitle).toMatch(/^codeman:.+/);
expect(payload.hostTitle).not.toBe('codeman:');
expect(payload.title).toBe('Permission Required');
});
it('different WebServer instances ship distinct hostTitles', () => {
const a = makeServerWithHost('host-a');
const b = makeServerWithHost('host-b');
(
a as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
}
).sendPushNotifications('hook:stop', { sessionId: 's-a', sessionName: 'A' });
(
b as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
}
).sendPushNotifications('hook:stop', { sessionId: 's-b', sessionName: 'B' });
expect(sendNotification).toHaveBeenCalledTimes(2);
const first = JSON.parse(sendNotification.mock.calls[0][1] as string) as PushPayload;
const second = JSON.parse(sendNotification.mock.calls[1][1] as string) as PushPayload;
expect(first.hostTitle).toBe('codeman:host-a');
expect(second.hostTitle).toBe('codeman:host-b');
});
});
// ─── SW display-title formatting ─────────────────────────────────────────
// The SW logic at sw.js:130 composes the OS notification title from the
// payload. It's a 3-line conditional we mirror here so any future change
// (e.g. swapping the separator) shows up in this test instead of being
// caught only by users running multiple Codeman instances.
function computeSwDisplayTitle(payload: { title?: string; hostTitle?: string }): string {
const { title, hostTitle } = payload;
return hostTitle && title ? `${hostTitle}: ${title}` : title || hostTitle || 'Codeman';
}
describe('service worker displayTitle composition (mirrors sw.js)', () => {
it('joins host and title with ": " when both present', () => {
expect(computeSwDisplayTitle({ hostTitle: 'codeman:laptop', title: 'Permission Required' })).toBe(
'codeman:laptop: Permission Required'
);
});
it('falls back to bare title when hostTitle is missing (older server)', () => {
expect(computeSwDisplayTitle({ title: 'Permission Required' })).toBe('Permission Required');
});
it('falls back to hostTitle alone when title is missing', () => {
expect(computeSwDisplayTitle({ hostTitle: 'codeman:laptop' })).toBe('codeman:laptop');
});
it('defaults to "Codeman" when both missing', () => {
expect(computeSwDisplayTitle({})).toBe('Codeman');
});
});
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -7,6 +7,7 @@
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
export interface RouteTestHarness {
app: FastifyInstance;
@@ -24,7 +25,7 @@ export interface RouteTestHarness {
export async function createRouteTestHarness(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string },
ctxOptions?: { sessionId?: string }
): Promise<RouteTestHarness> {
const app = Fastify({ logger: false });
@@ -34,6 +35,9 @@ export async function createRouteTestHarness(
const ctx = createMockRouteContext(ctxOptions);
registerFn(app, ctx);
// Mirror production: structured errors thrown by route helpers (findSessionOrFail,
// parseBody) are rendered to {success:false} bodies at the right status.
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
+11 -12
View File
@@ -104,9 +104,7 @@ describe('case-routes', () => {
});
it('includes hasClaudeMd flag', async () => {
mockedReaddir.mockResolvedValue([
{ name: 'case-with-md', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'case-with-md', isDirectory: () => true }] as never);
mockedExistsSync.mockReturnValue(true);
const res = await harness.app.inject({
@@ -120,9 +118,7 @@ describe('case-routes', () => {
it('includes linked cases from linked-cases.json', async () => {
// CASES_DIR readdir returns one case
mockedReaddir.mockResolvedValue([
{ name: 'regular-case', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'regular-case', isDirectory: () => true }] as never);
// linked-cases.json is read second (after CASES_DIR readdir)
let readCallCount = 0;
mockedReadFile.mockImplementation(async () => {
@@ -158,7 +154,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: 'invalid case name!!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -169,7 +165,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,7 +176,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: '../etc' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -230,7 +226,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: { name: 'bad name!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -241,7 +237,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -294,7 +290,10 @@ describe('case-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.case.name).toBe('linked-project');
expect(harness.ctx.broadcast).toHaveBeenCalledWith('case:linked', expect.objectContaining({ name: 'linked-project' }));
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'case:linked',
expect.objectContaining({ name: 'linked-project' })
);
});
});
+2 -1
View File
@@ -312,7 +312,8 @@ describe('file-routes', () => {
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=../../etc/shadow`,
});
expect(res.statusCode).toBe(400);
// Path traversal returns 404 ("File not found") to avoid revealing the target exists.
expect(res.statusCode).toBe(404);
});
it('rejects overly large raw files', async () => {
+6 -9
View File
@@ -38,7 +38,7 @@ describe('hook-event-routes', () => {
expect(body.success).toBe(true);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'hook:stop',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -55,7 +55,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith(
'hook:idle_prompt',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -85,7 +85,7 @@ describe('hook-event-routes', () => {
data: null,
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -98,7 +98,7 @@ describe('hook-event-routes', () => {
event: 'stop',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,10 +180,7 @@ describe('hook-event-routes', () => {
},
});
expect(res.statusCode).toBe(200);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith(
'stop',
expect.any(Object),
);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
it('starts transcript watcher when transcript_path is provided', async () => {
@@ -199,7 +196,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith(
harness.ctx._sessionId,
'/home/user/.claude/transcript.jsonl',
'/home/user/.claude/transcript.jsonl'
);
});
+3 -3
View File
@@ -103,7 +103,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: { goal: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -114,7 +114,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -228,7 +228,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/reject',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+8 -10
View File
@@ -80,7 +80,7 @@ describe('plan-routes', () => {
url: '/api/cancel-plan-generation',
payload: { orchestratorId: 12345 }, // should be string
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -129,7 +129,7 @@ describe('plan-routes', () => {
expect(body.data.status).toBe('completed');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskUpdate',
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' })
);
});
@@ -159,7 +159,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`,
payload: { status: 'invalid_status' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -208,7 +208,7 @@ describe('plan-routes', () => {
expect(body.data.completedCount).toBe(5);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planCheckpoint',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
});
@@ -279,9 +279,7 @@ describe('plan-routes', () => {
});
it('rolls back to a previous version', async () => {
const mockPlan = [
{ id: 'task-1', content: 'Step 1', status: 'pending' },
];
const mockPlan = [{ id: 'task-1', content: 'Step 1', status: 'pending' }];
harness.ctx._session.ralphTracker = {
rollbackToVersion: vi.fn(() => ({ success: true, plan: mockPlan })),
} as never;
@@ -296,7 +294,7 @@ describe('plan-routes', () => {
expect(body.data).toHaveLength(1);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planRollback',
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 })
);
});
@@ -358,7 +356,7 @@ describe('plan-routes', () => {
expect(body.data.content).toBe('New task');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskAdded',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -372,7 +370,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
payload: { priority: 'P1' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+5 -5
View File
@@ -65,7 +65,7 @@ describe('push-routes', () => {
expect.objectContaining({
endpoint: 'https://push.example.com/send/abc123',
keys: { p256dh: 'test-p256dh-key', auth: 'test-auth-key' },
}),
})
);
});
@@ -87,7 +87,7 @@ describe('push-routes', () => {
expect.objectContaining({
userAgent: 'TestBrowser/1.0',
pushPreferences: { 'session:idle': true, 'session:error': false },
}),
})
);
});
@@ -99,7 +99,7 @@ describe('push-routes', () => {
keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -112,7 +112,7 @@ describe('push-routes', () => {
endpoint: 'https://push.example.com/send/abc123',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -160,7 +160,7 @@ describe('push-routes', () => {
url: '/api/push/subscribe/sub-123',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+35 -21
View File
@@ -66,7 +66,9 @@ describe('ralph-routes', () => {
});
it('enables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -79,7 +81,9 @@ describe('ralph-routes', () => {
});
it('disables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -99,7 +103,7 @@ describe('ralph-routes', () => {
});
expect((harness.ctx.mux as Record<string, unknown>).updateRalphEnabled).toHaveBeenCalledWith(
harness.ctx._sessionId,
true,
true
);
});
@@ -116,7 +120,9 @@ describe('ralph-routes', () => {
});
it('handles reset option', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -127,7 +133,9 @@ describe('ralph-routes', () => {
});
it('configures completion phrase and max iterations', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -138,7 +146,9 @@ describe('ralph-routes', () => {
});
it('sets max iterations independently', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -154,7 +164,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-config',
payload: { enabled: true },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -178,13 +188,15 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
payload: { enabled: 'not-boolean' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('handles disableAutoEnable flag', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -208,7 +220,9 @@ describe('ralph-routes', () => {
describe('POST /api/sessions/:id/ralph-circuit-breaker/reset', () => {
it('resets circuit breaker for valid session', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -225,7 +239,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/ralph-circuit-breaker/reset',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -253,7 +267,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/ralph-status',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -279,7 +293,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/fix-plan',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -315,7 +329,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/fix-plan/import',
payload: { content: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -326,7 +340,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -341,7 +355,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-prompt/write',
payload: { content: 'test prompt' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -365,7 +379,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -379,7 +393,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/write',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -405,7 +419,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/read',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -432,7 +446,7 @@ describe('ralph-routes', () => {
url: '/api/ralph-loop/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -466,7 +480,7 @@ describe('ralph-routes', () => {
caseName: '../escape-path',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+2 -2
View File
@@ -172,7 +172,7 @@ describe('respawn-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
payload: { idleTimeoutMs: 'not-a-number' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -196,7 +196,7 @@ describe('respawn-routes', () => {
expect(mockController.updateConfig).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'respawn:configUpdated',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
+4 -8
View File
@@ -114,7 +114,7 @@ describe('scheduled-routes', () => {
prompt: '',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -125,7 +125,7 @@ describe('scheduled-routes', () => {
url: '/api/scheduled',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -139,7 +139,7 @@ describe('scheduled-routes', () => {
workingDir: '/tmp/test;rm -rf /',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -171,11 +171,7 @@ describe('scheduled-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
// Should default to 60 minutes
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith(
'test',
expect.any(String),
60,
);
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
});
});
+59 -13
View File
@@ -7,6 +7,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, execFile };
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
describe('session-routes', () => {
@@ -20,6 +28,44 @@ describe('session-routes', () => {
await harness.app.close();
});
// ========== POST /api/sessions/:id/send-key ==========
describe('POST /api/sessions/:id/send-key', () => {
it('routes tmux send-keys through the dedicated Codeman socket (-L)', async () => {
// Regression guard: bare `tmux` would hit the user's default server and never
// find a session that lives only on the Codeman socket (#80 regression class).
execFile.mockReset();
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
cb(null)
);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'S-Enter' },
});
expect(res.statusCode).toBe(200);
expect(execFile).toHaveBeenCalledTimes(1);
const [bin, argv] = execFile.mock.calls[0];
expect(bin).toBe('tmux');
expect((argv as string[]).slice(0, 2)).toEqual(['-L', 'codeman']);
expect(argv).toContain('send-keys');
expect(argv).toContain('-H');
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'rm -rf' },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(execFile).not.toHaveBeenCalled();
});
});
// ========== GET /api/sessions ==========
describe('GET /api/sessions', () => {
@@ -57,7 +103,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent',
});
expect(res.statusCode).toBe(200); // returns error in body, not HTTP 404
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toBeDefined();
@@ -128,7 +174,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/name',
payload: { name: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -181,7 +227,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/input',
payload: { input: 'hello' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -192,7 +238,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/input`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -219,7 +265,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 501, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -230,7 +276,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 80, rows: 201 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -241,7 +287,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 0, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -266,7 +312,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/terminal',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -292,7 +338,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/run`,
payload: { prompt: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -303,7 +349,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/run',
payload: { prompt: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -340,7 +386,7 @@ describe('session-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/interactive',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -404,7 +450,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/output',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -540,7 +586,7 @@ describe('session-routes', () => {
resumeSessionId: 'not-a-uuid',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+4 -4
View File
@@ -166,7 +166,7 @@ describe('system-routes', () => {
url: '/api/config',
payload: { unknownField: 'invalid' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -361,7 +361,7 @@ describe('system-routes', () => {
url: '/api/settings',
payload: { unknownField: 'bad' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -459,7 +459,7 @@ describe('system-routes', () => {
url: '/api/subagent-window-states',
payload: { minimized: { 'agent-1': 'not-a-boolean' } },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -516,7 +516,7 @@ describe('system-routes', () => {
url: '/api/subagent-parents',
payload: { 'agent-1': 123 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Verifies that WebServer templates the `<title>` tag in the served
* index.html with the hostname-aware `codeman:<host>` window title
* (feature #82). The title must:
* - default to `codeman:<os.hostname()>` when no override is supplied
* - honor a custom `titleHostname` passed via the constructor (CLI flag
* `--title-hostname <host>` plumbs through to here)
* - HTML-escape the hostname so a value like `<script>foo</script>`
* can't break out of the title tag
* - replace the bare `<title>Codeman</title>` literal exactly once
* - leave the rest of the document byte-for-byte identical to the
* template on disk
*
* Strategy: construct WebServer with port 0 / testMode (no network
* activity until start()) and call the private `renderIndexHtml()`
* method directly. The Fastify `/` and `/index.html` route handlers
* are one-liners that call exactly this method (server.ts:539-544),
* so testing the render function covers both endpoints without
* needing to listen on a port.
*
* Port: N/A (no server start)
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { hostname as osHostname } from 'node:os';
import { WebServer } from '../src/web/server.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const indexHtmlPath = join(__dirname, '..', 'src', 'web', 'public', 'index.html');
const rawTemplate = readFileSync(indexHtmlPath, 'utf-8');
function render(host?: string): string {
const server = new WebServer(0, false, true, host);
return (server as unknown as { renderIndexHtml: () => string }).renderIndexHtml();
}
describe('WebServer index.html <title> templating (#82)', () => {
it('substitutes the bare <title>Codeman</title> with codeman:<host>', () => {
const html = render('laptop');
expect(html).toContain('<title>codeman:laptop</title>');
expect(html).not.toContain('<title>Codeman</title>');
});
it('defaults to os.hostname() when no titleHostname is supplied', () => {
const html = render();
const expected = `<title>codeman:${osHostname()}</title>`;
expect(html).toContain(expected);
});
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', () => {
// CLI normally guarantees a non-empty string, but the constructor's
// `titleHostname || getHostname()` guard makes empty fall through —
// pin that behavior so a future refactor doesn't accidentally ship
// a `<title>codeman:</title>` to users.
const html = render('');
expect(html).toMatch(/<title>codeman:.+<\/title>/);
expect(html).not.toContain('<title>codeman:</title>');
});
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', () => {
const html = render('<script>alert(1)</script>');
expect(html).toContain('<title>codeman:&lt;script&gt;alert(1)&lt;/script&gt;</title>');
// The raw closing </title> from the injected payload must NOT appear
// outside the actual title element — escape-then-substitute prevents
// an attacker-controlled hostname from terminating the tag early.
expect(html).not.toContain('<script>alert(1)</script></title>');
});
it('escapes an ampersand without double-encoding existing entities', () => {
// The escaper replaces & first, then < and >. A hostname that already
// contains a literal `&` should render as `&amp;` once, not `&amp;amp;`.
const html = render('a&b');
expect(html).toContain('<title>codeman:a&amp;b</title>');
expect(html).not.toContain('&amp;amp;');
});
it('only substitutes the <title> tag — the rest of the template is byte-for-byte identical', () => {
const html = render('laptop');
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
expect(html.startsWith(beforeTitle)).toBe(true);
expect(html.endsWith(afterTitle)).toBe(true);
// Sanity check: length differs only by the title swap.
const expectedDelta = `<title>codeman:laptop</title>`.length - `<title>Codeman</title>`.length;
expect(html.length - rawTemplate.length).toBe(expectedDelta);
});
it('replaces the <title> placeholder exactly once', () => {
const html = render('laptop');
// Defense against a future regression where the template gains a
// second `<title>Codeman</title>` (e.g. inside a <noscript>) and only
// the first gets templated — would leave a stale literal in the served
// HTML that overrides the correct one in some renderers.
const occurrencesOfNew = html.split('<title>codeman:laptop</title>').length - 1;
const occurrencesOfOld = html.split('<title>Codeman</title>').length - 1;
expect(occurrencesOfNew).toBe(1);
expect(occurrencesOfOld).toBe(0);
});
it('two WebServer instances on different hostnames render distinct titles', () => {
const htmlA = render('host-a');
const htmlB = render('host-b');
expect(htmlA).toContain('<title>codeman:host-a</title>');
expect(htmlB).toContain('<title>codeman:host-b</title>');
expect(htmlA).not.toContain('host-b');
expect(htmlB).not.toContain('host-a');
});
});
+225
View File
@@ -0,0 +1,225 @@
/**
* Regression tests for stripInkRedrawBloat() in session-routes.ts.
*
* Background: this helper trims the dense VPA (CSI n d) escape clusters that
* Ink emits while animating the spinner / status bar so terminal-tail responses
* stay manageable. The previous implementation collapsed *everything* after
* the first VPA, which silently discarded 100KB+ of legitimate streamed
* response text. The clustering rewrite shipped silently inside the v0.6.7
* "chore: version packages" commit (dcc814f) — these tests lock the algorithm
* down so neither the silent data-loss bug nor the threshold constants
* (FRAME_GAP=8KB, MIN_BLOAT_SIZE=32KB) regress.
*
* Pure (string)=>string helper, no I/O — synchronous tests, no port needed.
*/
import { describe, it, expect } from 'vitest';
import { stripInkRedrawBloat } from '../src/web/routes/session-routes.js';
const VPA = '\x1b[10d'; // VPA escape: move cursor to row 10. 5 bytes.
const VPA_LEN = VPA.length;
// Single counting regex shared across tests so the no-control-regex
// disable lives in one place.
// eslint-disable-next-line no-control-regex
const VPA_RE = /\x1b\[\d+d/g;
function countVpa(s: string): number {
return (s.match(VPA_RE) || []).length;
}
/** Build a buffer of `count` VPAs with `gap` bytes of filler between them. */
function vpaCluster(count: number, gap: number, fillerChar = ' '): string {
const filler = fillerChar.repeat(gap);
const parts: string[] = [];
for (let i = 0; i < count; i++) {
if (i > 0) parts.push(filler);
parts.push(VPA);
}
return parts.join('');
}
/** Span (bytes from first VPA's start to last VPA's start) of a cluster
* built by vpaCluster(count, gap). */
function clusterSpan(count: number, gap: number): number {
return (count - 1) * (gap + VPA_LEN);
}
describe('stripInkRedrawBloat', () => {
// ─── Early-out paths ─────────────────────────────────────────────────────
it('returns empty buffer unchanged', () => {
expect(stripInkRedrawBloat('')).toBe('');
});
it('returns buffer with no VPA escapes unchanged', () => {
const buf = 'Hello, this is a normal Claude response.\n'.repeat(100);
expect(stripInkRedrawBloat(buf)).toBe(buf);
});
it('returns buffer with fewer than 10 VPAs unchanged (even if total is large)', () => {
// 9 VPAs is below the early-out threshold; helper returns input verbatim
// regardless of how much filler sits between them.
const buf = vpaCluster(9, 50_000); // ~450KB of filler
expect(stripInkRedrawBloat(buf)).toBe(buf);
});
// ─── Small cluster preserved ─────────────────────────────────────────────
it('preserves a cluster smaller than MIN_BLOAT_SIZE (32KB span)', () => {
// 50 VPAs, 100B apart -> span ~5.2KB, well under 32KB
const cluster = vpaCluster(50, 100);
expect(clusterSpan(50, 100)).toBeLessThan(32 * 1024);
const buf = 'PREFIX\n' + cluster + '\nSUFFIX';
expect(stripInkRedrawBloat(buf)).toBe(buf);
});
// ─── Big cluster collapsed ───────────────────────────────────────────────
it('collapses a single big cluster but preserves bytes before and after', () => {
// 50 VPAs, 700B apart -> span 49*(700+5) = 34_545B, comfortably over 32KB
const cluster = vpaCluster(50, 700);
expect(clusterSpan(50, 700)).toBeGreaterThanOrEqual(32 * 1024);
const prefix = 'BEFORE_THE_BLOAT\n';
const suffix = '\nAFTER_THE_BLOAT_THIS_IS_THE_RESPONSE_TEXT_THAT_USED_TO_BE_LOST';
const buf = prefix + cluster + suffix;
const out = stripInkRedrawBloat(buf);
// Both ends survive — that was the silent bug
expect(out.startsWith(prefix)).toBe(true);
expect(out.endsWith(suffix)).toBe(true);
// Output is much shorter than the input
expect(out.length).toBeLessThan(buf.length);
// Exactly one VPA remains (the last frame's), not all 50
expect(countVpa(out)).toBe(1);
});
// ─── THE REGRESSION: response text BETWEEN big clusters survives ─────────
it('preserves response text between two big clusters (the silent-data-loss bug)', () => {
// Make each cluster large enough that an old "keep just the tail" approach
// (the audit described it as "keep only the last 64KB after the first VPA")
// would push any in-between response text out of the kept window.
// 200 VPAs at 700B gap -> ~140KB span per cluster, total buffer >300KB.
const clusterA = vpaCluster(200, 700);
const clusterB = vpaCluster(200, 700);
expect(clusterSpan(200, 700)).toBeGreaterThan(64 * 1024);
const responseText =
'\n\n## Here is my detailed answer\n\n' +
'This is the kind of streamed response text that the old first-VPA\n' +
'algorithm silently dropped. Multiple paragraphs of it. Indented blocks,\n' +
'code samples, the entire conversation. Losing this was a silent bug\n' +
'that the changelog mentioned but no test had ever locked down.\n\n' +
'```ts\n' +
'function example() { return 42; }\n' +
'```\n\n' +
'And a closing paragraph.';
// Gap between clusters must exceed FRAME_GAP (8KB) so they're treated
// as separate clusters, not merged into one giant one.
const padded = responseText + '\n' + ' '.repeat(8 * 1024 + 100);
const buf = 'INTRO\n' + clusterA + padded + clusterB + '\nOUTRO';
expect(buf.length).toBeGreaterThan(280 * 1024); // sanity: well past any 64KB window
const out = stripInkRedrawBloat(buf);
// Every paragraph of response text survives intact. This is the assertion
// that would have caught the silent-data-loss bug — under a "keep the last
// 64KB after the first VPA" approach, all of responseText falls outside
// the kept window and is lost.
expect(out).toContain('Here is my detailed answer');
expect(out).toContain('the old first-VPA');
expect(out).toContain('function example() { return 42; }');
expect(out).toContain('And a closing paragraph.');
// Bookends survive too.
expect(out.startsWith('INTRO\n')).toBe(true);
expect(out.endsWith('\nOUTRO')).toBe(true);
// Each big cluster collapses to a single VPA (its last frame).
expect(countVpa(out)).toBe(2);
});
// ─── Mixed: small clusters preserved alongside big ones ──────────────────
it('preserves small clusters when a big cluster is also present', () => {
const small = vpaCluster(50, 100); // ~5.2KB span, kept as-is
const big = vpaCluster(50, 700); // ~34KB span, collapsed
// Separate them with > FRAME_GAP filler so they stay distinct clusters.
const gap = ' '.repeat(8 * 1024 + 100);
const buf = small + gap + 'MID_CONTENT' + gap + big + 'TAIL';
const out = stripInkRedrawBloat(buf);
// Small cluster survives intact: all 50 VPAs still there.
// Big cluster collapsed to 1 VPA. Total = 50 + 1 = 51.
expect(countVpa(out)).toBe(51);
expect(out).toContain('MID_CONTENT');
expect(out.endsWith('TAIL')).toBe(true);
});
// ─── FRAME_GAP boundary: two clusters separated by exactly > 8KB ─────────
it('treats VPAs separated by > FRAME_GAP (8KB) as different clusters', () => {
// Two big clusters with a 9KB gap between them must NOT be merged.
const clusterA = vpaCluster(50, 700);
const clusterB = vpaCluster(50, 700);
const gap = ' '.repeat(9 * 1024); // > 8KB FRAME_GAP
const buf = clusterA + gap + clusterB;
const out = stripInkRedrawBloat(buf);
// Two separate big clusters -> 2 VPAs survive.
expect(countVpa(out)).toBe(2);
// Gap content survives.
expect(out).toContain(gap);
});
it('treats VPAs separated by <= FRAME_GAP (8KB) as the same cluster', () => {
// Two would-be-separate clusters with a 1KB gap merge into one big cluster.
const left = vpaCluster(30, 700);
const right = vpaCluster(30, 700);
const gap = ' '.repeat(1024); // well under 8KB FRAME_GAP
const buf = left + gap + right;
const out = stripInkRedrawBloat(buf);
// Merged into one big cluster -> 1 VPA survives, gap content is gone too.
expect(countVpa(out)).toBe(1);
});
// ─── Big cluster at end of buffer ────────────────────────────────────────
it('preserves the last frame when a big cluster is at the end of the buffer', () => {
const cluster = vpaCluster(50, 700);
const buf = 'HEAD\n' + cluster;
const out = stripInkRedrawBloat(buf);
expect(out.startsWith('HEAD\n')).toBe(true);
// Exactly one VPA (the last frame's) at the tail.
expect(countVpa(out)).toBe(1);
expect(out.endsWith(VPA)).toBe(true);
});
// ─── Idempotency ─────────────────────────────────────────────────────────
it('is idempotent: stripping an already-stripped buffer is a no-op', () => {
// After one pass a big cluster shrinks to ~1 VPA, putting the buffer
// below the early-out threshold (positions.length < 10), so a second
// pass returns it untouched.
const buf = 'A' + vpaCluster(50, 700) + 'B';
const once = stripInkRedrawBloat(buf);
const twice = stripInkRedrawBloat(once);
expect(twice).toBe(once);
});
// ─── Realistic size guardrail ────────────────────────────────────────────
it('shrinks a 200KB Ink-bloat buffer down by an order of magnitude', () => {
// Roughly the shape of a real "Claude is thinking" terminal buffer.
const cluster = vpaCluster(300, 700); // ~210KB span
const buf = 'Question: Tell me about TypeScript.\n' + cluster + '\nAnswer: TypeScript is...';
const out = stripInkRedrawBloat(buf);
expect(buf.length).toBeGreaterThan(200 * 1024);
expect(out.length).toBeLessThan(buf.length / 10);
expect(out).toContain('Question: Tell me about TypeScript.');
expect(out).toContain('Answer: TypeScript is...');
});
});
+17 -2
View File
@@ -77,9 +77,24 @@ describe('TmuxManager (unit)', () => {
});
describe('getAttachArgs', () => {
it('should return attach-session args', () => {
it('should attach every session through the dedicated Codeman socket', () => {
const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['attach-session', '-t', 'codeman-abc12345']);
expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
});
it('should attach registered sessions on the same dedicated socket (no per-session socket)', () => {
manager.registerSession({
sessionId: 'some-session',
muxName: 'codeman-abc12345',
pid: 12345,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
});
});
+166
View File
@@ -0,0 +1,166 @@
/**
* Covers `queryTmuxWindowSize()`, the helper extracted from `_attachToMux`
* in PR #80 ("prevent tmux flicker on restart by matching existing window size").
*
* Before #80, the PTY was hardcoded to 120x40 on every attach. If a previous
* client had resized the tmux window to e.g. 200x50, the re-attach would
* shrink the window back to 120x40, then xterm.js would resize it again on
* the next frame — visible flicker and one lost repaint of scrollback.
*
* The fix queries tmux for the actual window geometry first via
* `tmux -L <socket> display -t <name> -p '#{window_width} #{window_height}'`
* (the `-L <socket>` targets the isolated Codeman socket). We cover:
* - Happy path: tmux reports valid geometry → those numbers are used.
* - Browser-resize-between-attaches: tmux reports a non-default size
* (because a prior client resized it) → the helper picks that up.
* - Query-then-die race: tmux dies between query and attach → the query
* either throws or returns garbage; either way the helper falls back to
* 120x40 so the attach can still proceed (the pty.spawn that follows has
* its own try/catch for the actual failed-attach case).
* - Defensive paths: empty output, non-numeric output, zero/negative
* dimensions, trailing whitespace.
* - Security: muxName is passed as an argv element (no shell), so a
* malicious mux name can't inject options.
*
* Strategy: mock `node:child_process.execFileSync` and assert both the call
* shape (argv, timeout) and the parsed return.
*
* Port: N/A (no server / no real tmux)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { execFileSync } = vi.hoisted(() => ({
execFileSync: vi.fn(),
}));
vi.mock('node:child_process', async () => {
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
return { ...actual, execFileSync };
});
import { queryTmuxWindowSize } from '../src/session.js';
const DEFAULT = { cols: 120, rows: 40 };
beforeEach(() => {
execFileSync.mockReset();
});
describe('queryTmuxWindowSize — happy path', () => {
it('returns the geometry tmux reports', () => {
execFileSync.mockReturnValue('200 50\n');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 200, rows: 50 });
});
it('picks up a non-default size left behind by a prior client (browser-resize-between-attaches)', () => {
// Scenario: client A attached at 220x60, resized tmux to that, then disconnected.
// tmux keeps the last-attached geometry. Client B re-attaches and should spawn
// its PTY at 220x60, not 120x40 — that's the whole point of #80.
execFileSync.mockReturnValue('220 60');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 220, rows: 60 });
});
it('tolerates trailing whitespace and newlines in tmux output', () => {
execFileSync.mockReturnValue(' 180 45 \n\n');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 180, rows: 45 });
});
});
describe('queryTmuxWindowSize — fallback paths', () => {
it('falls back to 120x40 when tmux exits non-zero (process not found)', () => {
// execFileSync throws when the child exits non-zero. Simulates `tmux` binary
// missing or `display -t` failing because the target session doesn't exist.
execFileSync.mockImplementation(() => {
const err = new Error('Command failed: tmux display -t bogus') as Error & { status: number };
err.status = 1;
throw err;
});
expect(queryTmuxWindowSize('bogus', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => {
// Query-then-die race: simulates the tmux server being killed mid-call.
execFileSync.mockImplementation(() => {
const err = new Error('spawn ETIMEDOUT') as NodeJS.ErrnoException;
err.code = 'ETIMEDOUT';
throw err;
});
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns empty output', () => {
execFileSync.mockReturnValue('');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns whitespace-only output', () => {
execFileSync.mockReturnValue(' \n');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns non-numeric output', () => {
execFileSync.mockReturnValue('not a size\n');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when only one dimension is present', () => {
execFileSync.mockReturnValue('200\n');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when a dimension is zero (degenerate geometry)', () => {
// tmux reporting `0` would crash node-pty downstream — must not propagate.
execFileSync.mockReturnValue('0 40\n');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
execFileSync.mockReturnValue('120 0\n');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when a dimension is negative', () => {
execFileSync.mockReturnValue('-200 -50\n');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
it('falls back when tmux returns NaN-producing tokens', () => {
execFileSync.mockReturnValue('abc def\n');
expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
});
});
describe('queryTmuxWindowSize — call shape', () => {
it('invokes tmux on the dedicated socket via argv (not a shell)', () => {
execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc', 'codeman');
expect(execFileSync).toHaveBeenCalledTimes(1);
const [bin, argv, opts] = execFileSync.mock.calls[0];
expect(bin).toBe('tmux');
// `-L <socket>` MUST lead: querying the default server would never find a
// session that lives on the isolated Codeman socket (the #80 regression).
expect(argv).toEqual(['-L', 'codeman', 'display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']);
// execFileSync — not execSync — so muxName is never substituted into a shell string.
expect(opts).toMatchObject({ encoding: 'utf8' });
});
it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => {
execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc', 'codeman');
const [, , opts] = execFileSync.mock.calls[0];
// Whatever the exact constant, the contract is: ≤5s so the user-visible
// attach path can't hang on a stuck tmux server.
expect(typeof opts?.timeout).toBe('number');
expect(opts?.timeout).toBeGreaterThan(0);
expect(opts?.timeout).toBeLessThanOrEqual(5000);
});
it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => {
execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('-x 1 -y 1; rm -rf', 'codeman');
const [, argv] = execFileSync.mock.calls[0];
// The whole "name" lives in a single argv slot (index 4, after `-L codeman
// display -t`), so tmux interprets it as a target session name, not as
// additional flags. The `-t` flag preceding it pins it as the target.
expect(argv?.[4]).toBe('-x 1 -y 1; rm -rf');
expect((argv as string[]).indexOf('-x')).toBe(-1);
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* WebGL longtask auto-fallback tests.
*
* Covers the three follow-ups from #89:
* 1. Pure trip-detection helper — rolling-window arithmetic for the
* "N longtasks of >=Xms within Yms" trip condition. Unit-tested
* independently of PerformanceObserver, which can't be driven
* deterministically from JS (entries arrive from the platform).
* 2. Constants are hoisted from inline literals to `WEBGL_FALLBACK`
* in constants.js — assert they exist with the documented values.
* 3. Observer disconnect — _disposeWebGLObserver() is idempotent and
* can be called from the onContextLoss path without the addon
* having been initialised. Mirrors the leak case in the issue:
* "observer outlives its addon" when teardown precedes a trip.
*
* Strategy: load the static app shell in a headless browser and drive
* the helper through page.evaluate(). No real PTY/tmux/WebGL needed —
* the trip math is pure and the dispose path is a couple of property
* mutations, both of which run on any page where app.js loaded.
*
* Port: 3166 (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3166;
const BASE_URL = `http://localhost:${PORT}`;
describe('WebGL longtask auto-fallback', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
// Wait for constants.js + app.js to have loaded — both expose globals.
await page.waitForFunction(
() =>
typeof (window as { WEBGL_FALLBACK?: unknown }).WEBGL_FALLBACK !== 'undefined' &&
typeof (window as { evaluateWebGLLongTaskTrip?: unknown }).evaluateWebGLLongTaskTrip === 'function' &&
typeof (window as { app?: unknown }).app !== 'undefined'
);
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
describe('constants are hoisted', () => {
it('WEBGL_FALLBACK exposes documented thresholds', async () => {
const cfg = await page.evaluate(
() => (window as unknown as { WEBGL_FALLBACK: Record<string, number> }).WEBGL_FALLBACK
);
expect(cfg).toEqual({
LONGTASK_MS: 200,
LONGTASK_COUNT: 3,
WINDOW_MS: 30000,
GRACE_MS: 5000,
STICKY_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000,
});
});
});
describe('evaluateWebGLLongTaskTrip — rolling window arithmetic', () => {
type EvalFn = (
recent: number[],
entries: { startTime: number; duration: number }[],
now: number
) => { tripped: boolean; recent: number[] };
/** Run the pure helper in the page and return the post-call state. */
const run: EvalFn = async (recent, entries, now) =>
page.evaluate(
({ r, e, n }) => {
const fn = (
window as unknown as {
evaluateWebGLLongTaskTrip: (
rec: number[],
ents: { startTime: number; duration: number }[],
now: number
) => boolean;
}
).evaluateWebGLLongTaskTrip;
const recent = [...r];
const tripped = fn(recent, e, n);
return { tripped, recent };
},
{ r: recent, e: entries, n: now }
) as unknown as { tripped: boolean; recent: number[] };
it('trips when 3 longtasks fall inside the 30s window', async () => {
const entries = [
{ startTime: 1000, duration: 250 },
{ startTime: 5000, duration: 300 },
{ startTime: 10000, duration: 220 },
];
const result = await run([], entries, 12000);
expect(result.tripped).toBe(true);
expect(result.recent).toEqual([1000, 5000, 10000]);
});
it('does not trip when 3 longtasks are spread across 60s', async () => {
// 3 longtasks 25s apart — only the most recent two stay inside 30s.
const entries = [
{ startTime: 1000, duration: 250 },
{ startTime: 26000, duration: 250 },
{ startTime: 51000, duration: 250 },
];
const result = await run([], entries, 51100);
expect(result.tripped).toBe(false);
// First entry pruned (1000 is >30s before now=51100); 26000 and 51000 stay.
expect(result.recent).toEqual([26000, 51000]);
});
it('ignores entries shorter than 200ms', async () => {
const entries = [
{ startTime: 1000, duration: 199 },
{ startTime: 2000, duration: 100 },
{ startTime: 3000, duration: 50 },
];
const result = await run([], entries, 3500);
expect(result.tripped).toBe(false);
expect(result.recent).toEqual([]);
});
it('prunes stale entries even when no new ones arrive', async () => {
// Existing window has 2 stale + 1 fresh; an empty batch should still
// age out the stale ones so the next real batch evaluates correctly.
const recent = [1000, 5000, 40000];
const result = await run(recent, [], 41000);
expect(result.tripped).toBe(false);
expect(result.recent).toEqual([40000]);
});
it('counts entries cumulatively across batches', async () => {
// Two batches of 2 entries each, all inside the window — second
// batch should push the cumulative count to 4 and trip.
const recent: number[] = [];
const first = await run(
recent,
[
{ startTime: 1000, duration: 250 },
{ startTime: 2000, duration: 250 },
],
3000
);
expect(first.tripped).toBe(false);
expect(first.recent).toEqual([1000, 2000]);
const second = await run(
first.recent,
[
{ startTime: 4000, duration: 250 },
{ startTime: 5000, duration: 250 },
],
6000
);
expect(second.tripped).toBe(true);
expect(second.recent).toEqual([1000, 2000, 4000, 5000]);
});
});
describe('_disposeWebGLObserver', () => {
it('is idempotent — safe to call when no observer was installed', async () => {
const ok = await page.evaluate(() => {
const app = (
window as unknown as { app: { _disposeWebGLObserver: () => void; _webglLongTaskObserver: unknown } }
).app;
app._webglLongTaskObserver = null;
app._disposeWebGLObserver();
app._disposeWebGLObserver();
return app._webglLongTaskObserver === null;
});
expect(ok).toBe(true);
});
it('disconnects a stub observer and nulls the reference', async () => {
const result = await page.evaluate(() => {
const app = (
window as unknown as { app: { _disposeWebGLObserver: () => void; _webglLongTaskObserver: unknown } }
).app;
let disconnectCalls = 0;
app._webglLongTaskObserver = {
disconnect() {
disconnectCalls++;
},
} as unknown;
app._disposeWebGLObserver();
return { disconnectCalls, ref: app._webglLongTaskObserver };
});
expect(result.disconnectCalls).toBe(1);
expect(result.ref).toBeNull();
});
it('swallows a throwing disconnect — guards against driver quirks', async () => {
const result = await page.evaluate(() => {
const app = (
window as unknown as { app: { _disposeWebGLObserver: () => void; _webglLongTaskObserver: unknown } }
).app;
app._webglLongTaskObserver = {
disconnect() {
throw new Error('synthetic');
},
} as unknown;
app._disposeWebGLObserver();
return app._webglLongTaskObserver;
});
expect(result).toBeNull();
});
});
});