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>
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>
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).
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>
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>
Remove the dead mobile-collapsed header tray that hid the entire header-right cluster (incl. the opt-in response-viewer eye) on phones/tablets, and update the mobile test to assert inline reachability. Eye stays hidden by default (showResponseViewer).
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>
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).
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>
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.
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>
Review of the plan-usage chip feature (commits since 1.0.0) surfaced several
issues; this fixes all confirmed findings:
- HIGH: applyStatusLineConfig clobbered a user's hand-authored statusLine on
the enable path (the isOurs guard only protected disable). Now bails out when
an existing statusLine isn't ours, on both the enable and disable paths.
- MED: StatusTelemetrySchema used z.optional() (rejects null) on Claude's
undocumented statusline fields — a single stray null 400'd the entire POST and
silently killed the chip's data feed. Switched the modeled fields to .nullish().
- MED: dropping the Token Count / Show Cost header toggles left their features
reading settings.showTokenCount/showCost, but saveAppSettings rebuilds settings
fresh from the DOM, dropping those keys and resetting them to defaults on every
save (re-enabling the token chip with no UI to turn it off). Preserve the prior
stored preference.
- telemetrySignature keyed on contextUsedPercentage (never displayed) and the raw
unrounded %, churning a redundant SSE broadcast + localStorage write + identical
chip re-render on every assistant message. Now keys on the rounded displayed
window values only.
- Plan-usage chip flashed hidden on load (no server-side reveal): renderIndexHtml
now strips header-plan-usage--hidden when enabled, matching btn-multimonitor;
fixes the FOUC and makes the "server renders initial state" comments accurate.
- Serialize all settings.local.json read-modify-write writers in hooks-config via
a shared per-path mutex (previously lock-free; concurrent session-create +
settings-toggle on the same repo could lose writes).
- Hardened the chip's innerHTML against any future string field; removed the dead
_latestPlanUsage field; clamped ctx% in the footer formatter; corrected the
session-create comment (the path is add-only by design — a per-repo settings
file is shared by sibling sessions).
- Tests: new test/routes/status-telemetry-routes.test.ts (route behavior, dedup,
null-tolerance) + NaN/Infinity/fractional and signature-churn unit tests; made
server-index-title.test.ts deterministic against the ambient settings.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end testing on the real install surfaced several issues the unit
tests missed:
- Injection gate excluded real sessions: gated on workingDir under CASES_DIR,
but sessions run in linked cases / real repos. Drop the gate (match
updateCaseModel, which writes settings.local.json unconditionally).
- statusLine curl failed on HTTPS: prod is loopback HTTPS with a self-signed
cert; `curl -s` returns 000. Use `curl -sk` (loopback only). applyStatusLineConfig
now also updates an out-of-date ours-command so the fix propagates.
- Footer hijacked by limits: the in-terminal statusline now shows CURRENT
SESSION status — `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` —
while the account-wide plan limits live only in the header chip.
- Chip blank after reload: persist last-known to localStorage and restore on
load (account-global, slow-moving; 12h freshness guard).
- Readability + color: per-window green/yellow/red by usage (<60 / 60–84 / ≥85),
bolder labels and values.
- Drop the renderIndexHtml strip (client-side reveal only, response-viewer
pattern) — fixes server-index-title test fragility to local settings.
Footer fields flow through context_window.total_input_tokens/total_output_tokens
(schema + parser). Tests updated; verified live (footer, chip, colors, reload
persistence) on the real install.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface Claude subscription plan usage limits (5-hour rolling + 7-day
weekly: percent used + reset time) in the header, opt-in via App Settings
→ Display → "Plan Usage Limits" (default OFF, no behavior change when off).
A Codeman-managed Claude statusLine exporter forwards the rate_limits JSON
to a new auth-exempt POST /api/status-telemetry (same loopback + hook-secret
gate as /api/hook-event); parsed telemetry broadcasts over SSE
session:statusTelemetry to a header chip (amber >=80%, red >=95%, reset
times on hover). The exporter prints the same summary back as the
in-terminal footer (print-through).
- src/usage-telemetry.ts: pure parser/formatter (epoch-sec -> ms, clamp,
change signature) + test/usage-telemetry.test.ts
- hooks-config.ts: generateStatusLineCommand + applyStatusLineConfig
(add/remove; never clobbers a user's own statusLine)
- session-routes.ts: inject gate (Claude-only, Codeman-managed cases),
driven by create-payload statusLineTelemetry (session-ui.js)
- schemas.ts: StatusTelemetrySchema + showPlanUsageLimits + payload field
- frontend: header chip, applyHeaderVisibilitySettings toggle,
renderIndexHtml strip, _onSessionStatusTelemetry handler
Schema empirically confirmed against Claude Code 2.1.177 (Claude Max):
only five_hour/seven_day windows exist (no Opus-weekly field); rate_limits
is absent before the first API response and for non-subscriber auth. Design
+ verification method in docs/usage-limits-display-plan.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Security (MAJOR): the terminal-output codeman://attach scanner registered any
matching path server-side with no user confirmation and broadcast the rawUrl
over SSE. Terminal output is attacker-influenceable (a prompt-injected session
can print an arbitrary path), so on the default no-auth deployment this was an
arbitrary host-file (png/pdf/docx/pptx/md/txt) read primitive reachable by any
SSE client. Magic-link registration is now force-confined to the session
workspace (forceWorkspaceConfinement) regardless of the global confine setting;
deliberate cross-workspace attach still works through the explicit,
Origin-guarded POST /attachments route and 'codeman attach' (which POSTs
directly inside a managed session). Documented in security-architecture.md.
Regression (MAJOR): .png was rerouted from the image-popup path to
attachment:detected, which has no frontend consumer — silently breaking the
dropped/pasted-screenshot popup. PNG stays on image:detected; only pdf/docx/pptx
(which never had a popup) emit attachment:detected.
Also:
- raw route streams the freshly-resolved path, not the stored one, so a
post-registration symlink swap can't redirect the stream (TOCTOU).
- 50MB cap on the attachment raw route, matching file-raw / download.
- per-session attachment registry cap (200) to bound the POST path.
- CLI reads creds via dataPath('.env'), honoring CODEMAN_INSTANCE.
Tests: forced-confinement reject/allow cases; PNG popup-path assertions updated.
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).
- Skip snapshot save for shell sessions (restore is gated on mode!=='shell',
so they only burned a serialize() + cache slot + localStorage quota).
- In-memory cache: delete-before-set so eviction is true LRU, not FIFO that
could drop the most-recently-used session.
- localStorage: extract _persistXtermSnapshot — evict to a fixed key budget
regardless of session liveness (the old prune only dropped dead keys, so
>10 live sessions at the 20-session target deadlocked the quota) and
evict-and-retry on quota errors (the old prune ran only after a successful
setItem, so a full quota permanently disabled persistence).
- Tighten the OSC-strip regex in _isUsableXtermSnapshot to stop at ST.
- Update the structural test's usability-gate assertion to not depend on a
fixed byte window.
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.
Review fixes:
- Hold back a trailing partial CSI (digit-only intro, ≤7 chars) in
_handleTerminalOutput and prepend it to the next chunk. PTY chunk
boundaries are arbitrary, so '\x1b[?1049h' can arrive as '\x1b[?104' +
'9h' — the per-chunk strip misses it, xterm obeys the reassembled toggle,
and (with the matching ?1049l stripped) stays stuck in the scrollback-less
alt buffer until the next replay. Complete sequences are never held; the
carry resets with the other buffers in _resetBuffers.
- Replay path now also strips mouse-tracking enables (?1000-?1007), matching
the live strip: buffers persisted BEFORE the live strip existed can still
carry them, and a replayed ?1006h re-hijacks the scroll wheel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review fixes for COD-54:
- Generated hook curl commands now present X-Codeman-Hook-Secret, read from
the secret file AT EXECUTION TIME via $CODEMAN_HOOK_SECRET_FILE (exported
into every managed session's env by tmux buildEnvExports / the direct-PTY
env builders). Without this, every local hook 401'd the moment a managed
tunnel came up — the enforcement existed but nothing presented the secret.
Path-not-value keeps the secret off command lines and out of config files,
and running sessions pick up a newly generated secret with no respawn;
server.start() ensures the file exists up front.
- Hook-secret failures now count into a DEDICATED per-IP bucket
(hookSecretFailures) instead of the shared authFailures map. Legacy
(pre-secret) hook configs fire constantly from 127.0.0.1; counting their
401s against the shared bucket would 429 every cookie-less loopback
request — locking out the Basic-Auth login path (and, through a tunnel,
every client, since tunneled traffic also arrives as 127.0.0.1).
- docs/security-architecture.md: secret-gated hook exemption, dedicated
bucket, COD-55 refusal, and the residual caveat for EXTERNAL loopback
proxies (user-run cloudflared / tailscale serve), which the
managed-tunnel probe cannot see.
- test/cod54-hook-event-auth.test.ts: +3 tests — login path unaffected
after hook-bucket exhaustion; generated hooks reference the header +
$CODEMAN_HOOK_SECRET_FILE without embedding the value; env builders
export the path only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post-takeover/re-assert needsRefresh made multi-client redraws worse
in practice (fragmented mixed-width frames on the phone) — reverted to
the behavior the user verified as good: cross-device reflows rely on
Ink's own redraw, stale scrollback scrolls away with new output.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Auto-resume on usage limit ("token pause" control, opt-in checkbox at the
top of the Respawn tab, off by default):
- usage-limit-patterns.ts (new, pure): detects all Claude Code limit
messages (1.0.x-2.1.x eras incl. "5-hour limit reached - resets 8pm",
"You've hit your limit - resets 1:40pm (TZ)", weekly date forms, raw
"usage limit reached|<epoch>") and parses the reset time. Conservative:
no parseable future reset time, no action.
- SessionAutoOps: arms a timer at reset+2min, sends Esc (dismisses the
rate-limit dialog) + "continue"; dedups footer redraws, retries every
5min on stale times, cancels when Claude starts working, persists and
re-arms across Codeman restarts (SessionState.autoResumeEnabled/At).
- Respawn guard: cycles are blocked while limit-paused so /clear cannot
wipe the paused conversation (respawnBlocked reason 'usage_limit').
- POST /api/sessions/:id/auto-resume; SSE session:limitPauseScheduled/
limitResume/limitResumeCancelled; toasts + status line in the modal.
- Respawn tab tidied: single-row prompt fields, merged behavior row.
Mobile fixes (0.9.8 regressions, user-reported):
- Resize arbitration is now activity-based: a desktop sizing claim only
blocks phone resizes while the desktop typed within 90s
(Session.DESKTOP_CLAIM_IDLE_MS). Idle desktop -> phone takes the pane;
next desktop keystroke re-asserts the desktop layout server-side
(noteDesktopActivity via ws-routes input). Phones re-send dims every
30s (visible tab only, skipped while the keyboard is open) so attaching
under a hot claim self-corrects. Fixes the desktop-width-stream-in-
narrow-xterm soup (mid-word wraps, tmux dot fill, Ink overdraw).
- Cross-device reflows (takeover/re-assert) emit a debounced needsRefresh
so all clients reload the buffer instead of stacking ghost Ink frames.
- Keyboard accessory/toolbar lift restored: measure keyboardOffset
against window.innerHeight (layout viewport), not the shrunken .app -
on iOS the offset computed to 0, leaving both bars hidden behind the
OS keyboard with a dead gap above.
- Removed the mobile header utility ("three dots") toggle entirely;
the headerRight tray stays collapsed on small viewports.
Tests: usage-limit-patterns (36), session-auto-resume (21), resize
arbitration (+6), session routes (+4), respawn guard (+2); MockSession
auto-resume/sizing stubs; mobile tabs test updated for toggle removal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
cmdPattern's empty-matchable unbounded arg group backtracked exponentially
on wrapped heredoc/table lines — hovering one froze the tab for minutes.
Non-empty tokens + bounded reps make it O(n); regression test extracts the
shipped patterns and pins timing on the real killer shapes.
worker-src 'self' blob: is now unconditional so terminal-ui's _safeYield
tick worker (throttling escape) isn't CSP-blocked on non-gesture installs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A KeepAlive system-level LaunchDaemon (the right setup for headless Macs,
where no GUI login means LaunchAgents never start) is now detected as
supervisor 'launchd-daemon': the updater kills the server PID (passed via
--server-pid) and launchd respawns it on the new dist/ — no root needed.
Detection requires the daemon plist to be bootstrapped AND KeepAlive=true.
Also: on boot, a 'completed-needs-manual-restart' status auto-completes
when the running version matches the staged target, so the stale
'restart Codeman to apply' instruction no longer lingers in the UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>