Release 1.3.5. Consumes the changeset from PR #155: re-issue the
codeman_session cookie on every authenticated request so the browser cookie
lifetime tracks the server-side sliding TTL, fixing the recurring native Basic
Auth dialog during active use.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
screenshots-readme/, screenshots-readme-real/, screenshots-real/ and
design-explorations/ are local capture scratch that was untracked but not
ignored, so an unqualified `git add -A` during a COM could sweep them into a
release (this has happened before).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deterministic claude --version probe seeds cliVersion so wheel-forwarding
to Claude's transcript engages (banner scrape was unreliable on 2.1.187+
and resumed sessions). Shift+wheel reads the dominant axis so a trackpad's
horizontal Shift-scroll reaches local scrollback. New per-device
"Wheel Scrolls Local History" opt-out. Wheel reports use a fire-and-forget
send path so they no longer flicker the pending-bytes indicator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the Cron Jobs modal skin-aware + consistent with App Settings:
skin-variable selects (appearance:none, --bg-input fill, custom chevron),
color-scheme:dark for native controls, themed date/time inputs, and
btn-toolbar-sized toolbar/footer buttons. Bumps aicodeman to 1.3.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign the Cron Jobs modal to match App Settings styling + fix the
create form never collapsing (scoped #cronModal .hidden rule). Bumps
aicodeman to 1.3.1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Hide the new btn-session-manager header button on phones: add it to the
@media (max-width: 430px) display:none block in mobile.css (next to
.btn-away-digest) and to KNOWN_PHONE_HIDDEN in the mobile-header policy
test, closing the recurring phone-header-leak regression that was PR
#153's red CI job.
- Put the session-manager header button on its own line in index.html
(was crammed onto the away-digest line).
- app.js: drop session:updated from the unified-list SSE refresh trigger —
it is batch-broadcast ~every 500ms per active session and would turn an
open modal / visible welcome list into a sustained ~1 Hz full projects
rescan loop; created/deleted (structural changes) are sufficient.
- terminal-ui.js _fetchUnifiedSessions: check the ApiResponse envelope and
throw on failure so a 5xx surfaces via the caller's catch instead of
rendering an empty history.
- terminal-ui.js _openSessionRowMenu: on re-entry, invoke the previous
menu's close fn (stored as _openRowMenuClose) so its document/window
listeners are detached rather than leaked; use claudeSessionId ||
sessionId in the 'Resume session' menu item to match the main-row and
Session Manager resume routing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve the 4 conflicted files toward master's merged #146 work while
keeping PR #153's genuinely-new additions:
- app.js: keep the full Escape chain (closeSessionManager +
closeCommandPalette + closeShortcutOverlay).
- index.html: keep master's Command Palette modal markup alongside the
PR's Session Manager modal + header button.
- styles.css: keep master's Command Palette + COD-157 shortcut CSS AND
the PR's COD-130 session-row kebab-menu CSS (both inserted at the same
spot — reunited each with its own closing brace).
- terminal-ui.js: resolve _buildHistoryItem's main-row click handler to
master's options.onActivate contract with a liveness + claudeSessionId
-aware resume default, preserving the PR's two-shape/badges/kebab body.
- panels-ui.js: the PR's pre-#146 Session Manager block auto-merged as a
duplicate AFTER master's fixed block (last-key-wins regression) — drop
it, keep master's implementation plus the PR's new
_onSessionListMaybeChanged.
Backend projectKey plumbing and the SSE live-refresh listeners in app.js
merge additively and are kept as-is.
Includes review fixes: full route-test coverage for the rollout locator/parser (originator/uuid/pin resolution, dedup, injected-context filtering), LRU caches, multi-block text joins.
# Conflicts:
# src/web/routes/session-routes.ts
Includes review fixes: real _wsState lifecycle (connecting/connected/disconnected), per-tab supersede identity (multi-tab coexistence), preserved reconnect backoff, connection-dot CSS for connected/fallback states.
Includes review fixes: explicit ?full=1 trigger wired from initial page load, capture maxBuffer sized from config with -S line bound, capture returned alone (no byte-buffer duplication), early byte-cap before normalization.
- help-modal extractor bounds at the next HTML comment (cron modal's 'Run At'
text false-positived the stale-shortcut regex)
- remote-shell run test expects the wired /api/quick-start path (#145) — POST
/api/sessions has no caseName in its schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Includes review fixes: Session Manager aligned to the merged /api/sessions/unified contract with error states, Ctrl+K no longer leaks 0x0B into the PTY, shortcut registry finished (dispatch/persistence/rendering), shortcutOverrides preserved across settings saves, help modal kept reachable.
# Conflicts:
# README.md
# src/web/public/index.html
# src/web/public/session-ui.js
- saveAppSettings() rebuilds settings from the DOM; carry over shortcutOverrides
like showTokenCount/showCost so rebinding survives unrelated saves
- shortcut overlay footer links to the full help modal (its only opener was the
legacy Ctrl+? route this PR replaced)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- UI: add the missing data-tab="case-remote" tab button; dispatch it through
submitCaseModal()/switchCaseModalTab() to linkRemoteCase() (was dead code).
- Restore: restoreMuxSessions() now passes remote (muxSession.remote ??
savedState.remote) into the Session constructor, so remote metadata round-trips
on restart instead of reattaching from a local cwd / respawning LOCAL / being
erased from state.json. Recovery tests added.
- Run flows: runClaude()/runShell() route remote cases through /api/quick-start
(POST /api/sessions stat-validates workingDir locally); run*() skip the
/api/*/status pre-check and omit inert config/env for remote cases.
- Quick-start: resolve the remote case BEFORE the local CLI availability gates and
skip isCodex/Gemini/OpenCodeAvailable() when remote; REJECT
envOverrides/effort/codex/gemini/openCode config for remote (they don't cross
ssh) instead of silently dropping them.
- Injection: reject $, backtick, $( in remotePath + identityFile at the schema
layer (they survive shellescape into the bash -c launch double-quote layer).
Regression tests for $(...) and backtick payloads added.
- Remote socket/name: launch on a DEDICATED -L codeman-remote socket under a
codeman-ssh-<id> name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN, so a
remote instance can't adopt the session; scope tmux set-options per-session
(never -g) so they don't mutate other sessions.
- Kill: best-effort ssh 'tmux -L codeman-remote kill-session' on remote session
kill (fire-and-forget, never blocks/throws the local kill) so the remote agent
isn't orphaned forever.
- Probe: wire checkRemoteTmuxAvailable() into POST /api/quick-start (structured
OPERATION_FAILED) and as courtesy validation in remote-link; add a default
-o ConnectTimeout=10 to buildSshConnectionArgs (overridable via extraSshOptions).
- Command default: remote claude default is now
'exec claude --dangerously-skip-permissions' (per-host override stays the escape
hatch), mirroring local non-interactive semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Reject multi-line prompts end-to-end: schema refines on promptText/
launchCommand, runtime check in resolvePrompt (prompt-file content;
trailing newlines tolerated), matching cron-ui form validation — delivery
is single-line only, so multi-line was silently corrupted (typed mode
fused lines, paste mode submitted partials)
- Close the workingDir confinement bypass (arbitrary server-side file read,
e.g. workingDir=/proc + /proc/self/environ): realpath-resolve workingDir
before the containment check, reject '/' and blocked/pseudo-fs trees
(/proc, /sys, /dev + the attachment-guard blocklist) at fire time AND at
job create/update (workingDir must exist and be a directory)
- Session lifecycle: new per-job autoClosePreviousSession (default true,
recurring schedules only; ignored for 'once') — the previous run's
still-open session is closed via the normal cleanupSession path when the
next run fires; UI switch added; 50-session cap math documented in
docs/cron-guide.md §8
- skip_if_same_agent_running: count only live sessions (exclude
stopped/error dead tabs), exclude sessions created by this job's own runs
(fixes the fire-once-then-skip-forever self-deadlock), and a skipped
'once' job stays armed and retries next tick instead of being consumed;
liveness filter mirrored in cron-ui _countActiveAgents
- Wire launchCommand (was accepted+documented but dead): shell mode sends
it via writeViaMux as the first input line after startShell readiness
(single-line, schema-enforced); form field shown for shell agent type
- Record delivery failures: a false writeViaMux result now fails the run
instead of recording a false 'prompt_sent'
- Cap saved jobs at MAX_CRON_JOBS (100) to bound state.json growth
- Surface field-specific schema messages (drop parseBody custom
errorMessage on cron create/update)
- Tests: workingDir create/update validation, /proc bypass regression,
single-line enforcement (schema+runtime+trailing-newline tolerance),
live/own-session skip filtering, once-skip re-arm, auto-close on/off/once,
job-count cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Session Manager (COD-121/192): align _loadSessionManagerList() with the
merged #139 endpoint — map UnifiedSessionItem fields (lastActivityAt
epoch-ms → lastModified, optional sizeBytes/firstPrompt/name) to the
history-record shape _buildHistoryItem renders; surface non-2xx /
error-envelope responses as a visible message instead of a silent
"No sessions found"; route clicks by liveness (live row → selectSession,
history row → resumeHistorySession by conversation UUID) via a new
onActivate option so a live session is never duplicate-resumed
- Ctrl+K double-dispatch: gate the palette chord in
attachCustomKeyEventHandler (return false on keydown) so xterm never
writes 0x0b kill-line into the PTY while the palette opens; gate is
registry-aware so a rebound/disabled palette shortcut restores normal
terminal Ctrl+K
- Shortcut registry (COD-157) finished per maintainer decision: document
keydown now dispatches through getShortcutRegistry() +
matchesShortcutEvent() (legacy SHORTCUTS table removed), honoring
per-shortcut disable and rebinds incl. the palette chord; overrides
persist via saveAppSettingsToStorage() (correct device key + cache
coherence, was orphaned 'codeman:settings'); Shortcuts tab renders on
open via switchSettingsTab hook; capture uses a persistent listener that
ignores bare modifier keydowns (combos now capturable) and requires a
Ctrl/Cmd/Alt chord; settings rows use delegated listeners instead of
inline onclick (JS-string injection sink) and overrides can no longer
clobber id/label/action; added the missing row + overlay CSS
- matchesShortcutEvent: reject undeclared extra modifiers (Ctrl+Shift+K
no longer hijacked from Firefox devtools) while keeping Ctrl/Cmd
interchangeable; match physical code OR produced key for layout parity
- Registry/dispatch gaps: added restore-terminal-size entry, documented
Ctrl+Shift+R again in the help modal (test flipped to assert presence),
Ctrl+?/Alt+? now really open the registry-driven shortcut overlay, and
Escape closes it
- Palette new-session pick routes through selectQuickStartCase() so the
searchable combobox, dir display, and lastUsedCase stay in sync
- Removed fork cherry-pick debris: dead _onSessionListMaybeChanged(),
orphaned .session-row-menu CSS, nonexistent closeMobileHeaderUtilities
calls
- Tests: functional vm-harness coverage for the unified-list field
mapping + error state + liveness routing, palette chord shift/disable/
rebind handling, override persistence round-trip, capture flow, tab
render hook, and source guards for the PTY gate + registry dispatch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- _wsState now transitions through the full lifecycle: _connectWs() sets
'connecting', ws.onopen (inside the this._ws === ws guard) sets 'connected',
_disconnectWs() resets to 'disconnected' — the connection chip's "WS" state
was previously unreachable (stuck on "WS…"/"HTTP" forever).
- WS registry supersede is now keyed per TAB: the upgrade URL sends
cid = clientId + ':' + per-page nonce (reusing the constructor's page UUID),
while input frames keep the bare browser clientId for seq dedup — two
tabs/windows on one session coexist instead of 4010-evicting each other in a
perpetual 5s ping-pong; a genuine same-tab reconnect still supersedes.
- Exponential backoff engages: _disconnectWs() no longer zeroes
_wsReconnectAttempts (it's called at the top of _connectWs, so every retry
replanned at attempt 0 → ~0ms tight reconnect loop during outages); onopen
resets the counter on success.
- styles.css: add .connection-dot.connected (green) and .connection-dot.fallback
(yellow) — both states rendered an invisible dot (no rule existed).
- Remove smuggled dead code: resolveMonitorRowLabels/CodemanMonitorLabels
(COD-122, no consumer, referenced test doesn't exist) and the never-written
_wsLastClose/_wsInputSendCount/_httpFallbackSendCount diagnostics.
- Tests: new test/ws-state-lifecycle.test.ts drives the REAL
_connectWs/onopen/onclose/timer cycle (state transitions, escalating backoff
delays, composite cid on the upgrade URL); registry two-tab coexistence test;
static check that every emitted connection-dot class has a styles.css rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Replace the 'missing ?tail means reload' overload with an explicit ?full=1
query param: the frontend's first buffer load after a page load (selectSession)
now requests full=1, tab switches keep ?tail=, and the legacy no-param callers
(response-viewer fallback, clearTerminal refresh) keep the cheap visible-frame
path — the COD-47 feature was previously unreachable from a real reload.
- When the full-history capture succeeds, return it ALONE instead of prepending
the byte buffer + \x1b[H\x1b[2J: the capture is the rendered superset of the
byte history, and ED2 clears only the viewport so the concat replayed the whole
conversation twice in xterm scrollback. The history+clear+frame concat stays
for the visible-frame/tab-switch path.
- Pass an explicit execSync maxBuffer for the full-history capture (configured
terminalBufferMaxBytes + slack) — the 1MB Node default ENOBUFS-killed exactly
the multi-MB captures the feature exists for; log ENOBUFS concisely instead of
dumping the truncated stdout.
- Bound the capture itself via -S -<N> derived from the configured tmux
history limit (was unbounded -S -), and add -J so lines hard-wrapped at the
capture-time pane width reflow in the browser xterm.
- Cap the concatenated buffer to terminalBufferMaxBytes EARLY (before the
regex normalization passes) so multi-MB captures don't stall the event loop
normalizing bytes that get sliced away.
- Tests: route tests updated for ?full=1 semantics (capture-alone response,
config-forwarded capture bounds, byte-history fallback, no-param requests
stay on the visible-frame path); source-scan tests cover the bounded -J -S -<N>
flags and explicit maxBuffer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Breaker reset is now explicit-only: POST /api/sessions/:id/interactive no
longer unconditionally resets the PTY-exit breaker (that endpoint IS the
frontend's automatic re-attach path, so the breaker could never trip on the
COD-115 crash loop and any tab click silently re-armed it). The route accepts
a schema-validated optional body flag {clearBreaker:true}
(InteractiveStartSchema) and resets only when it is sent.
- Frontend restart control: app.js selectSession keeps the bare auto-attach
(no body, never clears); when the selected session has respawnBlocked it asks
for explicit user confirmation and only then re-POSTs with clearBreaker:true.
respawnBlocked is surfaced via SessionState/toState() (runtime-only, not
restored on boot so recovery can re-attach).
- Trip observability: WebServer.setupSessionListeners() is now idempotent
(skips while refs are attached) and the re-attach routes (/interactive,
/interactive-respawn, /shell) re-run it, restoring the wiring that the exit
handler detaches on every PTY exit — without this the 5th-exit trip had
guaranteed zero listeners (no SSE, no push, no persist, no run-summary).
- Push notification: added SessionRespawnBreakerTripped to PUSH_EVENT_MAP
('Session crash loop stopped', urgency critical) with an exit-count body
branch; previously sendPushNotifications silently no-oped.
- Minor: buildMuxAttachEnv() truecolor param is now actually passed
(codex/gemini, mirrors buildEnvExports); buildClaudeEnv() uses delete for
COLORTERM/CLAUDECODE (same node-pty "KEY=undefined" quirk as COD-115).
- Tests: route tests assert auto-reattach does NOT reset, clearBreaker resets,
invalid flag rejected, and listener re-wiring on /interactive + /shell;
real-wiring lifecycle tests (createSessionListeners/attach/detach) prove the
exit-detach gap and that re-setup keeps the 5th-exit trip observable;
PUSH_EVENT_MAP regression guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Event-loop blockage: HEIC decode/encode (CPU-synchronous libheif WASM +
jpeg-js) now runs in a per-conversion worker_threads Worker
(src/web/heic-jpeg-worker.ts, spawned by heic-jpeg-converter.ts) with
resourceLimits and a 30s hard timeout that terminates the worker —
verified end-to-end under tsx and against compiled dist/ output with a
real iPhone HEIC (event-loop max stall 52ms during conversion).
- No server-side concurrency cap: conversions now acquire a slot from the
existing global runWithConversionLimit() pool (document-conversion-limiter),
bounding peak decode memory/CPU across simultaneous uploads.
- Decompression bomb: header-declared dimensions are read via heic-decode's
allocation-free `.all` path and rejected above 64MP BEFORE decode() can
allocate width*height*4 bytes (a <300-byte crafted file can declare
30000x30000 = 3.6GB). Regression-tested with a crafted ISOBMFF fixture
against the real heic-decode WASM (test/heic-jpeg-core.test.ts).
- Mislabeled HEIC (documented Android/MIUI case): conversion now routes on
ftyp magic-byte sniff of the raw buffer regardless of declared
ext/Content-Type, so a HEIF uploaded as image/jpeg converts instead of
415ing; the magic-mismatch 415 only fires for genuinely unrecognized bytes.
- Brand allowlist narrowed to what heic-decode's isHeic() accepts
(heim/heis/hevm/hevs dropped — they could only ever fail conversion).
- Converted-output size: the JPEG result is checked against
MAX_PASTE_IMAGE_BYTES (jpeg-js can inflate a within-limit HEIC past the cap).
- Deps: heic-convert replaced with its underlying heic-decode + jpeg-js
(the wrapper could not expose the pre-decode dimension check); lockfile
synced, drops pngjs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Pass the attachment request `source` through the server deps lambda and make
it a required param on SessionListenerDeps.registerAttachment + the wiring
event type (the 2-arg lambda silently dropped `source`, force-confining every
codex-generated artifact — the feature never worked outside the workspace);
new test/session-listener-wiring.test.ts asserts the pass-through
- Gate the Codex `Saved to: file://` scanner on mode === 'codex' via a
codexArtifacts option threaded from the session call site; magic links stay
mode-agnostic; tests assert claude/shell sessions never emit codex-generated
requests
- Decide the generated-artifact trust policy on the realpath-RESOLVED path
(unresolvable → force-confined) and anchor the ~/.codex marker dirs to
os.homedir() prefixes with startsWith instead of substring matching; symlink
escape + unanchored-marker regression tests added
- Run the Codex scanner on stripAnsi'd data so trailing SGR sequences don't
ride into the captured URL; styled 'Saved to:' test added
- Extend generateFirstPageThumbnail with jpg/jpeg/gif/webp passthrough and
per-extension content types (mirrors the png passthrough) so the PR's new
image formats render real thumbnails instead of 204 letter-tiles
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add test/routes/session-routes-codex-last-response.test.ts (app.inject +
temp CODEX_HOME fixture rollouts): originator match beats cwd fallback when
two panes share a dir, cwd fallback excludes sibling-claimed/foreign-cwd
rollouts, resume-uuid filename match, history.jsonl pin outranks originator,
event_msg/legacy user-turn dedup keeps old-codex turns, injected-context
filtering, image placeholder, envelope shape ({success:true,data:{text,
timestamp[,messages]}}), and a Claude-mode regression guard (codex reader
never consulted for claude sessions)
- Replace clear-at-cap Map caches (codexHistoryPinCache, codexRolloutMetaCache)
with the repo-standard LRUMap so a full cache wipe can't thrash hot entries
on large rollout collections
- Join multi-block assistant/user text with a blank line instead of no
separator (extractCodexBlockText)
- Re-enable the terminal-buffer eye fallback for shell sessions (they have no
transcript source at all); TUI modes keep the clear placeholder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 92vh on iOS Safari measures the large viewport; with browser chrome visible the
panel top (header + close button) clipped off-screen. 88vh fallback + 92dvh
matches the repo's established dvh idiom.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- saveAppSettings no longer sends webglRendererEnabled on the settings PUT:
the key is absent from the .strict() SettingsUpdateSchema, so every save
400'd with INVALID_INPUT, silently killing all server-side settings
persistence. Stripped in the per-device destructure alongside
localEchoEnabled/skin/etc.
- shouldSkipWebGL now treats a stored true like the untouched default w.r.t.
the sticky marker: the checkbox defaults checked on desktop, so any
unrelated save stored true and every page load then cleared the
'codeman-webgl-disabled' marker, permanently defeating the GPU-stall
auto-fallback. Only ?webgl=force clears the marker at init.
- The marker is instead retired on a real OFF->ON toggle flip detected at
save time (mirrors the _prevGestureEnabled pattern in settings-ui.js).
- webglRendererEnabled added to the displayKeys per-device set in
loadAppSettingsFromServer (renderer choice is device/GPU-specific; syncing
would leak mobile's hidden-checkbox false onto desktop).
- Tests: stored true + sticky marker -> still skips WebGL; OFF->ON save
clears the marker and keeps the key off the wire; default-checked save
leaves the marker alone; ?webgl=force / ?nowebgl behavior unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- BLOCKER (privacy): the CJK diagnostic trace logged typed CONTENT — _esc(e.key)
per keystroke, up to 24 chars of textarea value on focus/blur/compstart/
compend/input, and the flushed text — mirrored into _crashDiag, which
persists to localStorage and beacons to POST /api/crash-diag. Traces are now
content-free: key CLASS via _kdesc (any single code point → 'printable',
named keys pass through), value lengths + phantom presence via _vdesc
(len=N[+ph]), and 'flush send len=N'. _esc removed.
- MAJOR: the onData self-heal refocused the CJK field whenever gated data
arrived with focus elsewhere — but onData also fires for xterm's
SELF-GENERATED query replies (DA/DSR/CPR/OSC during Ink redraws), so it
stole focus from rename/search/settings inputs while output streamed. Now
requires document.activeElement === this.terminal.textarea (genuine typed
input) and bails when shouldSuppressTerminalQueryResponse(data) matches.
- MAJOR: the pointerdown blur→setTimeout(focus,0) wedged-IME recovery ran on
ALL platforms; on iOS tapping the focused empty field is normal and the
async refocus is outside the user-gesture stack. The listener is now only
registered when /Android/i.test(navigator.userAgent).
- tests: trace-privacy test (no typed character or textarea value ever appears
in the trace; lengths/key classes still recorded), iOS harness asserts the
pointerdown recovery never cycles, self-heal source guard asserts both new
conditions; vm harness gained a ua option (navigator injected, Android UA
default so the existing wedged-IME test still exercises the recovery).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Duplicate rows: transcript-history rows are keyed by the Claude
conversation UUID (.jsonl filename stem), which diverges from the Codeman
session id for resumed (claudeSessionId = resumeSessionId != id) and
/clear-respawned sessions, so one conversation surfaced as both a live row
and a history-only row. mergeUnifiedSessions now builds an alias map
(claudeSessionId -> Codeman id) from the live + persisted views and
resolves history/lifecycle keys through it; the route feeds
SessionState.resumeSessionId as the persisted alias.
- Inverted precedence: SessionLifecycleLog.query() returns entries
NEWEST-first, but the merge loop unconditionally overwrote name/mode so
the OLDEST entry in the window won (stale rename/mode). First-seen now
wins, mirroring the existing lastActivityAt guard.
- Tests: resumed session yields ONE row (service unit + route end-to-end
with a real transcript fixture); renamed-then-deleted session surfaces
the NEWEST name/mode. All 4 new tests fail against the pre-fix code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- _shouldForwardWheelToApp: claude sessions forward wheel to the TUI only
when the banner-parsed cliVersion is known AND >= 2.1.187 (older/unknown
Claude Code captures wheel as select-menu navigation → keep local
scrollLines); new dependency-free _cliVersionAtLeast semver-ish compare
- gemini excluded from wheel forwarding entirely (TUI wheel behavior
unverified); codex keeps forwarding (verified); taps/clicks still
forwarded for all strip modes
- link double-fire: registerFilePathLinkProvider links now track hover
state via ILink hover/leave callbacks (_linkHovered) and
_handleDesktopTerminalClick bails while a link is hovered, so a link
click no longer also sends a synthetic SGR press/release to the TUI
- help modal: document Shift+Wheel (scroll local history when mouse
passthrough is active)
- tests: version gate (2.1.186/unknown/garbage no forward, 2.1.187+
forwards), codex/gemini split, link-hover click suppression
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- UNBOUNDED-MEMORY: DEFAULT_TERMINAL_BUFFER_TRIM_BYTES from CODEMAN_TRIM_TERMINAL_TO
had no relation to DEFAULT_TERMINAL_BUFFER_MAX_BYTES — setting only
CODEMAN_MAX_TERMINAL_BUFFER=2097152 left the 24MB trim default in force, making
BufferAccumulator.trim() (slice(-trimSize)) a no-op: unbounded growth past the cap
plus a full string re-join on every append (O(n²)). Trim default is now clamped to
75% of the resolved max (the 24MB/32MB default ratio, preserved as hysteresis);
regression test re-evaluates the module under the env via vi.resetModules.
- OVERCLAIM: reverted DEFAULT_TERMINAL_SCROLLBACK_LINES 100k -> 50k — it has zero
consumers; browser xterm scrollback is the separate hardcoded DEFAULT_SCROLLBACK
(50k) in constants.js and deliberately stays 50k (mobile-memory hazard). The tmux
history-limit raise (50k -> 100k) and PTY 32MB/24MB raise remain (those are wired).
Module docstring now claims only what is wired; fixed the stale tmux-manager.ts
comment saying the tmux limit "matches the xterm-side default in constants.js".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release 1.2.1: fix iOS Safari local echo on keyboard-up tab switches
(selectSession now runs the keyboard-show heal so typed input paints at
the prompt instead of staying invisible/mispositioned until a manual
keyboard toggle).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Release 1.2.0: Gemini run mode, cross-session search, away digest, and
Ralph todo-config (PRs #133–#136), plus review fixes. Also refreshes CLAUDE.md
with the new-feature docs and several audit-verified drift corrections
(MockSession path, ultracode floating-window toggle, route counts, durable
input-delivery layer, mobile image-upload limits).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gemini (PR #134) blockers:
- runGemini() now unwraps the {success,data} envelope: status check reads
.data.available, quick-start reads data.data.sessionId (was reading the raw
shape, so the Run-Gemini button could never start a session).
- setGeminiEnvVars() now uses the socket-scoped ${this.tmux()} setenv instead of
bare tmux — Gemini/Google auth env vars were targeting the wrong tmux server
and silently failing on every install.
Gemini parity polish:
- gemini tab-mode badge ('gm') + .tab-mode.gemini CSS; kill-dialog label
'Kill Tmux & Gemini'; codeman doctor dependency-registry entry; export
isGeminiAvailable from utils barrel; COLORTERM=truecolor + unset NO_COLOR;
add gemini to isAltScreenStripMode (Ink TUI, repaints inline like Codex/Claude).
- Revert 4 system-routes.test.ts envelope assertions weakened to
(body.message ?? body.error) back to (body.success === false).
- Add a runGemini() vm-sandbox test that drives the envelope path end-to-end.
Ralph todo-config (PR #135): maxTodos/todoExpirationMinutes are now persisted
and read back — surfaced via the loopState getter (RalphTrackerState) into
toState()/SSE broadcast and restored in restoreState(), mirroring maxIterations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reliable-delivery layer marks every keystroke as briefly pending until its
ACK lands a few ms later, which made the connection indicator flash
"Sending 1B…" on every character during normal typing. Hide the indicator
entirely while the connection is healthy (connected/connecting) — it now only
appears for an actual problem (reconnecting/offline), where the queued-byte
count reassures the user their input is safely buffered.
Verified in a real browser: hidden throughout connected typing, shows
"Offline (NB queued)" when offline, hides again after reconnect+delivery.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mobile copy/paste overlay's "🖼 Image" button (and drag-drop / paste)
now handles real-world photo batches:
- Up to 20 images per batch, uploaded with bounded concurrency (3) and a
live "Uploading N/M…" progress toast; a final summary reports successes,
any failures, and whether the 20-cap trimmed the selection (no silent
truncation).
- Per-file upload limit raised 10MB → 50MB (MAX_PASTE_IMAGE_BYTES in
buffer-limits.ts, env-overridable) so full-resolution phone photos and
large screenshots aren't rejected.
- Very large images are downscaled to <=4096px longest edge before upload:
fixes iOS Safari's ~16.7M-px <canvas> limit (which made huge photos fail
to re-encode and fall back to an original that tripped the magic-byte
check), and keeps batch uploads fast and small.
- Fix a latent concurrency bug the batch path exposed: the first parallel
uploads to a session raced on `mkdir(.claude-images)` and the EEXIST
losers 500'd. mkdir now treats an existing real directory as success
(re-verifying it isn't a planted symlink), so concurrent uploads succeed.
Verified end-to-end in a real browser (Playwright): downscale, >10MB
server acceptance, 20-cap, 20/20 concurrent uploads landing on disk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A "sent" prompt could vanish with no trace on a flaky connection (e.g. a train):
with local echo on, Enter cleared the overlay then sent over the WebSocket
fire-and-forget. On a half-open socket (readyState===OPEN, dead TCP) ws.send()
doesn't throw, so the frame was silently discarded, nothing was enqueued, and
navigator.onLine stayed true — the prompt was lost and never resent.
Replace the best-effort offline queue with a durable, acknowledged delivery layer:
- Client (app.js): every input frame is recorded with a stable clientId +
monotonic per-session seq and persisted to localStorage BEFORE delivery, and
only dropped on a server ACK. Delivered over WS (acked via {t:'ia',seq}) or,
when the socket is down, POST in seq order (HTTP 2xx = ACK). A 2s sweep
force-reconnects a WS whose oldest frame is unacked past 4s (half-open sockets
never recover on their own); on reconnect/reload all pending frames re-deliver.
Survives reconnects AND page reloads. Connection indicator shows pending count.
- Server: Session.shouldApplyInput(clientId, seq) applies each frame exactly once
(bounded MRU map); ws-routes + POST /input dedup a redelivered seq but still ACK
it (200 / {t:'ia'}), so an at-least-once resend can never type the prompt twice.
Untagged input (curl/legacy) applies unconditionally — no behavior change.
- terminal-ui.js sendInput() (voice / keyboard-accessory / paste) now routes
through the same durable layer.
Tests: test/reliable-input-dedup.test.ts (exactly-once semantics on the real
Session) + POST /input dedup route tests. Design: docs/reliable-input-delivery.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pinch any floating subagent or ultracode run/transcript window with the
camera hand-tracking overlay and move it anywhere. Adds a 'window' grab
kind to entry.ts, slotted into the pinch priority chain
(cg-float panel → agent window → session tab → toolbar button). It moves
the window via its own style.left/top (matching app.js's mouse drag,
incl. bottom:'auto') and calls window.app.updateConnectionLines() so the
glowing connector line to the session tab tracks live — app.js redraws
from fresh rects, so no reach into its internals.
Hardening: el.isConnected guard (ultracode windows tear down mid-grab on
SSE reconnect / auto-close), all window.app calls optional-chained +
try/caught so the standalone playground still works, bring-to-front via
app.js's own z-counters, rAF-coalesced redraws cleared on drop so the
final placement always redraws.
Rebuilt the committed gesture-codeman.js bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends PR #132 (ultracode handlers) to the rest of the frontend. The same
JS-string-in-HTML-attribute pattern — '${escapeHtml(value)}' — remained in 32
more inline handlers across app.js, panels-ui.js, session-ui.js,
subagent-windows.js, and notification-manager.js. The browser HTML-decodes the
attribute value before parsing the handler source, so escapeHtml's ' reverts
to ' and a quote-bearing id/path/name breaks out of the JS string literal into
executable code.
Switch all to escapeHtml(JSON.stringify(value)): JSON.stringify JS-encodes and
quote-wraps first, then escapeHtml handles the HTML-attribute layer, so the
value round-trips as one inert string argument.
Also fixes two non-escapeHtml variants of the same class:
- panels-ui.js: mux-session `sid` was pre-escaped with escapeHtml() then dropped
into a single-quoted JS string (selectSession / killMuxSession). Now
JSON.stringify'd at the source.
- orchestrator-panel.js: phase.id was interpolated raw (no escaping at all) into
orchestratorSkipPhase / orchestratorRetryPhase. Now escapeHtml(JSON.stringify()).
The most realistic vector here is file paths (panels-ui openLogViewerWindow) —
filenames can legally contain a single quote.
Numeric interpolations (${i+1}, ${index}, ${item.version}) and the
developer-literal ${onclick} in orchestrator-panel are not user data and are
left as-is. Verified: 0 vulnerable patterns remain, all 22 frontend files parse
(check:frontend-syntax + node --check), and a runtime round-trip confirms the
injection that fired under the old pattern is now an inert string argument.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking an agent card opens its live transcript as an in-page connected
floating window instead of a detached browser popup. The "−" button on both
run and agent windows now minimizes into the originating session tab as a
restorable ULTRA badge (🧬 runs, 📄 transcripts). Removes the old
collapse-to-header behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The touchmove handler accumulated pixelAccum/velocity and could scrollLines
on every move — including micro-drift below the 8px tap threshold. A jittery
tap (<8px) stayed classified as a tap (didScroll=false, so tap-to-position
fired) yet still left a non-zero velocity, which touchend turned into a
momentum fling. Result: one tap both positioned the cursor and scrolled.
Gate the scroll/velocity accumulation behind didScroll so sub-threshold
movement is inert, matching the handler's stated tap-vs-scroll intent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Daylight Blue: Cloudflare Tunnel welcome button is now purple (was orange),
keeping Claude blue / Tunnel purple / OpenCode green distinct.
- Allow enabling the Cloudflare tunnel with no CODEMAN_PASSWORD via the UI: the
toggle now pops a security confirm dialog and, on confirm, sends an explicit
per-request acknowledgeUnauthTunnel:true (new action field, never persisted).
Server logs a loud warning whenever a passwordless public tunnel starts.
curl/API/CLI stay refused unless password/env/flag — no accidental exposure.
Tests: extend test/routes/system-routes-tunnel-guard.test.ts (ack allows + not
persisted; ack:false still refuses). Verified e2e on an isolated instance
(purple button, confirm dialog, retry carries the flag, no real tunnel opened).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the default daylight-blue skin the three welcome buttons all read blue.
Give each its own identity: Run Claude Code keeps the blue accent, Cloudflare
Tunnel takes Cloudflare brand orange, Run OpenCode takes emerald green (with
matching hover/active states + dark ink for contrast). Scoped to daylight-blue
only; daylight-green and OG unchanged. Verified in-browser (blue/orange/green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Terminal scroll-up intermittently broke for Claude sessions (most visible on
iPhone). Claude Code periodically emits alt-screen switches (?1049h/?47h/?1047h),
scrollback-erase (3J), and mouse-tracking enables for full-screen UIs, which move
xterm.js to the scrollback-less alt buffer / wipe saved lines / hijack the wheel.
Codeman stripped these but only for codex mode.
Share the strip via isAltScreenStripMode(mode) = codex || claude, applied at both
sites that were codex-only: the live PTY stream (Session._handleTerminalOutput,
incl. the chunk-boundary carry) and the /terminal buffer replay. shell stays
excluded (vim/less/htop need the alt screen); opencode unchanged.
Tests: test/claude-scrollback-strip.test.ts (8 new); codex strip tests unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-run syncAllUltracodeFloatingWindows() after server settings load so a
first-time device whose getLightState run snapshot arrives before the async
settings fetch resolves still pops an already-active run's window immediately,
instead of waiting for the next ~10s watcher tick. Also fixes a stale
@fileoverview comment that named the wrong gating setting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
closeUltracodeAgentsPanel() only removed `open`, leaving the drawer in its
collapsed peek state (header strip still visible) — so (x) looked like a no-op.
Now also adds `hidden` (display:none), mirroring closeSubagentsPanel; does NOT
flip showUltracodeAgents (that gates the watcher + floating windows). Verified in
a real browser (post-close computed display:none). Bumps 1.1.4 -> 1.1.5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Workflow runtime writes workflows/wf_<id>.json only at completion (always
terminal), so workflow-run-watcher never saw a run until it was already done and
the ACTIVE-gated floating window never popped. The watcher now also scans
subagents/workflows/wf_<id>/ and synthesizes a minimal running record (agentId
slots preserved for the transcript-click join, lastActivityAt from mtimes,
done/running from the journal), superseded by the real wf_<id>.json at
completion. Standalone (no subagent-watcher import). Verified e2e on a real
in-flight run; +6 unit tests. Bumps 1.1.3 -> 1.1.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auto-popping draggable window per active ultracode/Workflow run, connected by a
glowing line to its originating session tab (resolved via claudeSessionId ===
sessionUuid). Mirrors the live agent grid; auto-closes after a run finishes;
dismissals are remembered. Additional to the existing docked panel.
New "Ultracode Floating Windows" setting (default OFF), independent of the
"Ultracode Agents" panel toggle; either toggle starts the workflow-run watcher.
Also bumps version to 1.1.3 and brings CLAUDE.md up to date for the ultracode
subsystem.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each workflow agent card with an agentId is now clickable and opens that agent's
live transcript in a popup, reusing the existing GET /api/subagents/:agentId/
transcript route. The workflow agent's agentId is byte-identical to the
agent-<id>.jsonl stem that subagent-watcher already tracks (via w16's
watchWorkflowDirs), so this is a pure client-side join — ZERO subagent-watcher
edits.
Graceful degradation: 'start' (queued) agents have no agentId yet and stay
non-clickable; an aged-out/untracked agent (subagent-watcher's 4h startup window,
or tracking disabled) returns an empty transcript and shows a friendly note
instead of an empty popup.
Verified on a live isolated server: the subagent transcript route serves a
workflow agent's transcript (150 entries) and the runId's agents[] carries the
matching agentId; Playwright confirmed clicking a card opens the transcript popup
with no console errors. frontend-syntax / public-assets / CSS-parse clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opt-in (showUltracodeAgents, default OFF) panel that visualizes ultracode /
Workflow-tool runs like Claude Code's "working agents" TUI: LEFT = runs + phases
(selectable tasks), RIGHT = each run's agents with model, live state, tokens
burned, and tool calls.
Standalone — ZERO edits to subagent-watcher.ts. A new workflow-run-watcher.ts
singleton globs the run-state tree (~/.claude/projects/*/*/workflows/wf_*.json,
disjoint from the transcript tree), strips the heavy script/scriptPath/result/logs
fields (174KB -> ~25KB/run), and emits workflow:run_* SSE events. The LEFT list
ships lightweight summaries (getLightState replay + SSE); the RIGHT pane fetches
the full run (with agents[]) via GET /api/workflows/:runId on selection.
Backend: workflow-run-watcher.ts, types/workflow-run.ts, config/workflow-config.ts,
3 SSE events, getLightState workflowRuns replay, GET /api/workflows[/:runId],
showUltracodeAgents schema key + boot-gate (default OFF) + live toggleService.
Frontend: ultracode-panel.js (debounced master-detail render, run/phase select),
header launcher (btn-ultracode-agents--hidden marker -> mobile-guard-exempt),
App Settings toggle (SYNCED, deliberately not in displayKeys).
Agent states on disk are start|progress|done (start=queued; done has
durationMs/resultPreview). Tests: workflow-run-watcher (9), workflow-routes (3).
Verified: tsc/lint/prettier/frontend-syntax/public-assets/mobile-header-guard
clean; full test:ci green (2986 passed); live server + Playwright e2e against 25
real runs (28-agent grid, phase filter, OFF hides launcher).
Design: docs/ultracode-agent-viz-plan.md (rev. 3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups to db93491 (the 2026-06 CC meta.json format change), after
reverse-engineering the new on-disk layout with a live current-CC subagent +
1Hz fs poller:
(1) Workflow recursion — the Workflow tool nests its agents at
subagents/workflows/{wf}/agent-{id}.jsonl, one level below the flat
subagents/ scan, so they were never tracked. Add watchWorkflowDirs()
(driven from scanForSubagents) to descend and watch each workflow dir
(idempotent; fs.watch recursive is unsupported on Linux, so the ~5s
periodic scan re-drives it — same latency as new-session discovery).
Require the `agent-` prefix in the flat readdir + watch callback so a
workflow dir's sibling journal.jsonl can't register a bogus "journal" agent.
E2E verified against real ~/.claude/projects: 32 workflow-nested agents
discovered (wf_fa35c1d8-4a9), 0 bogus journal agents.
(2) Transcript timing — empirically the per-agent .jsonl IS written at the
standard subagents/ path and grows incrementally (tailable); the
/tmp/.../tasks/<id>.output the prior probe found is just a symlink back to
it. meta.json lands at spawn, the .jsonl a beat later. Add a meta→transcript
upgrade in registerAgentFile: when an agent registered meta-only gets its
sibling .jsonl, re-point filePath, drop the stale sidecar context, start
tailing, and emit subagent:updated (not a duplicate discovered). Corrects the
now-inaccurate "no transcript to tail" doc comment on registerAgentMeta.
Tests: 2 new cases (workflow-nested discovery; journal.jsonl not registered).
All 56 pass; tsc/lint/format clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Code changed its subagent on-disk format (~2026-06-14): TUI Task
subagents now write `agent-{id}.meta.json` ({agentType,description,toolUseId})
into the session's `subagents/` dir and no longer reliably write a per-agent
`agent-{id}.jsonl` transcript there. The watcher discovered agents ONLY by
`.jsonl`, so it tracked zero — subagent windows and the monitor's "N TRACKED"
showed nothing.
- Add `registerAgentMeta()`: discover from the meta sidecar (description from
meta.description/agentType), prefer a sibling `.jsonl` transcript when present
(richer), never tail a meta file.
- Initial scan + directory watcher now handle `.meta.json` alongside `.jsonl`.
- Tests: 2 new cases (meta-only discovery; prefer-.jsonl-when-present).
Verified e2e against a real ~/.claude/projects fixture.
Known follow-ups (not in scope): meta-only agents have no per-agent transcript
to tail (no live tool-call feed, status stays 'active'); workflow agents under
`subagents/workflows/{wf}/agent-*.jsonl` are still missed by the flat scan.
Also adds the README screenshot tooling used to surface this:
- capture-real-overview.mjs: DSF=2 + ?nowebgl crisp path (DOM renderer avoids
the WebGL glyph-doubling at deviceScaleFactor>1).
- capture-readme-real.mjs: real-instance desktop-scene capture (dashboard/
monitor/subagent) for an isolated beta seeded from prod settings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The File Browser preview and Attachments preview share openFilePreview(),
but the workspace branch (via /file-content) misclassified several types the
attachments viewer handled fine:
- SVG was reported as type:image, but file-raw serves SVG as octet-stream +
attachment (XSS hardening), so the <img> broke. Now fetched and rendered via
a same-origin image/svg+xml blob <img> (safe; <img> never runs SVG scripts).
file-raw's SVG hardening is unchanged.
- Audio (mp3/wav/ogg/m4a/aac/flac/opus) was type:binary -> "Cannot preview".
Now classified as audio and rendered with <audio controls>; file-raw gained
the matching audio/video MIME types so playback works.
- Binary formats not in the hardcoded list (xlsx/doc/zip/...) were decoded as
UTF-8 and dumped as mojibake. Replaced the static list with a NUL-byte
content sniff that flags arbitrary binaries; the binary fallback now offers a
Download link instead of dead-ending.
Adds route tests for audio, known-binary (xlsx), and NUL-sniff classification.
Verified end-to-end on an isolated instance + headless browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/capture-real-overview.mjs:
- Default deviceScaleFactor to 1 (DSF=2 makes xterm's headless WebGL renderer
draw console glyphs at ~2x while reporting nominal cell dims — invisible to
cols/cell measurement, only the pixels reveal it; HTML chrome is unaffected so
only the terminal font looks oversized)
- Mint a unique timestamped filename per run so a viewer/HTTP cache can't shadow
a fresh capture with a stale render of a fixed path
- Seed per-device localStorage (skin, codeman-font-size, codeman-app-settings)
so the capture reflects a real device: plan-usage chip shown (per-device key,
deleted from server payload), side panels closed for a full-width terminal
- Support prod's self-signed HTTPS (ignoreHTTPSErrors), env-configurable viewport
CLAUDE.md:
- Document the DSF=1 / unique-filename screenshot gotcha (incl. the real
Codeman-side immutable-static-asset cache footgun)
- Add the sanitize-html.js infra module (DOMPurify mXSS allowlist, COD-56) to the
frontend module list and load order (was missing)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review polish on the desktop tab auto-wrap:
- Auto-wrap is purely width-driven, but updateTabOverflowMode() was only called at the
tail of _renderSessionTabsImmediate (SSE content renders). Window resize — the primary
trigger for tabs crossing the one-row overflow threshold — never re-evaluated it, so
narrowing/widening the window left the wrap state stale until an unrelated status event
fired a render. Call it from the debounced window-resize handler (no-op on
mobile/tablet, where the method bails).
- Move the re-evaluation into _fullRenderSessionTabs() as well, so the incremental
branch's two early `_fullRenderSessionTabs(); return;` paths (badge add/remove, which
change tab width) and the manual two-rows toggle (applyTabWrapSettings → _fullRender…)
re-evaluate too. The latter also fixes a transient where enabling manual two-rows while
auto-wrap was on left both classes set (clipping folder tabs to 96px) until the next
render.
- Add boundary cases to the policy test: exact fit and the +1 sub-pixel tolerance (no
wrap), 2px over (wrap), and a single overflowing tab (no wrap).
Verified: tab-overflow test passes; tsc, check:frontend-syntax, check:public-assets,
prettier all clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Making the hook-event secret unconditionally required closes the own-loopback-proxy gap,
but it would also silently 401 the hook curls baked into cases created BEFORE the secret
header existed (COD-54, 2026-06-10): writeHooksConfig only runs at case CREATION, so an
existing/linked case on a password-protected install keeps secret-less curls that the new
gate rejects (degrading idle/stop/teammate/task signalling with no error surfaced).
No-password installs are unaffected — the gate isn't registered without CODEMAN_PASSWORD.
Add `refreshStaleHookSecret(casePath)` and call it on Claude-mode spawns in
POST /api/sessions and POST /api/quick-start (existing-case branch). It regenerates the
hooks block ONLY when settings.local.json already holds Codeman's own hook curls (they
target /api/hook-event) that lack the X-Codeman-Hook-Secret header — a no-op when the
hooks are absent, not ours, or already current, so it never clobbers user customizations
and is cheap on every spawn. Fresh cases are unaffected (writeHooksConfig already wrote
the secret). withSettingsLock serializes it with the model/statusLine writers.
Verified: new test/hook-secret-selfheal.test.ts 5/5 (heal + key-preservation + no-op on
current/foreign/absent/malformed); the PR's cod54 + auth-security suites still pass
(36); tsc, lint, format:check, and npm run build all clean (symbol present in dist).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PR gates CJK textarea visibility on an active session
(`showCjk = cjkUserEnabled && !!activeSessionId`) so the fixed-position textarea no
longer floats over the welcome overlay. That intentionally changes the behavior the
existing `shows the CJK textarea on mobile only for server override` test asserted —
it set `_serverCjkOverride = true` on a fresh page (no active session) and expected the
textarea visible, which now (correctly) resolves to hidden. The test lives in
test/mobile/** (excluded from CI), so it wasn't caught by the PR's green CI.
Update the test to verify the new, intended behavior: with the server override on it
stays hidden on the welcome screen (no active session) and is revealed once a session
is active. This is a co-authored review fix; the original change is TeigenZhang's.
Verified: tsc, check:frontend-syntax, check:public-assets, prettier all clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PR migrated the app.js tab-nav handler to physical e.code but left xterm's
pass-through gate (terminal-ui.js) matching ev.key digits. Consequences:
- Alt+[ / Alt+] (the new bindings) were never in the gate, so xterm sent ESC[ / ESC]
to the PTY on every platform AS WELL AS switching the session.
- Alt+digit on a remapped macOS Option layout (Option+1 -> "¡") didn't match the
ev.key '0'-'9' gate either, so xterm injected ESC<char> — on exactly the layouts
this PR exists to fix.
Update the xterm gate to mirror app.js exactly: suppress when
`ev.altKey && !ctrl && !shift && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code)`.
Returning false there tells xterm not to write to the PTY, so the shortcut switches
the tab with no stray escape sequence.
Also: relabel the docs Alt/Option (the mechanism is layout/OS-independent, so the
shortcut works for Linux/Windows Alt users too — "Option" alone was Mac-only wording),
and add a keyboard-shortcuts test asserting terminal-ui.js gates on the same physical
codes so this desync can't regress (a grep the original test missed).
Verified: keyboard-shortcuts test 4/4, check:frontend-syntax, check:public-assets,
format:check all clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review fixes on top of the `codeman doctor` checker:
- Node minVersion 18.0.0 -> 22.0.0. package.json engines is ">=22.0.0" and the docs/CI
require Node 22+, so doctor was green-lighting Node 18-21 (a false pass).
- Remove the phantom `gemini` registry entry. Codeman has no Gemini backend
(SessionMode = 'claude' | 'shell' | 'opencode' | 'codex'); the entry advertised a
dependency that nothing uses.
- Add `pdftoppm` (poppler) to the office group. document-thumbnailer.ts calls pdftoppm
with no fallback as the sole PDF/Office first-page thumbnail renderer, yet it was
absent from the registry, so doctor never reported it missing.
- Fix the `--category` mismatch: the help advertised `documents|media` categories that
the ToolCategory type/registry never defined, and an unknown category silently
produced an empty "all healthy" table. Introduce TOOL_CATEGORIES as the single source
of truth (type + help + validation); an invalid `--category` now errors with the
valid list and exits 2.
Verified: tsc, lint, format:check all clean; both dependency tests pass (20);
`doctor` runs correctly (Node 22.22 ok, pdftoppm detected, no gemini), `--category media`
errors with exit 2, `--category office` lists libreoffice/pdftoppm/msoffice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review fixes on top of the DOMPurify mXSS hardening:
- Remove `USE_PROFILES: { html: true }` from the sanitize-html.js config. DOMPurify
treats USE_PROFILES and ALLOWED_TAGS/ALLOWED_ATTR as mutually exclusive — with a
profile set it resets the allow-lists to the full HTML profile and silently ignores
the curated lists, so the tight markdown-only allowlist was dead config (still
XSS-safe via FORBID + core, but far broader than intended: <button>/<input>/
<details>/<audio>/<select>/<label> all survived). Dropping USE_PROFILES puts the
curated ALLOWED_TAGS/ALLOWED_ATTR back in force; FORBID_TAGS/FORBID_ATTR stay as
defense-in-depth and DOMPurify keeps its default safe-URI handling.
- Rewrite test/markdown-sanitizer.test.ts to run in the default node environment with
an in-test jsdom window instead of a per-file jsdom environment. That environment
externalizes node:fs/node:path under vite, so the suite failed to load in isolation
("No such built-in module: node:") and only survived the full CI run because an
earlier node-env test happened to pre-cache node:fs — order-dependent and fragile.
The rewrite is order-robust and adds an "allowlist is actually enforced" block
(non-markdown tags must be dropped) that fails if USE_PROFILES is reintroduced.
Verified: 25/25 tests pass standalone under config/vitest.ci.config.ts; tsc, lint,
format:check, check:frontend-syntax, check:public-assets, and npm run build all clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Release 1.1.0. Headline: opt-in Plan Usage Limits chip (per-device live
5h/weekly plan %), attachment history drawer + opt-in Attachments button,
Opus 4.6 model options, and mobile header regression guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The COD-39 attachments button was hard-visible in the header — first on
mobile, then (after the mobile-only hide) still on desktop. Make it a
proper opt-in App Settings → Display toggle ("Attachments Button"),
default OFF everywhere, mirroring the Response Viewer button:
- index.html: button ships with the `btn-attachments-history--hidden`
marker; new settings checkbox #appSettingsShowAttachmentsButton.
- styles.css: base `display:inline-flex !important` + a more-specific
`--hidden` rule (same pattern as the response viewer).
- settings-ui.js: load/save/getDefaultSettings(false) + a live toggle in
applyHeaderVisibilitySettings. Per-device and NON-leaking — added to
displayKeys AND stripped from the server payload, so enabling it on
desktop never makes it appear on mobile (or any other device). No
server-side render step (purely client display, like the eye button).
- mobile.css: dropped the now-redundant phone-only hide — the opt-in
marker hides it everywhere by default; the per-device toggle governs
both desktop and phone.
Tests updated: the CI static guard drops btn-attachments-history from the
phone-hidden lock (it's opt-in now, excluded from the default-visible
enumeration — the guard still gates any NEW default-visible button); the
real-browser E2E now asserts default-hidden on a desktop-class viewport
and visible after enabling the setting.
Verified on a real desktop browser: hidden by default, the settings
toggle exists, enabling it shows the button. tsc + frontend-syntax +
prettier + public-asset checks + both test suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the three PRs that grew attachments since the last update:
COD-37/#119 (registry + magic links) was already covered, but
COD-38/#120 (document previews/thumbnails) and COD-39/#121 (history
drawer) added four source files and several endpoints that weren't
documented. Split a dedicated Attachments row out of Infra, extend the
Attachments Key Pattern to cover the converter pipeline + concurrency
limiter + history drawer, and refresh the files-route handler count
(8 -> 14) and total (~140 -> ~146). Also carries the prior pending
app.js line-count (3.7K -> 3.9K) and config-file-count (10 -> 12) bumps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The skin picker inherited .form-select's 0.8rem font + 0.5rem vertical
padding, rendering bigger and taller than the settings row it sits in
(0.75rem / 0.45rem). The daylight skins' Manrope font exaggerated it,
so "Daylight Blue" looked oversized and the field too thick. Scope a
0.75rem font + 0.3rem vertical padding to .settings-item-skin .form-select
so the field text matches the row label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The COD-39 attachment-history header button was visible on the cramped
phone header. Hide it on phones alongside the settings gear and lifecycle
log (the mobile header is intentionally minimal — those controls live in
the toolbar). One-line addition to the existing @media (max-width: 430px)
display:none block in mobile.css.
This is the second time a header control leaked onto mobile (the
plan-usage chip was the first), so add two regression guards:
- test/mobile-header-buttons-policy.test.ts — a pure static analysis of
index.html + mobile.css (no browser), so it runs in the normal CI sweep
(the test/mobile/** Playwright suite is EXCLUDED from CI and never gated
this). It enumerates every default-visible header button and fails when
one has no phone-visibility decision — either a mobile.css hide rule or
an explicit MOBILE_VISIBLE_ALLOWLIST entry. A new header button now
forces that decision. Verified it fails on the pre-fix state and passes
after.
- test/mobile/header-buttons.test.ts — real-browser E2E in the mobile
suite: asserts the attachments/settings/lifecycle buttons are hidden on
an emulated iPhone 14 Pro and the attachments button is visible on a
desktop-class tablet.
tsc + lint + prettier + both new tests green. Only CSS + tests changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plan-usage header chip (5h/7d %) was a SYNCED setting, so enabling
it on desktop turned it on for mobile too — even though the user never
enabled it there. Make the chip's DISPLAY purely per-device (default
OFF) like the response viewer / skin, while keeping telemetry COLLECTION
server-side.
Three leak sources fixed:
- server.ts renderIndexHtml force-revealed the chip from the synced
value (pre-paint), pushing the desktop choice onto every device.
Removed — the chip now ships hidden and the client reveals it
per-device via applyHeaderVisibilitySettings.
- settings-ui.js load-merge let the server value win, writing desktop's
`true` into the (separate) mobile settings blob. showPlanUsageLimits
is now a displayKey AND is dropped from the server payload on load, so
a stale server value is never seeded into a device that didn't enable
it. It's also stripped from the save payload so a mobile "off" can't
clobber the server.
- Collection was gated on the same synced flag. Decoupled via a new
`statusLineTelemetry` ACTION field (schema + system-routes): sent on
ENABLE only and never persisted, so the exporter is injected when a
device turns the chip on but is never yanked when another device has
it off (it's shared across sibling sessions). Session-create already
reads the per-device blob, so that path was already correct.
One-time migration clears a stale synced `true` from the mobile blob so
existing mobile installs default to OFF without a manual toggle.
Verified end-to-end on an isolated server: with showPlanUsageLimits=true
persisted, the rendered HTML ships the chip hidden; a fresh browser
context (mobile case) keeps it hidden while a context that explicitly
enabled it shows it; the PUT accepts statusLineTelemetry and does not
persist it. tsc + frontend-syntax + system-routes/index tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removing the dead `mobile-collapsed` tray (this PR) means the test that
asserted the headerRight tray *stays collapsed* on mobile now contradicts
the code and would fail when run. Flip it: with the three-dot utility
toggle gone, the header-right utilities must flow inline and stay
reachable on small viewports. The response-viewer eye itself remains
hidden by default (showResponseViewer opt-in), so this only re-exposes
the already-default-visible utilities inline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-session attachment history with a slide-in drawer, unread badge, and
re-show. Rebased onto master (stacked on #120) + review hardening (malformed-
history recovery guard, resilient list route, badge positioning, debounce
cancel, stable re-show, Escape-to-close, CSS token fixes). See PR #121.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up fixes applied during review of PR #121 (all confirmed minor/nit;
no blockers). Security posture verified sound (externalPath never leaves
toState()/the list route; re-registration runs the guard).
- fix(recovery): restoreAttachmentHistory now skips malformed/legacy saved
items (null, non-object, missing source/fileName) instead of throwing inside
the Session constructor — a corrupt __attachmentHistory entry could otherwise
abort the entire mux-recovery loop. (P1)
- fix(routes): the attachment-list route degrades a single failing entry to
{missing:true} instead of failing the whole drawer. (INT-4)
- fix(ui): give the attachments header button a positioning context so the
unread badge anchors to the icon, not the header bar. (F1/CSS-1)
- fix(ui): cancel the debounced history refresh on drawer close and guard it
against a stale session/closed drawer. (F3)
- fix(ui): re-show ("Card") of a detected item now uses the item's own
timestamp so the cardId is stable — focuses the existing card instead of
stacking duplicates. (F4)
- fix(ui): Escape now closes the drawer, matching every other panel. (UX-1)
- fix(ui): badge shows "99+" past 99 (was an inconsistent 100/99 cap). (BADGE-1)
- style: drop the duplicate @keyframes notif-badge-pulse (dead CSS). (INT-1/CSS-3)
- style: empty-state used three undefined CSS custom properties
(--text-primary/--border-color/--bg-tertiary) → use the defined
--text/--border-light/--bg-input tokens. (CSS-2)
- test: add constructor restore round-trip + malformed-item resilience tests.
Deferred (noted for author): broadcasting the full 100-item history in every
session-state SSE event (payload bloat), "unread" badge semantics, making the
header button opt-in, and app.inject route tests for the two new endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up hardening applied during review of PR #120, addressing the
adversarial multi-agent findings:
- fix(preview): render auto-detected (workspace, unregistered) DOCX/PPTX via
the file-preview route and PDFs via file-raw in openFilePreview. Previously
the Preview button fell through to file-content, dumping the binary Office/PDF
bytes as mojibake, and the new file-preview route was unreachable dead code.
(MAJOR: file-preview-route-unreachable-detected-office)
- perf(convert): add a global converter-concurrency limiter
(document-conversion-limiter.ts) wrapping every pdftoppm / soffice /
powershell spawn, so N simultaneous preview/thumbnail requests can no longer
fork unbounded converter processes. Default cap 3, CODEMAN_MAX_DOCUMENT_CONVERSIONS.
(MAJOR: no-converter-concurrency-limit)
- fix(cache): bound the converted-PDF disk cache with LRU-by-mtime eviction
(pruneDocumentPreviewCache, default 100 files, CODEMAN_MAX_PREVIEW_CACHE_FILES),
run after each successful conversion. Was unbounded.
(MAJOR/MINOR: preview-cache-unbounded-disk-growth)
Tests: document-conversion-limiter.test.ts, document-preview-cache-eviction.test.ts,
and route coverage for the four new endpoints in
routes/file-routes-preview-thumbnail.test.ts (closes the missing-route-test gap).
Verified end-to-end against real pdftoppm (thumbnail render + concurrency cap).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>