GET /api/sessions/:id/files gains an optional `q`. With one, the endpoint
answers a FLAT match list instead of a nested tree; without one, the response is
exactly what it was, so every existing caller is untouched.
compileFileQuery() (src/utils/file-query.ts) turns the query string into a
reusable predicate, so the walk prunes as it goes rather than streaming the
whole tree to the client to be filtered there. An empty or whitespace-only
query compiles to null, which is what makes "no query" and "blank query" the
same thing.
The search walk deliberately recurses past directories that do not match — a
file whose ancestors don't match is exactly what people are searching for — so
it carries its own maxMatches cap on top of the existing maxFiles and maxDepth
ones, and reports `truncated` when it stops early. Hidden-file and
excluded-directory rules are the same ones tree mode already applies.
Tests: file-query.test.ts covers the matcher; routes/file-search-mode.test.ts
drives the endpoint against a real temp tree and pins the two properties worth
having — that the walk reaches a match under non-matching parents, and that an
absent or whitespace query leaves the tree response alone. Gating the recursion
on a match turns those red.
Each cycle step (kickstart, update, /clear, /init) checks for `stopped` before
`await session.writeViaMux(...)`, then emits `stepSent` and calls
`setState('waiting_*')` after it.
stop() is asynchronous with respect to that await. One that lands while the
write is in flight has already passed the guard that ran, so the post-await
setState() puts a stopped controller back into a waiting state — re-arming its
step timers against a session the user asked to stop.
Re-check after the await, before emitting and setting state.
The guard reads the public `state` getter rather than `_state` on purpose:
TypeScript narrows `_state` across the await from the pre-await check and cannot
see that stop() mutated it, so `this._state === 'stopped'` is rejected as a
comparison with no overlap (TS2367) at all four sites.
Adds test/respawn-stop-race.test.ts, which drives the interleaving
deterministically by calling stop() from inside the mocked write rather than
relying on timing. All four steps go red without these guards.
validateSessionFilePath realpath-resolves the candidate path but compared it
against the raw sessionWorkingDir. When the workspace is itself reached through
a symlink the two sides live in different namespaces, so relative() reports a
spurious `../` and every file in that workspace is judged an escape — reads and
writes in the session are refused wholesale.
That is not an exotic setup: os.tmpdir() hands back a symlinked path on macOS
(/tmp -> /private/tmp), and symlinked project directories and bind-mounted case
paths hit it too.
Resolve both sides and compare canonical to canonical. This only makes the
comparison honest — it does not widen it. The candidate keeps its own realpath,
so a symlink pointing out of the workspace and a ../ traversal are still
refused, and a workspace that cannot be resolved now fails closed.
Three stubs in file-routes.test.ts used a blanket
realpathSync.mockReturnValue(escapeTarget), which answers the same path for the
workspace and the candidate; with both sides resolved that makes an escape look
contained. They now use the input-aware mockImplementation idiom the rest of
that file already uses, so the workspace resolves to itself and only the
candidate escapes. Verified they still bite: removing the confinement check
turns all of them red.
Adds test/route-helpers-symlink-confinement.test.ts, which exercises the
function against a real symlinked workspace on disk and pins the negative cases
(../ escape, symlink-out, missing file) alongside the fix.
Building on COD-140's firstPrompt backfill, surface each session's most
recent user prompt too, so a long-running session is identifiable by both
where it started and where it is now.
- session-routes: add extractLastUserPrompt() (mirrors extractFirstUserPrompt
with last-match semantics + same noise/secret/slash-command filters + 120
cap); scanProjectDir computes lastPrompt from the file tail (reads a tail for
large files; small files scan head); thread lastPrompt through HistorySession
and the /api/sessions/unified history rows.
- unified-session-service: add lastPrompt to UnifiedSessionItem + HistoryInput,
set it from history in the merge, and extend the backfill with parallel
by-uuid / newest-by-workingDir indexes (never overwrites); add lastPrompt to
the filterAndPaginate search haystack.
- terminal-ui: render a 'Last prompt' detail row, omitted when absent or equal
to the first prompt (single-prompt sessions show one line).
Tests: unified-session-service.test.ts +5 (uuid-join, workingDir fallback,
newest-wins, no-overwrite, search). Beta-verified: /api/sessions/unified
populated firstPrompt+lastPrompt on all 200 rows (12 distinct); Playwright on
the session-manager modal rendered 12 'Last prompt' rows, 0 console errors.
(cherry picked from commit 115f4d397e91decc1a6381b47a99d74922e9055b)
The unified session list only set firstPrompt from the transcript-history
view, keyed by the Claude transcript file's UUID. A live/persisted row
keyed by its Codeman id only inherited a prompt when that id happened to
equal an on-disk transcript filename; when it didn't (stale/wrong
claudeSessionId, post-/clear new uuid, resumed/attached/worktree session),
the session manager showed "(no prompt captured)" even though a real
transcript for that working dir existed under a different UUID.
Add a pure firstPrompt backfill pass in mergeUnifiedSessions (after the
merge loops, using the already-passed history source): for any row with no
firstPrompt, join by claudeSessionId first, then fall back to the newest
transcript in the same workingDir. Never overwrites a non-empty prompt, so
rows keyed to their own transcript are untouched; rows with genuinely no
transcript still show the placeholder. Pure, unit-tested (+5).
(cherry picked from commit 1f9f53ec64a61c9fa7f77d29efcbdd1d2794ec38)
A killed session was full-deleted from state.json (removeSession),
dropping the COD-139 pinned/pinnedAt fields, so the session vanished
from the session-manager pinned group. cleanupStaleSessions also reaped
any persisted record with no live session on boot, which would have
wiped a preserved pin on the next restart.
Fix (state-store):
- demoteOrRemoveSession(id): on kill, demote a *pinned* record to a
lightweight stopped record (status=stopped, pid=null, pin retained)
instead of deleting; unpinned records are removed as before.
- cleanupStaleSessions skips pinned records so the pin survives restart.
- server _doCleanupSession calls demoteOrRemoveSession on the killMux
path (shutdown path unchanged).
Restoration iterates live mux sessions, not state.json, so a stopped+
pinned record is never auto-revived. Unit-tested on the real StateStore
path (state-store.test.ts +4); session-cleanup/session-pin regress green.
(cherry picked from commit 86f183eacfc3f2f6ac28499fb1ae2d21eef2bbed)
resumeHistorySession ignored the row's name and always synthesized a fresh
w<N>-<dir> name from the working dir, so resuming a custom-named session lost its
name. Thread the name through resumeHistorySession(sessionId, workingDir, name) and
extract the choice into a pure _resolveResumeName helper: prefer a non-empty existing
name, else generate the next free w<N>-<dir>. Forward s.name at all three call sites
(terminal-ui.js history-item + session-manager menu, session-ui.js run-mode history);
sessions without a name fall back to the generated name (unchanged behavior). The
unified session rows already carry name, so session-manager rows resume with it.
TDD: test/resume-name.test.ts drives the real _resolveResumeName via vm-harness.
(cherry picked from commit 56c7906a48d8b453ed55810a02ec70f72d34ed32)
Pin/unpin a session via POST /api/sessions/:id/pin {pinned}; pinned sessions
sort above unpinned in the unified session manager list (COD-121), ordered by
pinnedAt descending. Pin state lives on SessionState, persists to state.json,
and survives reload/reconnect/restart (persisted-input carries pinned; the
merge skips undefined so a recovered live session can't clobber it). New SSE
event session:pinned re-sorts the open list live across clients. Pin/Unpin
affordance in the session-row kebab menu with a 📌 glyph + amber highlight.
(cherry picked from commit 82749747039afcd4a3104f6a97ce7d3c2ddd048d)
Tab reordering (drag-and-drop + Ctrl+Shift+{/}) persisted only to
localStorage (codeman-session-order), so each device kept its own private
order. Add server-side persistence so the order follows the user across
devices, live. Takes the issue's recommended default (a): one global order,
server authoritative, localStorage as offline fallback.
- session-order.ts (new, pure + unit-tested): normalizeSessionOrder (coerce
to string[], drop empty/non-string, dedup) and mergeSessionOrder (the
pushing device's order wins; ids the device hadn't loaded fall to the end
in their existing relative order, never dropped — graceful for
closed/remote/parked sessions absent on that device).
- AppState.sessionOrder?: string[]; StateStore get/setSessionOrder + the field
added to buildPartialJson() (the incremental serializer whitelists fields,
so without this the value never reached disk / survived a restart).
- PUT /api/session-order (session-routes): parse -> merge -> persist ->
broadcast session:orderChanged; getLightState() init snapshot now carries
sessionOrder so a fresh load/reconnect restores it.
- SSE event session:orderChanged registered in sse-events.ts + constants.js.
- app.js: handleInit seeds localStorage from the server snapshot before
syncSessionOrder(); saveSessionOrder() also PUTs to the server (debounced
400ms, covers drag + both keyboard moves); _onSessionOrderChanged adopts a
remote order and re-renders (no-op-guarded to avoid echo flicker).
Verified (orchestrator re-ran all gates): tsc 0, lint 0, frontend-syntax +
prettier clean, build ok; session-order + session-order-routes + state-store
56/56. Functional round-trip on an isolated beta: PUT {a,b,c} -> status
snapshot reflects it; merge PUT {c,a} vs {a,b,c} -> {c,a,b} (b preserved at
end); malformed payload rejected with a clean 400; sessionOrder persisted to
state.json and survived a restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 79415f2fdfbdf3fbe362a063534e7f84c553eefb)
55f5ada (COD-105) added Phase 2 of the remote-tmux arc: discover codeman-*
sessions on a host and attach to non-owned ones, with detach-not-kill on close.
- Data model: SessionRemote.owned/remoteSessionName + RemoteSessionInfo;
toSessionRemote (owned:true) vs toAttachedSessionRemote (owned:false).
- New Ownership section: discovery (listRemoteCodemanSessions, the literal-\t
parse quirk, never-throws/VITEST), attach-vs-launch selection
(buildRemoteSessionCommand), and the killSession detach-not-kill guarantee.
- API: GET /api/remote-hosts/:hostId/sessions + the attachRemoteSession
create path.
- CLAUDE.md Remote Key Pattern notes discover/attach + detach-not-kill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit f321e1200a9a7c1e58c69ba936b680200fd53275)
Two Codeman clients attaching the same durable remote tmux session at different
viewports would fight: tmux sizes a window to the SMALLEST attached client by
default. Push `window-size latest` to the remote session config so the window
tracks the most-recently-active client instead, letting concurrent clients
coexist; surface the client count for a "shared · N" badge.
Reconciled onto upstream PR #145: #145 moved the durable remote session onto the
dedicated `-L codeman-remote` socket under a `codeman-ssh-` name and scoped every
tmux set-option PER-SESSION (`set -t <name>`, never `-g`) so a shared remote tmux
server's OTHER sessions keep their own prefix/mouse/sizing. The original COD-106
commit added `set -g window-size latest` (GLOBAL) on the old `-L codeman` socket —
a regression against #145's hardening. This commit layers the window-size feature
onto #145's structure as `set -t <name> window-size latest` (per-session, on the
codeman-remote socket). Test assertions updated to the per-session form
(remote-shared-sessions.test.ts) and the byte-identical launch-command test
(remote-ssh-options.test.ts) extended with the window-size line — which supersedes
the separate f09323c9 assertion fix (dropped: it targeted the global form and also
carried unrelated CLAUDE.md doc changes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continuous remote-only reconnect watcher closing the COD-104 durability
arc: when a remote session's local ssh pane dies mid-run, re-establish it
automatically instead of leaving a dead pane until the user pokes it.
Design decisions (per cod108 design doc):
- D1 event->owner: TmuxManager watcher DETECTS a dead remote pane and emits
`remoteSessionDropped`; the session owner (server) reassembles the same
RespawnPaneOptions and calls Session.reattachRemote() -> respawnPane, which
re-runs the idempotent remote command (owned new-session -A / non-owned
attach) and REJOINS the still-running durable remote tmux session. The
watcher never reassembles options itself, and never routes through the
Claude-idle respawn-controller.
- D2 bounded backoff: per-session exponential backoff [5s,15s,45s,2m,5m,5m],
reset on a successful reattach, `remoteReconnectExhausted` emitted once after
the cap. Pure, unit-tested schedule + eligibility decision.
- D3 always-on + kill-switch: `remoteAutoReconnect` app setting (default ON),
read each tick; when false the watcher does nothing.
Guards: killSession() (incl. the non-owned DETACH early-return) and shutdown
add the session to an intentional-teardown guard set + clear its backoff
BEFORE teardown, so a closed/killed tab is never auto-revived. Exactly one
reconnect in flight per session (inFlight guard prevents stacked respawns).
Per-session reconnect/guard state cleared on session removal.
New: src/remote-reconnect.ts (pure backoff + decideReconnect), TmuxManager
startRemoteReconnectWatcher/stop + runRemoteReconnectTick + noteRemoteReconnect
+ guardRemoteReconnect + clearRemoteReconnectState; Session.reattachRemote()
(+ extracted _buildRespawnPaneOptions, shared with interactive start); server
wiring + watcher start; 3 SSE events (sse-events.ts + constants.js in sync,
broadcast + app.js exhausted "Reconnect" affordance); remoteAutoReconnect
schema + settings-ui toggle.
Tests: test/remote-auto-reconnect.test.ts (21) - pure schedule, eligibility
(guarded never reconnects, non-remote/pane-alive/not-due skip, over-cap
exhaust), and manager-level integration (dead remote pane -> dropped ->
backoff -> exhausted; guarded emits nothing; reset-on-success; kill-switch
off; state-cleared-on-remove). Verified real-remote against aa-desktop: drop
local ssh pane -> watcher emitted -> respawnPane reattached the SAME remote
session (remote pane_pid unchanged 3939->3939); test session cleaned up, the
real host sessions left untouched.
Checks: tsc, eslint, check:frontend-syntax, check:public-assets, prettier
--check, build all green; tmux-manager/session-routes/session-manager/
sse-registry-parity suites pass.
(cherry picked from commit d13d58b1994eb6594fd2eadea208104d36204f9d)
Since COD-104 a remote session lives in a durable tmux server on the host and
outlives the local pane, so killing a tab only DETACHED — even for sessions we
own. Propagate `kill-session` to the remote for OWNED sessions in killSession's
owned path (after COD-105's non-owned detach-only early-return); non-owned
detach-only is untouched.
Reconciled onto upstream PR #145: #145 already upstreamed this exact owned-kill
propagation as `buildRemoteKillCommand({ remote, sessionId })` on the dedicated
`-L codeman-remote` socket (matching buildRemoteLaunchCommand) and wired it into
killSession (Strategy 3b, owned-only, fire-and-forget). The original COD-109
commit added a second `buildRemoteKillCommand(remote, name)` overload on the old
`-L codeman` socket plus a duplicate kill block — a compile error AND a wrong
socket post-#145 (owned sessions no longer live on `codeman`). This commit keeps
#145's socket-correct implementation and drops the duplicate; the required
test/remote-kill-command.test.ts is retargeted to #145's `{ remote, sessionId }`
signature and the `codeman-remote` socket.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 of the remote-tmux arc. Discover codeman-* tmux sessions already
running on a remote host (created by the remote's own Codeman or another
instance) and attach to one this Codeman didn't launch, with detach-not-kill
ownership for non-owned sessions.
- remote-hosts.ts: listRemoteCodemanSessions (ssh, VITEST-guarded, never throws)
+ pure parseRemoteSessionList + buildRemoteListSessionsCommand. Parser splits
on the LITERAL \t the remote tmux emits (next-3.7 does not expand \t) AND a
real tab. toAttachedSessionRemote builds a non-owned SessionRemote; toSessionRemote
now marks the COD-104 launch path owned:true.
- tmux-manager.ts: buildRemoteAttachCommand (sibling of buildRemoteLaunchCommand);
buildRemoteSessionCommand selects attach vs launch by ownership. killSession gains
a detach-not-kill early return for non-owned remote sessions: tears down only the
LOCAL pane (kills local ssh -> remote attach detaches), NEVER issues a remote
kill-session.
- types/session.ts: RemoteSessionInfo; SessionRemote.owned + remoteSessionName.
- schemas.ts: CreateSessionSchema.attachRemoteSession {hostId, remoteSessionName};
fixed a pre-existing no-useless-escape lint error in the jumpHost regex.
- case-routes.ts: GET /api/remote-hosts/:hostId/sessions (explicit discovery).
- session-routes.ts: attachRemoteSession create path -> non-owned session.
- UI (index.html/session-ui.js/styles.css): explicit "Discover existing sessions"
button + Attach action (owned:false). No auto-discover.
Verified on aa-desktop: discovered codeman-disco1, attached (attached=1, shared
view), killed local probe pane -> remote SURVIVED_DETACH (attached=0). Tests:
parse/attach-cmd/ownership unit + discovery route, session-routes + case-routes green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 55f5ada9db6d01518a4adf6b752e460b5df39524)
COD-104 wired checkRemoteTmuxAvailable into the remote-session create path,
but it does a real `ssh` via exec — so 2 remote-create tests in
session-routes.test.ts hit a ~10s ssh timeout and failed (422). Mirror
TmuxManager's IS_TEST_MODE no-op-shell-under-VITEST: short-circuit the live
probe to {ok:true} under vitest. Command construction stays covered by
buildRemoteTmuxCheckCommand unit tests. session-routes.test.ts now 61/61.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6ae2c0b8160090a1f0f6b32a3fe8496d402ac2c6)
The per-row ⋯ in the session list was a details toggle that did nothing in
the Session Manager modal (swallowed by the modal's capture-phase
close-on-click). Replace it with a real kebab context menu.
- terminal-ui.js: ⋯ now opens _openSessionRowMenu() — a body-anchored popup
(fixed-positioned, flips/clamps to viewport, z-index above the modal) with:
Resume/Switch-to (live→select tab, closed→resume), Open folder in the file
browser (live sessions only — the browser is session-scoped), Copy path
(_copyText + toast, when workingDir present), and Show details (the old
inline prompt/path panel). Closes on outside-click / Escape / scroll / resize.
- panels-ui.js: _loadSessionManagerList scopes its modal-close to the
.history-item-main (resume) click, so the ⋯/menu no longer closes the modal.
- styles.css: .session-row-menu + .session-row-menu-item.
Verified in Chromium on an isolated beta: ⋯ opens the menu with the modal
still open; closed rows show Resume/Copy path/Show details, live rows add
Switch-to + Open folder; Show details expands inline (modal stays open),
Copy path copies the path, Resume closes the modal, Escape closes only the
menu. Gates: tsc 0, lint 0, frontend-syntax + public-asset format clean.
The complete session list now updates live as sessions change, instead of
only on open/welcome-load.
- app.js: extra SSE listeners (session:created/updated/deleted) on the same
EventSource (multiple listeners per event; existing handlers untouched;
registered via addListener so they tear down on reconnect) call
_onSessionListMaybeChanged().
- panels-ui.js: _onSessionListMaybeChanged() debounced-refreshes the Session
Manager modal when it's open and the welcome list when its overlay is
visible (no work when neither is showing). _loadSessionManagerList stores the
active query so refreshes preserve the user's search.
Verified on an isolated beta instance (Playwright): dispatching a session
event refreshes the modal while open, does NOT while closed (gated), and
refreshes the welcome list while visible. Gates: tsc 0, frontend-syntax +
public-asset format clean, build clean.
Adds a header-reachable Session Manager so the complete session list is
available mid-session, not only on the welcome screen.
- index.html: always-on header button (.btn-session-manager) + #sessionManagerModal
(mirrors the Away Digest modal) with a search box + results list.
- panels-ui.js: openSessionManager()/closeSessionManager()/_loadSessionManagerList()
— loads GET /api/sessions/unified (limit 200), renders via the unit-2
_buildHistoryItem (rich items, mode/LIVE badges, open->select / closed->resume),
debounced search wired to the endpoint's q= param, empty/error states. A
modal-scoped Escape listener closes it even when focus is in the search input;
backdrop click and item click also close it.
- app.js: closeSessionManager() added to the global Escape chain.
- styles.css: modal + list styling (items reuse .history-item).
Verified on an isolated beta instance (Playwright): the header button opens the
modal, it lists 200 sessions from /api/sessions/unified, a no-match query issues
?q= to the server and yields 0 items, clearing restores the list, clicking an
item closes the modal and routes resume/select, and Escape closes it. Gates:
tsc 0, lint 0, frontend-syntax + public-asset format clean, 17 tests pass.
Backs the welcome-screen "Resume Conversation" list with the new
GET /api/sessions/unified endpoint instead of /api/history/sessions, so it
shows the COMPLETE set (live + persisted + non-Claude + closed history)
newest-first with richer context, rather than only Claude transcripts.
- terminal-ui.js: new _fetchUnifiedSessions(); loadHistorySessions() now uses
it. _buildHistoryItem upgraded to the unified shape (kept backward-compatible
with the folder-modal's old shape): title = name || firstPrompt || dir; a
mode badge + a LIVE badge (sources includes 'live'); timestamp from
lastActivityAt (falls back to lastModified); size only when present; detail
panel + "View all in this folder" preserved (gated on projectKey). Resume
branches: an open live session selects its tab, a closed one resumes.
- unified-session-service.ts + endpoint: pass projectKey through the history
source so the folder drill-down survives.
- styles.css: .history-item-badges / -badge / -badge-live pills.
Verified: tsc 0, lint 0, frontend-syntax + public-asset format clean, service
tests 13/13 (+projectKey), route tests 4/4. Playwright on an isolated beta:
the welcome list renders real items from /api/sessions/unified, and the
renderer produces the tab-name title + codex mode badge + visible LIVE badge,
omits LIVE on closed items, keeps "View all in folder", and routes resume
correctly (open->select tab, closed->resume). Persistent panel + live SSE
status are later units.
When a browser pastes an HEIC file without normalising it first, the
paste-image route now converts it to JPEG server-side via heic-convert
before writing to .claude-images/. Magic-byte validation confirms the
output is valid JPEG. Adds type declarations for the heic-convert package.
Co-authored-by: Saqeb Akhter <saqeb.akhter@gmail.com>
A freshly created shell session rendered blank until a tab-switch. selectSession()
fetches the terminal buffer, but for a just-started shell that fetch resolves before
the PTY emits its prompt, so the buffer is empty; the prompt then arrives as a live
SSE event queued during the load and _finishBufferLoad() discarded it. The discard is
correct for an established session (its fetched buffer already contains that output),
but harmful when the load painted nothing.
_finishBufferLoad(owner, { flushQueued }) now REPLAYS the queued events through
batchTerminalWrite (after _isLoadingBuffer is cleared, so they write through, not
re-queue) instead of discarding. selectSession passes flushQueued only in the empty
branch (no fresh buffer + no cache), so the established-session de-dup path is
unchanged. TDD: test/terminal-buffer-flush.test.ts exercises the real begin/finish
mixin (vm-harness, no jsdom).
_updateConnectionIndicator() ran on every keystroke (_reliableSend) and
every ACK (_ackDelivery), unconditionally writing display/className/
textContent/title. During fast typing the rendered output is usually
identical between calls, so those were wasted main-thread DOM writes.
Extracted the branch logic into a pure DOM-free _computeConnectionDescriptor()
returning { display, dotClass, text, title } (every branch/string preserved
verbatim; hidden state normalizes the three non-display fields to '' so the
compare is well-defined). _updateConnectionIndicator() now computes the
descriptor, compares all four fields against a cached _lastIndicatorDescriptor,
and early-returns when unchanged — otherwise caches and writes the DOM exactly
as before (display always; dotClass/text/title only when shown). First call
renders (cache starts null). Perf only, no behavior change.
Tests: test/connection-indicator.test.ts — 9 descriptor cases pinning the
exact strings per state + 4 skip cases (first call writes; two identical calls
write DOM once via counting setters; state change and hidden->shown re-render).
31/31 with input-send-order regression; build, frontend-syntax, prettier clean.
MAX_WS_PER_SESSION was gated by a bare Map<sessionId,number> counter,
incremented on upgrade and decremented only on the old socket's async
close. A client that dropped and immediately reconnected could land its
new upgrade before the old socket's close fired, briefly over-counting and
tripping a spurious 4008 (-> HTTP fallback). The limit also counted raw
sockets, so a reconnecting client consumed a new slot instead of its own.
Replace the counter with WsConnectionRegistry (new pure, unit-tested module)
that tracks live sockets per session keyed by clientId. A same-cid upgrade
SUPERSEDES its own socket (evicts the stale one with close 4010, reuses the
slot, no net count change) -> a reconnect can never be rejected by the cap.
The reliable-input protocol (shouldApplyInput(cid,seq)) already assumes one
logical client per cid per session, so same-cid eviction is principled, not
a regression of multi-tab (which already collides on seq). Slots are freed
EAGERLY on error/terminate, not just async close; close is identity-matched
so a superseded socket's late close is a no-op. cid-less upgrades are
admitted anonymously up to the cap and never evict (backward-compat).
Client sends cid on the WS upgrade URL (?cid=, encoded, omitted if absent).
Tests: ws-connection-registry.test.ts (reconnect-reclaim at cap, rejects
N+1th distinct, eager-terminate frees slot, cid-less up-to-limit + no-evict,
late-close-no-evict, per-session isolation) + route integration in
ws-routes.test.ts (real upgrade through the cap). 45/45 across registry +
ws-routes + input-send-order + ws-reconnect-plan; tsc 0, build, prettier,
frontend-syntax clean.
A reliable-input frame could be stranded forever if its server ACK
({t:'ia',seq}) was lost while the WebSocket kept delivering other output.
_drainSession's WS fast path skips records with sentAt!==0, and after
COD-134 the sweep only force-closes a *silent* socket -- so a lost ACK on
an otherwise-live socket (stale && !silent) was never re-sent.
_redeliverSweep now, for an active-WS session whose oldest unacked frame
is stale but the socket is NOT silent, resets sentAt=0 on every stale
unacked frame and lets the existing _drainSession re-drive them over the
live socket (server dedups by seq). The stale && silent force-close
remains the fallback for a genuinely half-open socket. Restores the
exactly-once recovery guarantee without reintroducing the flap.
Tests: new failing-first COD-135 cases in test/input-send-order.test.ts
(re-drive on live socket; leave not-yet-stale alone; keep stale+silent
force-close). 18/18 across input-send-order + reliable-input-dedup +
ws-reconnect-plan; tsc 0, frontend-syntax, build all clean.
Root cause of the WS->HTTP->WS flapping: the v1.1.15 input-delivery merge left a
call to the now-undefined _flushHttpFallbackQueuesViaWs() in ws.onopen, so every
(re)connect threw a TypeError BEFORE _onWsReady() ran -- durable input was never
re-flushed over the fresh socket, the 2s redeliver sweep then saw stale unacked
frames and force-closed the socket, reconnect, throw again: a self-sustaining
flap loop. Remove the dead call (_onWsReady, 10 lines below, is its replacement).
Resilience + observability:
- Pure CodemanWsReconnect.plan(code, attempt) (constants.js, TDD, 6 tests):
<4004 -> fast reconnect (immediate jittered first retry, faster backoff);
4008/unknown->=4004 -> bounded retry-fallback (HTTP no longer sticks until a
tab switch); 4004/4009 -> give up (session gone). Wired into onclose.
- Redeliver sweep force-closes only a SILENT socket (no recent recv), not one
actively delivering output/ACKs -- stops self-inflicted flaps while typing.
- Client logs WS close code/reason to crash-diag; server logs [ws]
open/close/terminate/4008 (console -> journald; Fastify runs logger:false).
Verified: 6/6 unit, tsc 0, frontend-syntax + prettier clean, build; beta WS
reaches connected with zero console errors (onopen TypeError gone),
_wsLastRecvAt tracked, server [ws] lines emit.
The upstream v1.1.15 merge spliced upstream's transport-object indicator
body onto local's _connectionStatus-based _updateConnectionIndicator()
without defining `transport`, so every transport.* reference threw
ReferenceError on any queued state. That hid the "WS" status and, because
_reliableSend() updates the indicator before _drainSession(), made every
keystroke skip immediate delivery (input flushed only on the 2s sweep =
typing lag).
- Rewrite _updateConnectionIndicator() to show the terminal WebSocket
transport from _wsState (WS / HTTP / WS… / Offline), falling back to the
SSE _connectionStatus only on the idle dashboard.
- Only annotate a backlog (· N queued) above 4 bytes so normal typing no
longer flickers "sending 1B" on each key press.
- test/connection-indicator.test.ts (new): transport labels, the >4B
threshold, an exhaustive never-throws guard for the ReferenceError, and
the _reliableSend -> _drainSession invariant (typing-lag guard).
- test/input-send-order.test.ts: reconcile to local's durable input layer
(the prior coalescing-fallback tests had been failing since 1255e28).
A shell terminal could render output diagonally (each line shifted one
column right) after a full page reload or a cursor-query-failure replay.
Root cause: capturePaneBuffer's full-history path (capture-pane -p -e -S -)
and its cursor-query-failure fallback returned raw scrollback, which tmux
joins with a BARE \n. The browser xterm uses convertEol:false (correct for
the live PTY stream, which carries real \r\n), so each bare \n dropped a
row without returning the cursor to column 0 -> staircase. The visible /
tab-switch path (formatPaneSnapshot) was immune because it repaints each
row with an absolute cursor CSI.
Fix: new pure helper normalizeScrollbackEol() (\r?\n -> \r\n, idempotent
on CRLF, leaves lone \r overwrites untouched, adds/removes no rows) applied
at both raw-return seams. The absolute-positioned snapshot path is unchanged.
Tests: test/tmux-scrollback-eol.test.ts pins the invariant (no LF without a
preceding CR) + CRLF idempotency + lone-CR preservation. 136/136 across
tmux-scrollback-eol + tmux-capture-full-history + tmux-manager +
routes/session-routes; build, tsc, prettier, frontend-syntax clean.
A full page reload (GET /api/sessions/:id/terminal with no ?tail=) now captures
the ENTIRE tmux scrollback via capture-pane -p -e -S -, so users get back history
that scrolled off Codeman's byte buffer. Tab switches (?tail=N) keep the fast
visible-frame capture.
- tmux-manager capturePaneBuffer/captureActivePaneBuffer take { fullHistory }:
full-history returns raw linear scrollback (skips the single-screen
formatPaneSnapshot repaint, which would clip multi-screen history).
- /terminal selects full-history on full reload, visible on tail; caps the
payload at the configured terminalBufferMaxBytes (keeps most-recent bytes,
line-aligned) and returns source/fullSize/truncated metadata.
Verified: tsc 0, tmux-capture-full-history 5/5, session-routes 68/68.
Caveat: lines tmux already evicted past its history-limit can't be recovered.
Defense-in-depth after COD-115. If the interactive PTY exits non-zero
repeatedly within a short window, recovery/reconnect paths recreate it
indefinitely (COD-115 saw 114 'exited with code: 1' events + orphans).
- New pure InteractivePtyExitBreaker (session-pty-exit-breaker.ts):
injectable time, sliding window, clean-exit resets counter, stays
tripped until reset(). Defaults: threshold 5, window 10s.
- Session records each interactive PTY exit in the breaker; on trip it
flips _status to 'error', sets _respawnBlocked, emits
respawnBreakerTripped. startInteractive() refuses to respawn while
blocked, so all recovery/reconnect callers stop looping uniformly.
- Explicit user restart (POST /api/sessions/:id/interactive) calls
resetRespawnBreaker() so intentional restarts are never blocked.
- New SSE event session:respawnBreakerTripped wired in sse-events.ts +
constants.js (registries in sync) + session-listener-wiring.ts;
minimal diagnostic toast in app.js.
- Tests: test/respawn-pty-breaker.test.ts (pure trip/reset/window +
MockSession session-level trip/reset).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the web server is launched from inside a tmux pane it inherits TMUX/
TMUX_PANE. tmux's nesting guard then makes every new attach-bridge PTY
(`tmux attach-session`, used by codex/opencode/gemini and mux-wrapped claude)
exit code 1; the respawn controller recreates the dead bridge → infinite loop.
The existing guard in buildMuxAttachEnv() used `TMUX: undefined` on a
{...process.env} spread, which leaves the KEY present with value undefined —
node-pty serializes it as the literal string "TMUX=undefined", still tripping
the guard. (The working create path in tmux-manager.ts uses `delete`.)
Fix:
- Primary: delete process.env.TMUX / TMUX_PANE at web bootstrap (src/index.ts)
so every downstream {...process.env} spread is clean regardless of launch
context. `delete`, not `= undefined`.
- buildMuxAttachEnv(): build a copy and `delete` TMUX/TMUX_PANE/CLAUDECODE
(and COLORTERM when not truecolor) instead of `: undefined` — same node-pty
quirk affected all of them.
- Test: assert the keys are genuinely ABSENT (`'TMUX' in env === false`), not
merely undefined — the prior test only checked `toBeUndefined()`, which is
why the bug slipped through. Red→green confirmed.
Verified on isolated beta launched from inside tmux (inherited the poisonous
TMUX=codeman,980,7): created a codex session + triggered interactive attach —
the bridge `tmux -L codeman-beta attach-session` spawned with NO TMUX in its
env, attached successfully, zero "exited with code: 1", server healthy.
Circuit-breaker for repeated non-zero bridge exits (AC bullet 4, optional)
split to a follow-up. Deploy-pending (substrate): never auto-deployed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pins a "Browse all sessions…" item at the bottom of the command palette
list (after "New session"). Activating it closes the palette and opens
the Session Manager modal, bridging the gap between the fast in-memory
switcher and the full server-side history browser.
The item gets a distinct visual treatment (≡ icon, muted title/icon
color, 4px top gap) so it reads as a secondary action separate from the
primary session rows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- src/remote-hosts.ts: add missing execAsync = promisify(exec) that was
implied by intermediate commits not in the cherry-pick set
- src/web/routes/session-routes.ts: add getDataDir import and
readRemoteCases/readRemoteHosts/toSessionRemote for remote case support
in quick-start; narrow casePath string|null via resolvedCasePath cast
- test/routes/session-routes.test.ts: add vi.hoisted remoteStore mock for
remote-hosts.js; fix 'creates session from remote case' test to use
/api/quick-start (remote cases are not supported on /api/sessions)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
buildSshConnectionArgs interpolated jumpHost raw while its siblings
(identityFile/socksProxy/extraSshOptions) were shellescaped. The token array is
joined and run via execAsync (/bin/sh -c), so a jumpHost like "x; touch /tmp/pwned"
executed. The Zod denylist only blocked backtick/newline/$( and let ;|& and spaces
through.
- shellescape jumpHost in buildSshConnectionArgs (primary fix)
- replace jumpHost denylist with a structural allowlist: [user@]host[:port],
comma-separated multi-hop, bracketed IPv6; no shell metachar can appear
- update/extend tests: escaped -J assertion + injection-safety case
Verified: remote-ssh-options (11) + case-routes (33) pass, tsc --noEmit clean,
regex accepts valid forms / rejects 8 injection payloads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Remote case form could only reach port-22, default-identity, directly
SSH-able hosts. Add an escape-hatch set of SSH connection options so Codeman
can reach a host like aa-desktop (custom port 2222, ed25519 identity, cloudflared
SOCKS5 ProxyCommand) the way ssh-aa-desktop does — without shelling out to that
wrapper.
- Model (types/session.ts): new optional RemoteSshOptions (identityFile,
socksProxy, jumpHost, extraSshOptions) on RemoteHost AND SessionRemote; all
absent = today's behavior. toSessionRemote() carries them case->session.
- Shared buildSshConnectionArgs(remote) in remote-hosts.ts: pure, exported,
ordered ssh connection tokens (-o BatchMode=yes, -p, -i <abs identity with
~/$HOME expanded + shellescaped>, -J, -o ProxyCommand=nc -X 5 -x <socks>
%h %p emitted as ONE shellescaped token so %h %p reach ssh literally, then
each extraSshOptions -o). Both buildRemoteLaunchCommand (tmux-manager.ts) and
buildRemoteTmuxCheckCommand now use it, so the prereq probe and the real
launch connect identically. checkRemoteTmuxAvailable widened to accept the
options (callers already pass the full host).
- Validation (schemas.ts): identityFile (no newline/NUL), socksProxy
(host:port), jumpHost (no shell metachars), extraSshOptions (KEY=VALUE,
reject newline/NUL/backtick/$() — defense-in-depth on operator-entered config.
- UI (index.html + session-ui.js): SSH Port field + collapsible "Advanced SSH"
section (identity, SOCKS proxy, jump host, extra -o options one per line);
wired into the remote-host create payload.
Empty-options remotes emit byte-identical ssh to before (pinned by test).
Tests: test/remote-ssh-options.test.ts (buildSshConnectionArgs +
buildRemoteLaunchCommand + buildRemoteTmuxCheckCommand for the aa-desktop set,
escaping/%h %p/identity-~ expansion, byte-identical back-compat); case-routes
schema tests (advanced options round-trip; malformed extraSshOptions/socksProxy
rejected). tsc/eslint/frontend-syntax/prettier/build clean.
Acceptance (real remote, no wrapper): the emitted command connected to
aa-desktop through the cloudflared SOCKS proxy and created a durable remote
tmux session (verified independently via ssh-aa-desktop: CONNECTED_NO_WRAPPER,
STILL_ALIVE_AFTER_DETACH); checkRemoteTmuxAvailable over the proxy returned
{ok:true, tmuxPath:/usr/local/bin/tmux}; test session cleaned up.
createInitialRalphTrackerState() stamps lastActivity: Date.now(). The
'should create fresh instances each time' test deep-equaled two factory
results, so two calls straddling a millisecond boundary differed by 1ms
and failed intermittently (e.g. PR #139 CI: 1782927694581 vs ...580).
Exclude the dynamic lastActivity from the equality check and assert it
is a number separately, preserving the test's intent (distinct instances
with identical initial field values) without the timing race.
First increment of the read-only "complete + searchable session list".
- New src/services/unified-session-service.ts: mergeUnifiedSessions() combines
live + persisted (state.json) + lifecycle + ~/.claude transcript history + mux
stats into one list de-duped by sessionId, with precedence
history < lifecycle < persisted < live, a meaningfulness floor that drops bare
lifecycle/mux-only noise, and a stable newest-first sort. Plus
filterAndPaginate() (case-insensitive q over name/firstPrompt/workingDir/
sessionId; total before paging; limit clamped [1,500]). No IO — unit-testable.
- New GET /api/sessions/unified in session-routes.ts: gathers the five sources
from ctx (sessions/store/lifecycle/scanProjectDir/mux, each try/caught), feeds
the pure service, returns { sessions, total } (ApiResponse envelope). testMode
short-circuits to empty.
Tests: unified-session-service.test.ts (12, pure) + unified-sessions-routes.test.ts
(4, app.inject).
Bump the centralized terminal-history defaults: tmux scrollback 50k->100k and
PTY buffer cap 2MB->32MB (trim 1.5MB->24MB). Both remain env/settings overridable
and bounds-clamped. Worst-case 20-session buffer budget rises 40MB->640MB.
Stacked on the terminal-history config commit.
Introduce src/config/terminal-history.ts: one place for terminal scrollback,
tmux history-limit, and PTY buffer byte caps, each overridable via env var or
the settings object and bounds-clamped via resolveTerminalHistoryConfig().
Defaults match the prior hardcoded values, so this is behavior-neutral. Wires
the resolver through buffer-limits, tmux-manager (incl. a setHistoryLimit so a
settings change applies live), session, server, system-routes, session-routes,
schemas, and the config port. Adds 4 optional settings keys (terminalScrollback
Lines, tmuxHistoryLimit, terminalBufferMaxBytes, terminalBufferTrimBytes) with
bounds + a trim<=max cross-check.
The mobile-header-buttons-policy static guard requires every default-visible
header button to make an explicit phone-visibility decision. The new
.btn-away-digest button had none, failing CI. Hide it on phones alongside
.btn-settings / .btn-lifecycle-log — it's a secondary informational control
that doesn't belong on the cramped phone header.
The Ralph settings modal sent maxTodos/todoExpirationMinutes but RalphConfigSchema
(zod) stripped them and the ralph-config route never applied them, so the inputs
were silent no-ops.
Fix: add both as optional positive-int fields to RalphConfigSchema; destructure
and apply them in the ralph-config route (matching the maxIterations pattern).
RalphTracker had no setters (the values were module constants) — added per-instance
_maxTodos/_todoExpiryMs (defaulting to the same constants, behavior unchanged),
switched the eviction + expiry sites to read them, and added
setMaxTodos/setTodoExpirationMinutes (minutes→ms) + getters.
Test: route test POSTs the two fields and asserts the route applies them to the
tracker. Verified RED (setters not called — fields stripped) → GREEN. 34/34
ralph-routes tests pass; tsc + eslint(src) + prettier + build clean. Frontend
already sent the fields (no change).
The ultracode run/agent cards and minimized-tab badges built inline onclick
handlers by interpolating escapeHtml(value) inside single-quoted JavaScript
strings within an HTML attribute:
onclick="app.openUltracodeAgentWindow('${escapeHtml(agentId)}', ...)"
escapeHtml maps ' -> ', but the browser HTML-decodes the attribute value
before the handler source is parsed, so ' becomes a literal ' again and a
quote in a run/agent/session id breaks out of the string literal into
executable JS. escapeHtml alone is insufficient for the JS-string-within-HTML-
attribute double context.
Switch each handler to escapeHtml(JSON.stringify(value)): JSON.stringify
JS-encodes and quote-wraps the value, then escapeHtml handles the HTML
attribute layer, so the value round-trips as an inert string argument. This
matches the encoding already used by other handlers in these files.
Affected:
- ultracode-panel.js: selectWorkflowRun, openUltracodeAgentWindow
- ultracode-windows.js: restore/dismiss for minimized run and agent tabs
Tab-switch shortcuts matched e.key, so on macOS Option+1 emits a special
character ('¡', not '1') and the shortcut silently failed. Switch to physical
e.code (Digit1-9), which is layout-independent. Also adds Option+[ / Option+]
for previous / next session. Help modal + README updated.
Test: test/keyboard-shortcuts.test.ts.
When desktop session tabs overflow one row, wrap them to a second row instead
of horizontal scroll — unless the user has pinned the manual two-row layout
(tabTwoRows). Mobile/tablet keep horizontal scroll. The wrap policy
(shouldAutoWrapTabs) lives in constants.js as a pure, unit-testable function;
updateTabOverflowMode() measures overflow after each tab render and toggles
.tabs-auto-wrap.
Test: test/tab-overflow.test.ts (vm-loads constants.js, asserts the policy).
COD-54 gated the /api/hook-event + /api/status-telemetry localhost bypass
behind the shared X-Codeman-Hook-Secret only WHILE a managed tunnel was
running, keeping a plain localhost bypass otherwise. But Codeman can't detect
a user's OWN loopback reverse proxy (their own `cloudflared --url`,
`tailscale serve`, nginx -> 127.0.0.1), which proxies internet traffic into
the loopback origin with req.ip === 127.0.0.1 — so that setup kept the unsafe
plain bypass.
Require the secret on the loopback bypass unconditionally. Managed-session
hooks already always present it (X-Codeman-Hook-Secret from
$CODEMAN_HOOK_SECRET_FILE, generated for every instance), so the legitimate
hook channel is unaffected; only the previously-unguarded own-proxy path is
now rejected. Drops the now-unused getTunnelRunning param from
registerAuthMiddleware.
Tests: cod54-hook-event-auth (tunnel-down now also requires the secret, plus
a good-secret positive case); auth-security (hook tests present the secret to
reach schema validation).
The previous _sanitizeHtml was a denylist over agent/transcript markdown
rendered via innerHTML; it missed style attributes and the svg/math mXSS
namespaces — e.g. <svg><style><img src=x onerror=alert(1)></style></svg>
re-serialized into a live <img onerror>.
Vendor DOMPurify 3.4.8 (allowlist) following the existing marked.min.js
vendor pattern (same-origin, CSP script-src 'self'; not in package.json so
no lockfile drift). New sanitize-html.js wires a hardened allowlist config
(FORBID style/svg/math/script/iframe/object/embed/form; no data attrs);
app.js _sanitizeHtml delegates to it with a fail-closed escape-all fallback.
index.html loads dompurify -> sanitize-html -> app.js (defer); build.mjs
minifies + content-hashes sanitize-html.js.
Test: test/markdown-sanitizer.test.ts (jsdom, real shipping artifacts) —
mXSS payloads neutralized + legit markdown preserved.
Environment-aware dependency probe (linux|darwin|win32|wsl) with a static
registry, an injectable ProbeHost seam for testing, grouped table + `--json`
output, and a non-zero exit when a required dependency is missing/outdated.
Node and tmux are the only hard-required tools; the agent CLIs and document
converters (LibreOffice / MS Office via WSL interop) are optional. CI-safe
unit tests (no tmux, injected host).
Stacks on COD-38: accumulates a per-session attachment history and exposes it
through a slide-in drawer with an unread badge, so attachments stay reachable
after their cards are dismissed.
Backend:
- session-attachment-history: history state — dedupe by source path / relative
path, newest-first, 100-item cap, and externalPath sanitization (the absolute
host path is server-private and never leaves toState()).
- session.ts: _attachmentHistory + getter (sanitized) / upsert / restore /
getAttachmentHistoryForPersist; restored from saved state in the constructor.
- file-routes: GET /attachments (list — resolves each entry to live metadata +
routes; external entries are re-registered) and GET /attachments/:id
(metadata poll). The by-id route guards via the registry's TOCTOU-safe
resolveServableAttachmentPath.
- server.ts: detected/registered attachments upsert into history and persist;
the private (externalPath-bearing) history rides on disk under
__attachmentHistory, separate from the sanitized public copy, and is restored
on mux-session recovery.
- types/session.ts: SessionAttachmentHistoryItem + SessionState.attachmentHistory.
Frontend:
- panels-ui: the drawer (lazy-built), unread badge, list render with per-item
preview/download/open/"Card" (reshow) actions, and live refresh of the open
drawer on new detections.
- app.js: history state + per-session badge/cleanup wiring.
- index.html / styles.css / mobile.css: header button + badge and the drawer.
Verified: tsc / eslint / prettier / frontend-syntax / public-assets clean; new
history-module unit tests pass; full test:ci green (2866 passed); badge, drawer
open/render/reshow/close verified in-browser.
Builds on the COD-37 registry: surfaces detected/registered attachments as
dismissible cards with a first-page thumbnail and an inline preview — the
consumer the registry PR deliberately deferred.
Backend:
- document-thumbnailer: first-page PNG thumbnails (PNG passthrough; PDF via
pdftoppm; Office via the preview cache).
- document-preview-cache: disk-cached DOCX/PPTX -> PDF conversion (LibreOffice
/ PowerShell COM), in-flight dedup, multi-converter fallback.
- file-routes: serveConvertedPreview / serveThumbnail + four routes —
GET .../attachments/:id/preview, .../thumbnail and the workspace-path
file-preview / file-thumbnail. Reuses the registry's TOCTOU-safe
resolveServableAttachmentPath, so previews stream the freshly-resolved path.
- server: enrich detected attachment events with a thumbnail route.
- image-watcher: .png now routes to attachment:detected — this PR adds the card
consumer, so the screenshot popup is no longer its only handler.
Frontend:
- panels-ui: attachment cards (addAttachmentCard, lazy stack, Clear-all,
per-session cleanup) plus a 3-arg openFilePreview that renders registered
attachments inline (image/PDF) or via the server-converted PDF (docx/pptx).
- app.js: wire attachment:detected -> _onAttachmentDetected and card state.
- styles: attachment-card + stack styling.
Verified: tsc / eslint / prettier / frontend-syntax clean; new thumbnailer +
preview-cache unit tests pass; full test:ci green (2861 passed); card render +
preview overlay + dismiss verified in-browser.
Adds the foundation for serving local files to the browser as live external
attachments with a stable id, so requests never carry arbitrary absolute paths.
- attachment-registry: in-memory, session-scoped registry. registerExternalAttachment
validates an absolute path, resolves symlinks, enforces the path guard, and mints
an `att_<uuid>` id; records are cleared when the session is removed.
- attachment path guard: a configurable blocklist (secret locations + /root,/etc
trees, extendable via attachmentBlockedPaths / CODEMAN_ATTACHMENT_BLOCKED_PATHS)
plus an optional, default-off workspace-confinement mode. Shares one
sensitive-path blocklist (web/sensitive-path.ts) with /api/download, which is
refactored to use the extracted module instead of an inline copy.
- terminal magic links: the session scans output for codeman://attach?path=... and
emits `attachmentRequested`; the web server registers the file and broadcasts an
`attachment:detected` SSE event. `codeman attach <path>` (CLI) prints the magic
link or POSTs directly when a session id is known.
- image watcher: detects png/pdf/docx/pptx dropped into a session's working dir and
emits `attachment:detected`.
- routes: POST /api/sessions/:id/attachments (register) and
GET /api/sessions/:id/attachments/:attachmentId/raw (serve), both re-checking the
guard before streaming.
Document previews/thumbnails and the attachment-history drawer build on this
foundation and land separately.
Verified: tsc --noEmit, lint, format, frontend-syntax, full test:ci (2846 passed),
and a server boot smoke (/api/status 200).
Switching away from a session and back replayed only the server's byte
history. For TUI modes (codex especially) that shows just the latest
repaint — the idle banner — because the TUI drops earlier conversation
from its current frame. This restores the actual on-screen view.
Two complementary mechanisms:
- Client: load xterm's SerializeAddon and snapshot the rendered state
(viewport + scrollback + colors) per session on switch-away, restoring
it for an instant first paint on switch-back. The snapshot is only the
first paint — the canonical /terminal frame is still fetched and
reconciled (restoredSnapshot/clearedForBusy force the replay). Snapshots
are LRU-bounded in memory (<=20) and persisted to localStorage
(<=256KB each, <=10 sessions, stale-pruned) so they survive tab discard.
- Server: GET /api/sessions/:id/terminal prepends the live tmux pane
buffer (via the existing captureActivePaneBuffer) ahead of the byte
history, cleared between, so replay reflects the current frame.
Also fix formatPaneSnapshot dropping the rightmost column of every
captured row: it painted to cols - 1 out of caution about last-column
autowrap, but every row is followed by an absolute cursor-position CSI
that cancels xterm's pending-wrap, so painting the full width is safe.
The SerializeAddon is built from @xterm/addon-serialize (new dependency)
into the vendor bundle by postinstall.js (dev) and build.mjs (prod),
matching how the other xterm addons are vendored.
Two render-polish fixes for codex sessions in the terminal write pipeline:
- flushPendingWrites uses a 32KB first-frame budget for codex (vs 64KB for
other modes). Codex's TUI emits dense synchronized redraws during
thinking/high-effort phases; a smaller first frame keeps per-frame
xterm/WebGL stalls short and avoids multi-second main-thread blocks.
- Sticky-scroll now honours a short grace window after a manual scroll-up
gesture (USER_SCROLL_STICKY_SUPPRESS_MS = 1500ms). High-frequency codex
"Working (Ns)" status ticks were snapping the viewport back to the bottom
while the user tried to read earlier output. The wheel/touch scroll
handlers record the gesture (_noteTerminalUserScroll); flushPendingWrites
suppresses the auto-scroll-to-bottom and restores the preserved viewport
via scrollToLine while the grace window is active.
Adds test/terminal-flush-budget.test.ts (vm-sandbox harness over
terminal-ui.js): codex vs non-codex first-frame budget, buffer-load
ownership, and the scroll-up suppression / viewport restore.
Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
Codex's TUI emits alternate-screen toggles (DECSET/DECRST 47/1047/1049),
scrollback-erase (CSI 3 J), and mouse-tracking enables (?1000-1007) during
startup and on every repaint. xterm.js obeys them: it switches to the
scrollback-less alternate buffer, wipes saved lines, and forwards the scroll
wheel to codex — so the user's conversation history both disappears and
becomes unreachable on each tab switch / pane refresh.
Strip these sequences in two places, leaving the visible-viewport erases
(2J / J) intact so codex can still repaint its own rows:
- Session._handleTerminalOutput: filter the live SSE/WS stream and the
persisted terminal buffer at the source, for mode === 'codex'.
- GET /api/sessions/:id/terminal: apply the same strip to the replayed
buffer (ALT_SCREEN_TOGGLE_PATTERN / ERASE_SCROLLBACK_PATTERN) so a
tab-switch replay keeps full scrollback.
Adds test/codex-terminal-output.test.ts covering the strip (alt-screen and
3J removed, 2J/J preserved, Ctrl+L redraws preserved) and confirming codex
output passes through without Ink row-repair mangling.
Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
Two hardening fixes for the public-tunnel exposure path (COD-54 / COD-55).
COD-54 — gate the /api/hook-event localhost bypass when a tunnel is up:
`cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
loopback origin, so a tunneled hook request arrives with req.ip === 127.0.0.1
and the old bare-localhost bypass would pass it unauthenticated. Now:
- tunnel running → bypass requires a shared per-instance hook secret
(X-Codeman-Hook-Secret header; constant-time compare) + per-IP rate limiting
- tunnel not running (loopback-only, the normal case) → unchanged, so
already-deployed credential-less hooks keep working.
New src/config/hook-secret.ts; auth middleware takes a getTunnelRunning probe
(wired from server.ts via tunnelManager.isRunning()).
COD-55 — refuse starting the Cloudflare tunnel without auth:
enabling the tunnel publishes full terminal control to a public URL; with no
CODEMAN_PASSWORD the auth middleware is inactive and the bind guard never trips
(tunnel binds loopback). PUT /api/settings now refuses tunnelEnabled:true with a
403 (before persisting) unless CODEMAN_PASSWORD is set or
CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 is acknowledged. New
isUnauthenticatedNetworkAcknowledged() in network-auth-policy; settings-ui
surfaces the refusal as an error toast and reverts the toggle.
Scope: the always-on CSRF/Origin guard, Host-header allowlist, and
network-auth-policy itself are already upstream (#113) and not re-proposed here.
Verification: tsc, eslint, prettier, check:frontend-syntax clean; full test:ci
green (2723 passed), incl. test/cod54-hook-event-auth and
test/routes/system-routes-tunnel-guard.
Mobile-focused fixes for the web UI: keyboard-accessory layout and
overlap, native input visibility above the keyboard, CJK input handling,
terminal touch scrolling, tab-menu tap targets, mic-recording glow
containment, and mobile resize/keyboard-state handling on tab switch,
plus mobile visual-regression test coverage and snapshots.
Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
Mode-agnostic terminal foundation extracted from the downstream branch:
- formatPaneSnapshot: SGR/grapheme-aware tmux pane capture + active-pane
resolution, with OSC/CSI redraw suppression and the buffer-load owner-token
race fix on the terminal fetch path
- socket-correct tmux lifecycle: dedicated -L socket and /tmp launch cwd in
createSession (restores the FUSE/getcwd hardening from #110), and a
socket-aware re-attach window-size query (avoids the 120x40 flicker)
- inline-rename: commit/cancel state handling clears _activeRename and skips
the API call on cancel
- selectSession: restored detached-window raise short-circuit
The codex-specific xterm snapshot/replay, the vendored serialize addon, and
the synchronous live pane-capture on the request path are intentionally
excluded: they depend on a 'codex' SessionMode that doesn't exist on master
and are deferred to COD-34 (which introduces that mode). The capture
primitives remain exported for COD-34 to build on.
Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
* 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>
* 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>
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>
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>
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>
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>
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>
POST /api/clipboard with { text } broadcasts to all connected browsers
via SSE. Browser attempts navigator.clipboard.writeText, falling back
to a modal with manual copy button if blocked.
Enables remote clipboard workflows: CLI tools can push text to the
user's browser clipboard across the network.
- Active tab: bright green border with color-matched glow per session color
- Tab number badges (1-9) showing Alt+N shortcut hints
- Badges update on tab reorder and re-render
On Android tablets, pressing Shift+A produces "AA" because the input
event listener re-sends characters that xterm already processed via
its keydown handler. Track keydown timestamps and skip input events
that fire within 50ms of a handled keydown.
Only affects touch devices (listener gated by isTouchDevice()).
Add app icons, update manifest with icon entries, and add app-shell
caching to service worker for offline/instant startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>