Compare commits

...
Author SHA1 Message Date
Codeman maintainer 7c5920d3b9 chore: version packages 2026-06-14 23:06:32 +02:00
Ark0N e1e670594b Merge PR #128: auto-wrap desktop session tabs on overflow + resize re-eval
Auto-wrap desktop session tabs to a second row on overflow
2026-06-14 22:43:43 +02:00
Ark0N 2e28e17834 Merge PR #123: hide CJK textarea on welcome screen + mobile test update
fix(cjk): hide CJK textarea on welcome screen and fix vertical centering
2026-06-14 22:43:17 +02:00
Ark0N 90f18438ff Merge PR #127: require hook-event secret unconditionally + stale-config self-heal
Require the hook-event secret unconditionally (drop managed-tunnel gating)
2026-06-14 22:43:13 +02:00
Ark0N 5b62f397ec Merge PR #129: macOS Option/physical-key session shortcuts + terminal-ui ESC-leak fix
Make Option/Alt session shortcuts work on macOS (physical key codes)
2026-06-14 22:43:08 +02:00
Ark0N 1e54ebcdf4 Merge PR #125: add codeman doctor dependency checker + accuracy review fixes
Add `codeman doctor` tool-dependency checker
2026-06-14 22:43:04 +02:00
Ark0N 0364bea166 Merge PR #126: harden markdown sanitizer with DOMPurify (mXSS) + allowlist/test review fixes
Harden markdown HTML sanitizer with vendored DOMPurify (mXSS)
2026-06-14 22:42:59 +02:00
Claude (Codeman maintainer) c7e8ff616f fix(tabs): re-evaluate auto-wrap on resize and on every full tab rebuild
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>
2026-06-14 22:38:19 +02:00
Claude (Codeman maintainer) 21fbff4d8a fix(hooks): self-heal stale pre-secret hook configs so COD-91 doesn't 401 them
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>
2026-06-14 22:35:29 +02:00
Claude (Codeman maintainer) 8ffb2b0644 test(cjk): update the mobile server-override test for the welcome-screen gate
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>
2026-06-14 22:30:09 +02:00
Claude (Codeman maintainer) 80ebf8b549 fix(shortcuts): stop Alt/Option nav keys leaking ESC sequences into the terminal
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>
2026-06-14 22:27:36 +02:00
Claude (Codeman maintainer) c101cc8716 fix(doctor): correct Node minimum, drop phantom gemini, add pdftoppm, validate --category
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>
2026-06-14 22:24:46 +02:00
Claude (Codeman maintainer) cceb24ed8f fix(sanitizer): enforce the curated allowlist + make the test run order-independently
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>
2026-06-14 22:21:13 +02:00
Aamer Akhter 60dab7ce3f Make Option/Alt session shortcuts work on macOS (physical key codes)
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.
2026-06-14 15:54:50 -04:00
Aamer Akhter a5263b3252 Auto-wrap desktop session tabs to a second row on overflow
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).
2026-06-14 15:49:12 -04:00
Claude (Codeman maintainer) 90cd481b9f chore: version packages
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>
2026-06-14 21:39:14 +02:00
Claude (Codeman maintainer) 787e5e2a03 feat(attachments): make the header attachments button opt-in (default OFF)
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>
2026-06-14 21:21:06 +02:00
Claude (Codeman maintainer) 097433c86f docs: update CLAUDE.md for the attachments subsystem growth
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>
2026-06-14 21:13:36 +02:00
Claude (Codeman maintainer) e738c776c1 fix(settings): slim the Skin picker select to match its row
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>
2026-06-14 21:08:18 +02:00
Claude (Codeman maintainer) e10f0dabdb fix(mobile): hide attachments-history button on phones + regression guards
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>
2026-06-14 20:45:23 +02:00
Claude (Codeman maintainer) 661c89cefd fix(plan-usage): make the usage chip per-device, not synced
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>
2026-06-14 20:15:39 +02:00
Ark0N a122e867ef Merge PR #122: restore response-viewer eye button on mobile
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).
2026-06-14 19:46:11 +02:00
Claude (Codeman maintainer) a68f23e647 test(mobile): assert header tray reachable inline, not collapsed (#122)
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>
2026-06-14 19:40:51 +02:00
Aamer Akhter f0f43ddbad Require the hook-event secret unconditionally, not only under a managed tunnel
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).
2026-06-14 12:46:58 -04:00
Aamer Akhter ea53916adc Replace markdown denylist sanitizer with vendored DOMPurify (mXSS hardening)
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.
2026-06-14 12:33:48 -04:00
Aamer Akhter 585127deb2 Add codeman doctor tool-dependency checker (COD-45)
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).
2026-06-14 12:25:49 -04:00
Claude (Codeman maintainer) e742d00c98 Merge PR #121: attachment history drawer (COD-39)
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>
2026-06-14 09:34:41 +02:00
Claude (Codeman maintainer) 1a363a3e62 fix(attachments): address review findings on attachment history drawer (#121)
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>
2026-06-14 09:30:01 +02:00
Aamer Akhter 577b6d7384 COD-39 attachment history drawer
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.
2026-06-14 09:05:17 +02:00
Claude (Codeman maintainer) 5eacb1cf03 Merge PR #120: document attachment previews + thumbnails (COD-38)
Adds attachment cards with first-page thumbnails and inline document
previews (PDF/Office via pdftoppm + LibreOffice), plus review hardening
(converter concurrency limiter, bounded preview cache, fixed detected-doc
preview routing). See PR #120.

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

# Conflicts:
#	src/web/public/styles.css
2026-06-14 08:49:36 +02:00
Claude (Codeman maintainer) 5fbe451c26 fix(attachments): harden document preview/thumbnail path (review of #120)
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>
2026-06-14 08:43:57 +02:00
Tenggan ZhangandTeigen 99e537ef1a feat(settings): add Opus 4.6 model options to Claude Model picker (#124)
Add claude-opus-4-6[1m] (1M context) and claude-opus-4-6 to the model
selector dropdown.

Co-authored-by: Teigen <teigenzhang@gmail.com>
2026-06-14 08:13:13 +02:00
arkonandClaude Opus 4.8 67c7973aa5 docs: document plan-usage telemetry feature in CLAUDE.md
- New "Plan-usage chip" Key Pattern: statusLine telemetry (rate_limits) →
  injected statusLine exporter → POST /api/status-telemetry (auth-exempt) →
  usage-telemetry.ts parse → SSE session:statusTelemetry → opt-in header chip,
  with plan-usage-latest.ts replaying the last value in the SSE init snapshot.
- Architecture map: add src/usage-telemetry.ts + src/web/plan-usage-latest.ts;
  bump route modules 15→16 and handlers ~136→~140 (status-telemetry route,
  attachment file routes).
- Security: note /api/status-telemetry shares the hook auth-bypass path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:34:02 +02:00
arkonandClaude Opus 4.8 534712e50f fix(usage): address code-review findings in plan-usage telemetry
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>
2026-06-14 07:33:52 +02:00
arkonandClaude Opus 4.8 f69cd4874c feat(settings): drop Token Count + Show Cost header toggles, move Plan Usage Limits to top
The header Token Count and Show Cost ($) display options are superseded by the
Plan Usage Limits chip, so remove both toggles from App Settings → Header
Displays along with their read (populate) and write (save payload) wiring in
settings-ui.js. Relocate the Plan Usage Limits toggle to the top of the section
for easier access.

Header token-chip render logic is left intact (toggles-only change): the chip
keeps its existing default behavior, it's just no longer user-toggleable.

Verified e2e against an isolated instance with Playwright: section now leads
with Plan Usage Limits; Token Count/Show Cost elements are gone; openAppSettings
(populate) and saveAppSettings (payload build) run with no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:44:42 +02:00
arkonandClaude Opus 4.8 1ac3c09054 docs(usage): update plan-usage design doc to match what shipped
Rewrite to the as-built design: header chip (account limits, green/yellow/red)
+ session-status footer split; fixed /api/status-telemetry endpoint; curl -sk;
add-only create injection + settings-toggle reconcile; no CASES_DIR gate; chip
robustness (live SSE + init-snapshot replay + localStorage); and the E2E bugs
that earlier builds hid. Status: shipped/pushed, not released.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:28:11 +02:00
arkonandClaude Opus 4.8 95fb5fc226 feat(usage): replay last-known plan usage in the SSE init snapshot
The header chip previously only repopulated on reload from per-browser
localStorage, so a fresh browser (or cleared storage) stayed blank until a
session next rendered telemetry. Store the latest broadcast telemetry
process-wide (plan-usage-latest.ts) and include it as `planUsage` in
getLightState — the per-connection SSE init snapshot — so handleInit paints
the chip immediately on every fresh load / reconnect, authoritative over the
localStorage restore. Null until the first telemetry of the process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:15:34 +02:00
arkonandClaude Opus 4.8 eae225bf9a fix(usage): make plan-usage chip work for every user, not just on enable
Two changes so the feature works for any user the moment they enable it,
without manual steps or per-client state:

- Reconcile on settings change: PUT /api/settings now applies the statusLine
  exporter across all ACTIVE Claude sessions' working dirs when
  showPlanUsageLimits is toggled (inject on enable, remove on disable). This is
  server-side and authoritative, so existing sessions get the footer + feed the
  chip immediately — no need to create a new session, no dependency on a
  browser's synced localStorage.

- Create is now ADD-ONLY: never remove the statusLine on session create.
  Sessions in a repo share one settings.local.json, so a single create-with-false
  (e.g. a client whose synced setting hadn't loaded) was yanking the statusLine
  out from under all other live sessions in that repo, killing their footer and
  the chip's data feed. Removal now happens only via the explicit settings toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 06:00:30 +02:00
arkonandClaude Opus 4.8 4d9d93dfff fix(usage): make plan-usage chip work end-to-end + session-status footer
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>
2026-06-14 05:44:54 +02:00
arkonandClaude Opus 4.8 c82f6c802e feat(usage): plan usage limits header chip via statusLine telemetry
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>
2026-06-14 05:00:28 +02:00
arkonandClaude Fable 5 0809f59f0f chore: version packages — Codeman 1.0.0
Bumps aicodeman 0.9.14 → 1.0.0 (theme skins + first stable release).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 23:30:34 +02:00
arkonandClaude Fable 5 eda95adaa9 feat(ui): theme skins — OG Codeman, Daylight Green, Daylight Blue
Add a per-device skin switcher in App Settings → Display:
- Three skins via html[data-skin]: og (original Codeman look),
  daylight-green, and daylight-blue (new default). Per-skin CSS-variable
  token blocks; the v1.0 "Carbon Aurora" component polish is scoped to
  non-og skins and parameterized so green/blue differ only by token values.
- Self-hosted Manrope (UI) + JetBrains Mono (terminal) variable fonts,
  served from /fonts (no external CDN, CSP-safe via font-src 'self').
- Per-skin xterm terminal theme with live re-theming of open terminals on
  skin change; skin-aware --term-bg so the terminal background fills cleanly
  (fixes the variable-height gap above the toolbar).
- Pre-paint inline script applies the saved skin before first paint (no
  flash); persisted per-device in localStorage + the settings blob, and
  kept out of the server settings payload (device-local).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 23:21:47 +02:00
Teigen 41a209e96d fix(cjk): hide CJK textarea on welcome screen and fix vertical centering
- Guard `_updateCjkInputState()` with `activeSessionId` check so the
  `position: fixed` CJK textarea doesn't float over the welcome overlay
- Call `_updateCjkInputState()` in `showWelcome()`/`hideWelcome()` to
  sync CJK visibility on session enter/leave
- Add `padding: 12px 10px` to `.cjk-input-visible textarea` for proper
  vertical centering of input text
2026-06-13 18:49:57 +08:00
Teigen 7102fdb23a fix(mobile): restore response-viewer eye button on phones
The header-right tray (02fa3f3) was reworked into a position:fixed
collapsible panel with a hamburger toggle, but the toggle button, its
JS, and CSS were later reverted on master while the container kept a
static `mobile-collapsed` class. With no expand mechanism left, the
header-right stayed display:none on mobile, so the response-viewer eye
icon was unreachable even with "Response Viewer" enabled — desktop was
fine because the media-query rule doesn't apply there.

Restore the simple inline always-visible header-right layout (dev's
known-good state). The showResponseViewer setting still controls the
eye's --hidden marker class.

Verified on iPhone viewport: eye visible (26x26) with setting on, hidden
with setting off, tap opens the viewer; desktop eye unaffected.
2026-06-13 18:49:11 +08:00
Aamer Akhter 49c92e4723 COD-38 document attachment previews + thumbnails (attachment cards)
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.
2026-06-12 09:09:14 -04:00
Ark0N 3f2c23cb0f Merge pull request #119 from aakhter/pr/cod-37-attachments
Add server-side attachment pipeline (registry, magic-link, path guard)

Review fixes (2767e80): force-confine the terminal magic-link scan path to the
session workspace (closes a prompt-injectable arbitrary host-file read primitive
that broadcast over SSE); keep PNG on the image-popup path (the attachment UI
consumer is out of scope, so rerouting it broke the screenshot popup); serve the
re-resolved path (TOCTOU); 50MB raw cap; per-session registry cap; CLI .env via
dataPath(). Documented in security-architecture.md.
2026-06-11 10:32:34 +02:00
arkon f7ce8e4767 fix(attachments): harden registry + close magic-link injection vector
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.
2026-06-11 10:27:09 +02:00
Aamer Akhter f1c64994ad COD-37 add server-side attachment pipeline (registry, magic-link, path guard)
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).
2026-06-11 10:27:09 +02:00
Ark0N 12c8e080c1 Merge pull request #118 from aakhter/pr/cod-81-snapshot
feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)

Review fixes (9893a7f): bounded/hardened xterm snapshot persistence — shell-session skip, true LRU eviction, localStorage quota-deadlock fix with evict-and-retry, OSC-strip regex tightened.
2026-06-11 10:22:59 +02:00
arkon 9893a7f64a fix(terminal): bound + harden xterm snapshot persistence
- 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.
2026-06-11 10:16:47 +02:00
Aamer Akhter 5b2da424a1 feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)
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.
2026-06-10 19:56:30 -04:00
Ark0N aa84447899 Update README to include 'Terminal' in description 2026-06-11 00:38:34 +02:00
Ark0N a0e1a2e33b Update README.md 2026-06-11 00:36:51 +02:00
arkonandClaude Fable 5 6da22f0db0 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:06:12 +02:00
Ark0N dc9c4b3bda Merge pull request #117 from aakhter/pr/cod-86-codex-frontend
fix(codex): smaller first-frame write budget + scroll-up grace for codex
2026-06-10 22:59:40 +02:00
Ark0N 1cf5c8c8ad Merge pull request #116 from aakhter/pr/cod-35-codex-polish
fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
2026-06-10 22:52:02 +02:00
arkonandClaude Fable 5 7eda39e7f7 fix(codex): reassemble chunk-split sequences before the strip; mouse parity on replay
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>
2026-06-10 22:47:02 +02:00
Ark0N f0db5f827f Merge pull request #115 from aakhter/pr/cod-78-security
feat(security): hook-event auth secret + tunnel password guard
2026-06-10 22:36:47 +02:00
arkonandClaude Fable 5 aa4e1ce9cf fix(security): deliver the hook secret to hooks + isolate its rate-limit bucket
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>
2026-06-10 22:31:09 +02:00
arkonandClaude Fable 5 b8cb4670dd fix(mobile,respawn-ui): unbury the session-options modal on phones; regroup the Respawn tab
Mobile fixes (user-reported: stuck in Session Options with no way to close):
- Modals now stack at z-index 1300, above the fixed mobile/tablet header
  (z-index 1200) that was burying the modal header and its close button —
  the full-screen modal was undismissable on phones
- Duration presets collapse to one compact 24px row (was a 3-row grid)
- Hide the tab detach (open-in-new-window) button on viewports <=768px

Respawn tab regrouped so its two features read as separate options:
- New green-tinted "Respawn loop" box wraps duration, presets, cycle
  steps, and the status/Enable row — a visual sibling of the blue
  auto-resume box; includes a short explanation of the loop
- Enable/status row moved from the top of the tab to the bottom of the
  box, so it no longer reads as a modal-level confirm button
- Font sizes unified: feature titles match; step checkboxes (2./3.)
  match the step labels (1./4.); "Respawn Cycle" renamed "Cycle Steps"

CLAUDE.md: add usage-limit-patterns.ts to the Session row; app.js ~3.7K

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:37:44 +02:00
arkonandClaude Fable 5 4a33b91107 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:56:24 +02:00
arkonandClaude Fable 5 28b531fa5b revert(session): drop the cross-device needsRefresh buffer reload
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>
2026-06-10 20:50:24 +02:00
arkonandClaude Fable 5 68310619a7 feat(session,mobile): auto-resume on usage limit + mobile view fixes
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>
2026-06-10 20:41:34 +02:00
Aamer AkhterandSaqeb Akhter fe821fb679 fix(codex): smaller first-frame write budget + scroll-up grace for codex
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>
2026-06-10 13:34:45 -04:00
Aamer AkhterandSaqeb Akhter d7606366a2 fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
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>
2026-06-10 13:18:45 -04:00
Aamer Akhter 42f0b28c75 feat(security): hook-event auth secret + tunnel password guard
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.
2026-06-10 12:25:26 -04:00
97 changed files with 10540 additions and 591 deletions
+74
View File
@@ -1,5 +1,79 @@
# aicodeman
## 1.1.1
### Patch Changes
- Six reviewed contributor PRs (all adversarially reviewed and fixed before merge):
- **Markdown sanitizer hardened against mutation-XSS (#126).** The denylist `_sanitizeHtml` is replaced with vendored DOMPurify 3.4.8 (authentic, byte-matched to the official dist) wired via a new `sanitize-html.js` allowlist, with a fail-closed escape fallback. The curated allowlist is genuinely enforced (no `USE_PROFILES` override) so non-markdown tags and svg/math/style/script/event-handler/`javascript:` vectors are stripped while legitimate markdown survives.
- **Hook-event secret now required unconditionally (#127).** The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the per-instance hook secret whether or not a managed tunnel is running, closing the own-loopback-reverse-proxy gap. A self-heal refreshes pre-secret hook configs in existing cases on spawn so password-protected installs don't silently 401 their hooks. No-password loopback installs are unaffected.
- **`codeman doctor` dependency checker (#125).** New `doctor`/`check-deps` command probes Node, the agent CLIs, tmux, and document converters per environment (linux/darwin/win32/wsl), with grouped or `--json` output and a non-zero exit when a required tool is missing. Requires Node 22+, reports `pdftoppm` (used for PDF/Office thumbnails), and validates `--category`.
- **macOS Option / physical-key session shortcuts (#129).** Tab switching matches physical key codes (`e.code`) so Option+1–9 works on macOS layouts that remap Option, plus Option/Alt+`[`/`]` for previous/next session — without leaking escape sequences into the focused terminal.
- **Desktop session tabs auto-wrap to a second row on overflow (#128)** instead of horizontal scrolling (off when the manual two-row layout is pinned; mobile/tablet unchanged), re-evaluated on window resize.
- **CJK input textarea hidden on the welcome screen (#123)** so it no longer floats over the welcome overlay, and re-shown on session entry; vertical centering fixed.
## 1.1.0
### Minor Changes
- **Plan Usage Limits chip (new).** A header chip now shows your live Claude plan usage — the 5-hour and weekly windows as a percentage — parsed from Claude Code's statusLine telemetry (CLI v2.1.80+). It's opt-in via **App Settings → Display → "Plan Usage Limits"** (default OFF). The toggle is **per-device**: turn it on at your desk without it appearing on your phone. Telemetry collection is decoupled from display, so one device's preference never affects another's, and the last-known value replays instantly on reconnect. Distinct from auto-resume (which reacts to the limit _message_) — this proactively shows the live %.
**Attachments.** New attachment history drawer to browse files referenced by a session (COD-39), plus document previews and thumbnails on attachment cards (COD-38). The header **Attachments button is now opt-in** (default OFF) via **App Settings → Display → "Attachments Button"**, per-device like the Response Viewer button.
**Settings & models.** Added Opus 4.6 options to the Claude Model picker. Removed the legacy Token Count / Show Cost header toggles and moved Plan Usage Limits to the top of the Display settings. Slimmed the Skin picker control to match its row.
**Mobile & header polish.** Restored the response-viewer (eye) button on phones; kept the phone header minimal (settings gear + lifecycle log stay in the toolbar). Added two regression guards so header controls can't silently leak onto the mobile header again — a CI-runnable static policy check plus a real-browser E2E test.
## 1.0.0
### Major Changes
- # Codeman 1.0.0 🎉
The first stable release of Codeman — and it comes with a fresh new look.
**New: theme skins.** Codeman now ships a built-in skin switcher (App Settings → Display → Appearance):
- **OG Codeman** — the original look, preserved exactly.
- **Daylight Green** — a fresh emerald-on-slate theme.
- **Daylight Blue** — bright sky-blue on lifted slate (the new default).
Skins apply instantly, persist per device (with a pre-paint script so there's no flash on load), and re-theme any open terminals live. The system is built on `html[data-skin]` design tokens and self-hosted Manrope (UI) + JetBrains Mono (terminal) fonts — no external CDN, CSP-safe.
**1.0.0 milestone.** This marks the start of the stable 1.x line: the CLI, documented environment variables, and the `{ success, data }` HTTP/SSE API envelope follow semantic versioning (see `docs/versioning-policy.md`).
**Thank you to everyone who helped build Codeman.** This release is dedicated to all of our contributors for their work on the project: Ark0N, Aamer Akhter (@aakhter), Tenggan Zhang (@TeigenZhang), zhouyuan / @sunnyzhouy, jaypark, Marco Migozzi, Skúli Arnlaugsson, Aaron Fields, Loïc Sculier, and Noah Waldner (@noahwaldner). 💙
## 0.9.14
### Patch Changes
- Security hardening for the tunnel exposure path, Codex terminal rendering fixes, and a mobile modal fix.
**Security (PR #115, COD-54/COD-55):**
- `/api/hook-event` localhost bypass is now gated while the managed Cloudflare tunnel is running: tunneled traffic arrives with a loopback source IP, so the bypass additionally requires a per-instance shared secret (`X-Codeman-Hook-Secret`, 256-bit, `~/.codeman/hook-secret`, mode 0600). Locally generated hook commands read the secret file at execution time via `$CODEMAN_HOOK_SECRET_FILE` (exported into every managed session's environment), so the value never lands on command lines or in case configs, and running sessions pick up a new secret without respawn. Failed presentations rate-limit in a dedicated per-IP bucket so misfiring legacy hooks can never lock out the Basic-Auth login path. With no tunnel running, behavior is unchanged.
- Enabling the Cloudflare tunnel now **refuses with 403** when no `CODEMAN_PASSWORD` is set (a public tunnel URL with no auth is effectively public RCE), unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly acknowledges the exposure. The settings UI surfaces the refusal as an error toast and reverts the toggle.
**Codex rendering (PRs #116, #117):**
- Alt-screen toggles (`?47/?1047/?1049`), scrollback-erase (`CSI 3 J`), and mouse-tracking enables (`?1000`–`?1007`) are stripped from the Codex byte stream (live + replay), so conversation history survives tab switches and the scroll wheel scrolls the viewport instead of being hijacked. Sequences split across PTY chunk boundaries are reassembled via a small carry before stripping, so a split `?1049h` can no longer trap xterm in the scrollback-less alt buffer.
- Smaller 32KB first-frame write budget for Codex sessions keeps dense synchronized redraws from stalling the renderer; a 1.5s grace window after a manual scroll-up suppresses sticky-scroll so high-frequency `• Working (Ns)` status ticks no longer snap the viewport back to the bottom while reading earlier output.
**Mobile:** session-options modal raised above the fixed mobile/tablet header (z-index 1300 vs 1200) so the close button is reachable on phones; Respawn tab controls regrouped.
**Docs:** security-architecture.md updated for the secret-gated hook bypass (including the external-proxy caveat) and the tunnel password guard; README documents auto-resume on usage limit.
## 0.9.13
### Patch Changes
- Auto-resume on usage limit ("token pause" control) plus a set of mobile-view fixes for regressions introduced in 0.9.8.
**Auto-resume on usage limit** — new opt-in checkbox at the top of the session Respawn tab (off by default). When Claude stops because a usage limit was reached, Codeman parses the reset time from the limit message, waits until the limit lifts (plus a 2-minute safety buffer), then dismisses the rate-limit dialog (Esc) and sends "continue" so the session picks its work back up automatically. All Claude Code message formats from 1.0.x through 2.1.x are recognized ("5-hour limit reached ∙ resets 8pm", "Limit reached · resets 1pm (America/Chicago) · /upgrade…", "You've hit your weekly limit · resets Mon 12:00am", weekly date forms, and the raw API `usage limit reached|<epoch>` form). Still-limited responses re-arm the scheduler (5-minute retry loop); a pending schedule persists across Codeman restarts and re-arms on boot; respawn cycles are blocked while a limit pause is active so the cycle's `/clear` cannot wipe the paused conversation. New endpoint `POST /api/sessions/:id/auto-resume`; new SSE events `session:limitPauseScheduled`, `session:limitResume`, `session:limitResumeCancelled`; toast/notification on pause and resume, plus a live "resumes at HH:MM" status line in the modal. The Respawn tab layout was also tidied: compact single-row Update/Kickstart prompt fields and a merged options row.
**Mobile fixes (0.9.8 regressions)**:
- **Activity-based resize arbitration** — a desktop sizing claim now only blocks a phone's resize while that desktop has actually typed within the last 90 seconds. Previously any connected desktop tab (even one abandoned hours ago) silently discarded the phone's resize with no fallback, leaving the phone rendering a desktop-width stream in a narrow terminal: mid-word wraps, tmux dot-fill rows, overdrawn garbled text, and misplaced keyboard echo. Now an idle desktop yields the pane to the phone, and the next desktop keystroke automatically restores the desktop layout ("whoever is actively using the session wins"). Phones also re-send their dimensions every 30 seconds (visible tab only, skipped while the virtual keyboard is open) so attaching under a momentarily-active desktop self-corrects.
- **Keyboard accessory bar and toolbar restored on iOS** — the lift offset is measured against the layout viewport (`window.innerHeight`) again instead of the keyboard-shrunken app element; on iOS the offset computed to 0, leaving both bars hidden behind the OS keyboard with a dead black gap above it.
- **Removed the mobile header utility ("three dots") toggle** — the header-utilities tray stays collapsed on small viewports.
## 0.9.12
### Patch Changes
+18 -8
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.9.12 (must match `package.json`)
**Version**: 1.1.1 (must match `package.json`)
## Project Overview
@@ -102,6 +102,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
- **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, and Codex (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
@@ -117,7 +118,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Domain | Key files | Notes |
|--------|-----------|-------|
| **Entry** | `src/index.ts`, `src/cli.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/usage-limit-patterns.ts`, `src/usage-telemetry.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
@@ -127,16 +128,17 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts` | |
| **Frontend** | `src/web/public/app.js` (~3.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (16 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; single-source, bundled to the gitignored `vendor/xterm-zerolag-input.js` and consumed by `app.js` (see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
**Config**: `src/config/` — 10 files, no barrel (`index.ts`) exists; import from the specific file.
**Config**: `src/config/` — 12 files, no barrel (`index.ts`) exists; import from the specific file.
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
@@ -153,6 +155,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`.
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is *ours*, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated under a tunnel) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit *message*; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
**External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv`, never on the spawn command line: OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars` in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM).
@@ -165,13 +171,15 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups *identical* in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals.
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7).
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7).
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
@@ -181,6 +189,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman *consumer* that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture *feel* in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applyTheme()`/`applyTerminalSkin()` apply it live on save and keep the standalone `codeman:skin` key + the settings object in sync. `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
@@ -198,7 +208,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) |
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
@@ -209,7 +219,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~135 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~146 handlers across 16 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
+8 -6
View File
@@ -5,11 +5,7 @@
<h2 align="center">Mission control for AI coding agents</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &mdash; One Dashboard &bull; Any Device</em>
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
@@ -20,6 +16,10 @@
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<img src="docs/images/subagent-demo.gif" alt="Codeman — parallel subagent visualization" width="900">
</p>
@@ -214,6 +214,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
```
- **Multi-layer idle detection** — completion messages, AI-powered idle check, output silence, token stability
- **Auto-resume on usage limit** *(opt-in, off by default)* — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
- **Circuit breaker** — prevents respawn thrashing when Claude is stuck (CLOSED -> HALF_OPEN -> OPEN states, tracks consecutive no-progress and repeated errors)
- **Health scoring** — 0-100 health score with component scores for cycle success, circuit breaker state, iteration progress, and stuck recovery
- **Built-in presets** — `solo-work` (3s idle, 60min), `subagent-workflow` (45s, 240min), `team-lead` (90s, 480min), `ralph-todo` (8s, 480min), `overnight-autonomous` (10s, 480min)
@@ -482,7 +483,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|----------|--------|
| `Ctrl/Cmd+W` | Kill active session |
| `Ctrl/Cmd+Tab` | Next session |
| `Alt+1`–`Alt+9` | Switch to tab N |
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
+1
View File
@@ -216,6 +216,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
```
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
- **用量限额自动恢复**(*可选,默认关闭*)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
+53 -7
View File
@@ -124,7 +124,11 @@ loopback bind matters. The auth pipeline (`src/web/middleware/auth.ts`,
`onRequest` hook) runs in this order:
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3).
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3). While the
**managed tunnel is running**, the hook‑event exemption additionally requires
the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations
are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures)
so misfiring hooks can never lock out the login path.
2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to
allow.
3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear
@@ -165,17 +169,29 @@ protection is unchanged.
with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those
requests as local:
- `POST /api/hook-event` — auth‑exempt for loopback. Bounded impact: it is
- `POST /api/hook-event` — auth‑exempt for loopback **only while no managed tunnel
is running**. When Codeman's own tunnel is up, the exemption requires the
per‑instance shared secret (`X-Codeman-Hook-Secret`, 256‑bit hex in
`~/.codeman/hook-secret`, mode 0600, COD‑54). Local hook commands read the
secret file at execution time (`$CODEMAN_HOOK_SECRET_FILE`, exported into every
managed session), so they keep working — tunneled internet traffic can't know
it. Even without the secret the impact is bounded: the route is
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
drive respawn signals, SSE broadcasts, push notifications, and transcript
watching — **not** arbitrary terminal input or file reads. It is a
session‑disruption / notification‑spoofing surface, not RCE.
watching — **not** arbitrary terminal input or file reads. ⚠️ The gate keys off
the **managed** tunnel — an externally run loopback proxy (your own
`cloudflared`, `tailscale serve`) is invisible to it, so the plain loopback
exemption still applies there (prefer `tailscale serve`, which authenticates at
the tailnet layer). Hook configs regenerated since COD‑54 always present the
header, so a future release can require the secret unconditionally.
- QR `/q/` — still protected by its own short‑code brute‑force limiter
(10 failures / 60s against a 62⁶ space).
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
up (it does not gate the hook‑event exemption, but it gates everything else and
is the documented practice). Prefer `tailscale serve` (below), which authenticates
up — it gates everything except the (secret‑gated) hook exemption and is the
documented practice; since COD‑55 enabling the managed tunnel **refuses** to start
without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly
acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates
at the tailnet layer so untrusted clients never reach the loopback port at all.
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
@@ -304,7 +320,37 @@ injected from API JSON (`innerHTML`), not via `file-raw`, so they are unaffected
`/api/download` additionally refuses a blocklist of sensitive paths
(`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This
is **defense‑in‑depth, not the primary boundary** — the realpath containment is
the control.
the control. The blocklist patterns are shared (`src/web/sensitive-path.ts`) with
the attachment guard below.
### External attachments (registry) & the magic‑link trust boundary
Live external attachments (`src/attachment-registry.ts`) mint an `att_<uuid>` id
for a host file so browser requests carry the id, never an absolute path. Serving
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, 50 MB cap,
`nosniff`) and re‑resolves the symlink + re‑checks the **attachment guard**
(`src/config/attachment-guard.ts`: the shared sensitive‑path blocklist **plus**
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
routes, attachments are intentionally **cross‑workspace** — so the effective gate
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
realpath containment.
Two registration paths, with **different trust**:
- **Explicit `POST /api/sessions/:id/attachments`** (and `codeman attach`, which
POSTs directly inside a managed session) — a deliberate, Origin‑guarded HTTP
request. Allowed cross‑workspace (subject to the guard). This is the supported
path for codeman‑publish and the `~/.codeman` review‑card loop.
- **Terminal `codeman://attach?path=…` magic links** — scanned passively from
session output. Terminal output is **attacker‑influenceable** (a prompt‑injected
session can print an arbitrary path), and registration here is server‑side with
no Origin gate and broadcasts the `rawUrl` over SSE to all clients. This path is
therefore **force‑confined to the session workspace** (`forceWorkspaceConfinement`
in `registerExternalAttachment`, wired in `WebServer.registerAttachment`),
regardless of the global confine setting — a passive magic link cannot expose a
file outside the session's own workspace. Cross‑workspace attach must go through
the explicit POST path above.
### SSE log‑tail route — intentional extra read roots
+169
View File
@@ -0,0 +1,169 @@
# Plan Usage Limits Display — Design & As-Built
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** Opt-in via App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`, default OFF). Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
>
> Two surfaces from one `statusLine` callback:
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
> - **In-terminal statusline footer** — the **current session's** status: `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%`.
>
> The `rate_limits` JSON schema below was **empirically confirmed** against Claude Code 2.1.177 on a Claude Max account; see the Verification appendix to reproduce.
## Problem
Codeman had no proactive view of how much of the Claude subscription is left. It only learned about limits **reactively**: `usage-limit-patterns.ts` regex-scrapes ANSI-stripped terminal output for footer strings like `5-hour limit reached ∙ resets 8pm`, extracting only the **reset time**, and only *after* Claude has already stalled. There was no "73% of your 5-hour limit used" anywhere.
We wanted a live, always-visible gauge so the operator can see a wall coming and pace overnight/autonomous runs — without hijacking the in-terminal statusline, which should keep showing the current session's status.
## Data source: the statusline `rate_limits` JSON
Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a configured `statusLine.command` on stdin after each render. On Pro/Max subscriptions that blob includes `rate_limits`. **This is the only channel that exposes plan-limit data** (see rejected alternatives) — so the feature *must* set a statusLine command, which is why the footer is also reconstructed by it (below).
### Confirmed schema (real captured payload)
```jsonc
"rate_limits": {
"five_hour": { "used_percentage": 15, "resets_at": 1781409000 }, // → 2026-06-14T03:50:00Z
"seven_day": { "used_percentage": 34, "resets_at": 1781827200 } // → 2026-06-19T00:00:00Z
}
```
| Field | Type | Notes |
|-------|------|-------|
| `rate_limits.five_hour.used_percentage` | `number` 0–100 | Integer-valued in practice; treat as `number`, don't assume decimals. |
| `rate_limits.five_hour.resets_at` | `number` | **Epoch SECONDS** (10 digits). `×1000` for a JS `Date`. |
| `rate_limits.seven_day.{used_percentage,resets_at}` | same | |
**Confirmed facts & gotchas:**
- **Only two windows exist: `five_hour` and `seven_day`.** There is **no separate Opus-weekly field**, even on a Max/Opus account.
- `rate_limits` is **absent on the first render**, **present after the first API response**. UI degrades to "no chip yet."
- statusLine fires **only in interactive TUI mode**, never `--print`. Fine — Codeman sessions are interactive TUIs (and so are Codeman-spawned ones in tmux).
- **Subscriber-gated.** Absent for API-key / non-subscriber auth.
### Bonus telemetry in the same payload — used for the footer
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
### Alternatives considered & rejected
| Source | Why not |
|--------|---------|
| OAuth endpoint `api.anthropic.com/api/oauth/usage` | Undocumented, aggressively rate-limited, needs the **encrypted** OAuth token. Only worth it for *dollar spend*. |
| `/usage` slash command | Interactive-only, no programmatic output. |
| On-disk `~/.claude/` files | No usage state persisted (only `daemon.status.json` = auto-updater supervisor). |
| CLI flag (`claude usage` / `--check-usage`) | Does not exist. |
| `StopFailure` hook | Carries only an `error_type` on *failure* — no live percentages. |
## As-built architecture
```
Claude TUI (any Claude session, incl. linked-case/real-repo sessions)
│ renders statusline after each assistant msg (+ /compact, mode change)
▼
statusLine.command (settings.local.json) ──reads stdin JSON──▶
curl -sk POST $CODEMAN_API_URL/api/status-telemetry {sessionId, data}
(X-Codeman-Hook-Secret: $(cat $CODEMAN_HOOK_SECRET_FILE))
│ ◀── HTTP 200 text/plain = current-SESSION status string ──┘
▼
printf '%s' "$body" → in-terminal footer: "Opus 4.8 (1M context) in:… out:… ctx:…%"
server (status-telemetry-routes.ts):
parse rate_limits → (if changed) store last-known + broadcast SSE session:statusTelemetry → header chip
parse model/tokens/ctx → return the session-status footer string
▼
app.js: _onSessionStatusTelemetry → chip (per-window colors) + localStorage save
handleInit → chip from init-snapshot planUsage (fresh-load replay)
```
### 1. The exporter — `generateStatusLineCommand()` in `hooks-config.ts`
Mirrors the hook `curlCmd()`. Reads the stdin JSON, POSTs `{sessionId, data}` to a **fixed** loopback path, and prints the response body back to stdout (print-through, so the footer stays useful). The managed-session env carries `$CODEMAN_SESSION_ID` / `$CODEMAN_API_URL` / `$CODEMAN_HOOK_SECRET_FILE` (from `tmux-manager.buildEnvExports()`).
```bash
INPUT=$(cat 2>/dev/null || echo '{}'); \
printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | \
curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" \
-H 'Content-Type: application/json' \
-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" \
--data @- 2>/dev/null || echo codeman
```
⚠️ **`curl -sk`, not `curl -s`.** Prod is loopback **HTTPS with a self-signed cert**; without `-k`, curl returns `000` and the statusline silently shows nothing. `-k` is safe (loopback only). *(The existing hook curls use `-s` without `-k` and have the same latent issue on HTTPS installs — a known, separate follow-up.)*
### 2. Endpoint — `POST /api/status-telemetry` (`status-telemetry-routes.ts`)
Fixed path (sessionId in the **body**, not the URL) so the auth exemption is an exact-match like `/api/hook-event` (`middleware/auth.ts`: loopback-only; `X-Codeman-Hook-Secret`-gated while a tunnel runs). Schema `StatusTelemetrySchema` in `schemas.ts` validates the subset; unknown keys are stripped. Pure parsing/formatting in `usage-telemetry.ts`:
- `parseStatusTelemetry(data)` → `{ fiveHour, sevenDay, … }` or `null`. On change (signature dedup; statusline fires often), store last-known (`plan-usage-latest.ts`) and `broadcast('session:statusTelemetry', { sessionId, …telemetry })`.
- `parseSessionStatus(data)` + `formatSessionStatusText()` → the **footer** string `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` (returned as `text/plain`). Available from the first render, even before `rate_limits` appears.
### 3. SSE + frontend chip
`session:statusTelemetry` registered in `sse-events.ts` + `constants.js`. `app.js`:
- `_onSessionStatusTelemetry` → `updatePlanUsageChip(data)` + save to `localStorage['codeman:planUsage']`.
- `updatePlanUsageChip` renders two `5h`/`7d` windows; **per-window color by usage** — green `<60%`, yellow `60–84%`, red `≥85%` (`pu-green/pu-yellow/pu-red`); bold labels/values; reset times in the tooltip. `resets_at*1000 → Date`.
- Chip element ships hidden (`header-plan-usage--hidden`); `applyHeaderVisibilitySettings()` reveals it client-side when the setting is on (response-viewer pattern — **no `renderIndexHtml` strip**, which kept the "title-only" render contract intact).
### 4. Chip data robustness — three layers
1. **Live:** `session:statusTelemetry` SSE on every distinct render.
2. **Fresh load / reconnect:** server stores the latest in `plan-usage-latest.ts`; `getLightState()` includes it as `planUsage`; the per-connection **init snapshot** replays it; `handleInit` paints the chip immediately (authoritative over localStorage). Null until the first telemetry of the process.
3. **Offline / cross-restart:** `restorePlanUsageChip()` reads `localStorage` on load (12h freshness guard).
### 5. Injection lifecycle — works for *any* user, never self-destructs
The setting `showPlanUsageLimits` is **synced** (in `settings.json`, not a per-device `displayKey`).
- **On toggle** (`PUT /api/settings`, `system-routes.ts`): reconcile the exporter across **all active Claude sessions' working dirs** — inject on enable, remove on disable. Server-side and authoritative, so existing sessions get the footer + feed the chip *immediately*, no new session needed, no dependency on a client's synced localStorage.
- **On session create** (`session-routes.ts`): **ADD-ONLY** — inject when `statusLineTelemetry` is true; **never remove**. Sessions in a repo share one `settings.local.json`, so a single create-with-false (e.g. a client whose synced setting hadn't loaded) must not yank the statusLine out from under other live sessions. Removal happens only via the explicit toggle.
- `applyStatusLineConfig()` is **`isOurs`-guarded** (matches `/api/status-telemetry`), so a user's own hand-authored statusLine is never touched, and it **updates an out-of-date ours-command** so fixes (e.g. `-k`) propagate. **No `CASES_DIR` gate** — runs for linked cases / real repos (where sessions actually run), mirroring `updateCaseModel`.
## Codeman-specific considerations
1. **Account-global limits.** The 5h/7d pools are shared across all sessions on the account → one shared header chip (freshest sample wins), not a per-tab bar.
2. **The footer is owned, by necessity.** A statusLine command always replaces Claude's default footer. Since `rate_limits` *only* arrives via statusLine, we reconstruct a useful **session-status** footer (model · tokens · ctx %) from the same payload rather than showing the limits there.
3. **`isOurs`-guarded.** Never removes/overwrites a user's own statusLine on disable; only manages the Codeman exporter.
4. **Security envelope unchanged.** The exporter runs arbitrary shell every render — same trust model as the hook curls (localhost + `$CODEMAN_HOOK_SECRET_FILE`); reuses the hook-secret gate.
5. **Claude-only.** OpenCode/Codex emit no `rate_limits` JSON; injection is gated to `mode === 'claude'`.
6. **Future — auto-resume synergy.** Live percentages would let `SessionAutoOps` pre-arm *before* the wall instead of reacting to the stall footer. Not built.
## Files shipped
- `src/usage-telemetry.ts` — pure parse/format (`parseStatusTelemetry`, `parseSessionStatus`, `formatSessionStatusText`, `telemetrySignature`) + `test/usage-telemetry.test.ts`.
- `src/hooks-config.ts` — `generateStatusLineCommand()` (`curl -sk`), `applyStatusLineConfig()` (add/update/remove, `isOurs`-guarded).
- `src/web/routes/status-telemetry-routes.ts` — `POST /api/status-telemetry`.
- `src/web/plan-usage-latest.ts` — process-wide last-known store for init replay.
- `src/web/schemas.ts` — `StatusTelemetrySchema` + `showPlanUsageLimits` + create-payload `statusLineTelemetry`.
- `src/web/middleware/auth.ts` — exemption extended to `/api/status-telemetry`.
- `src/web/routes/session-routes.ts` — add-only create-time injection.
- `src/web/routes/system-routes.ts` — settings-toggle reconcile.
- `src/web/server.ts` — `getLightState().planUsage` (init snapshot).
- `src/web/sse-events.ts` + `constants.js` — `session:statusTelemetry`.
- Frontend: `app.js` (`_onSessionStatusTelemetry`, `updatePlanUsageChip`, `restorePlanUsageChip`, `handleInit`), `settings-ui.js` (toggle + `applyHeaderVisibilitySettings`), `index.html` (chip + toggle row), `styles.css` (chip + colors), `session-ui.js` (create payload).
## Bugs E2E testing caught (that unit tests didn't)
The first "shipped" build passed every test and was broken in practice. End-to-end testing on the real install (the lesson: drive a REAL session, observe the REAL output) surfaced:
1. **`CASES_DIR` injection gate** excluded the user's whole workflow — sessions run in linked cases / real repos, not under `~/codeman-cases`. → dropped the gate.
2. **`curl -s` → `000`** on the loopback self-signed HTTPS cert; statusline silently empty. → `curl -sk`.
3. **Remove-on-create-false + shared `settings.local.json`** let a single stale client yank the statusLine out from under all sessions in a repo. → add-only on create; removal only via the toggle reconcile.
4. **Chip blank after reload** (localStorage-only, lost on restart/fresh browser). → server-side last-known in the init snapshot.
## Open questions / future
- **Schema stability.** `rate_limits` is officially shipped but undocumented in exact shape; the parser is tolerant (renders whatever windows exist, ignores unknown).
- **Hook `curl -s` parity.** Hooks share the no-`-k` issue on HTTPS installs — worth fixing the hook curl too (separate change; covered by `cod54` tests).
- **Disable cleanliness.** Disabling removes the statusLine from active sessions; a brand-new session created by a *stale* client could re-add it (chip still hidden, footer benign). Fully server-authoritative create-time injection (read the setting server-side instead of the payload flag) would close this — deferred.
## Verification appendix — how the schema was captured (reproducible)
Captured without touching global settings or any real session:
1. Throwaway dir `/tmp/sl-capture` with an exporter `dump.sh` that appends stdin to `payloads.jsonl` and prints `cap`; a `settings.json` pointing `statusLine.command` at it.
2. `--print` mode does **not** render a statusline → no capture (confirms TUI-only). Must use interactive.
3. Launch interactive Claude in an **isolated tmux socket** (`tmux -L slcap`, never `-L codeman`) inside the temp dir, `--settings /tmp/sl-capture/settings.json` (no global mutation). Confirm the workspace-trust dialog (appears even with `--dangerously-skip-permissions`), then send a one-line prompt (literal text + Enter separately, Ink-style).
4. After the first response, `rate_limits` appears in the **second** captured record (absent in the first). Inspect with `jq '.rate_limits'`.
5. Tear down: `tmux -L slcap kill-server` + `rm -rf /tmp/sl-capture`; verify the `codeman` socket is untouched.
Related: `docs/claude-code-hooks-reference.md` (hook callback pattern), `src/usage-limit-patterns.ts` (reactive fallback), `docs/respawn-state-machine.md` (auto-resume interplay).
+9 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.9.12",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.9.12",
"version": "1.1.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -20,6 +20,7 @@
"@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
@@ -4525,6 +4526,12 @@
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-serialize": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.9.12",
"version": "1.1.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -61,6 +61,7 @@
"@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
+3
View File
@@ -45,6 +45,7 @@ run('copy template', 'cp src/templates/case-template.md dist/templates/');
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/lib/addon-serialize.js --minify --outfile=dist/web/public/vendor/xterm-addon-serialize.min.js');
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
@@ -66,6 +67,7 @@ appendFileSync(
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
@@ -89,6 +91,7 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
'sanitize-html.js',
'app.js',
'terminal-ui.js',
'respawn-ui.js',
+3
View File
@@ -252,6 +252,7 @@ if (isGlobalInstall) {
const require = createRequire(import.meta.url);
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
const serializeDir = join(require.resolve('@xterm/addon-serialize'), '..', '..');
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
const vendorDir = join(srcDir, 'web', 'public', 'vendor');
@@ -264,12 +265,14 @@ if (isGlobalInstall) {
try {
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(serializeDir, 'lib', 'addon-serialize.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-serialize.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
} catch {
// Fallback: copy unminified
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
copyFileSync(join(serializeDir, 'lib', 'addon-serialize.js'), join(vendorDir, 'xterm-addon-serialize.min.js'));
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @fileoverview Parses terminal magic links that request attachment cards.
*/
import { isAbsolute } from 'node:path';
import { isSupportedAttachmentExtension } from './attachment-registry.js';
const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g;
export function parseAttachmentMagicLinks(data: string): string[] {
const results: string[] = [];
const seen = new Set<string>();
for (const match of data.matchAll(MAGIC_LINK_RE)) {
const query = trimTrailingPunctuation(match[1] || '');
try {
const params = new URLSearchParams(query);
const filePath = params.get('path');
if (!filePath || !isAbsolute(filePath)) continue;
const extension = filePath.split('.').pop()?.toLowerCase() || '';
if (!isSupportedAttachmentExtension(extension)) continue;
if (seen.has(filePath)) continue;
seen.add(filePath);
results.push(filePath);
} catch {
// Ignore malformed terminal text. Magic links are advisory.
}
}
return results;
}
function trimTrailingPunctuation(value: string): string {
return value.replace(/[),.;:]+$/g, '');
}
+237
View File
@@ -0,0 +1,237 @@
/**
* @fileoverview In-memory attachment registry for live external document references.
*
* Session-local files keep using the existing workspace-scoped file routes. This
* registry is only for explicit, live external attachments that need a stable ID
* so browser requests never contain arbitrary absolute paths.
*/
import { randomUUID } from 'node:crypto';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { basename, extname, isAbsolute } from 'node:path';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { validateSessionFilePath } from './web/route-helpers.js';
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set(['png', 'pdf', 'docx', 'pptx', 'md', 'txt']);
export type AttachmentSource = 'detected' | 'external';
export interface AttachmentRecord {
attachmentId: string;
sessionId: string;
filePath: string;
fileName: string;
extension: string;
attachmentType: AttachmentDetectedType;
size: number;
mtimeMs: number;
timestamp: number;
source: AttachmentSource;
}
export interface AttachmentRegistrationResult extends AttachmentDetectedEvent {
attachmentId: string;
source: AttachmentSource;
rawUrl: string;
previewUrl: string;
thumbnailUrl: string;
}
export class AttachmentRegistrationError extends Error {
constructor(
message: string,
readonly statusCode: number = 400
) {
super(message);
}
}
/** Per-session attachment cap. Bounds memory against a client (or a
* prompt-injected magic-link flood) registering unbounded distinct paths. */
const MAX_ATTACHMENTS_PER_SESSION = 200;
class AttachmentRegistry {
private recordsBySession = new Map<string, Map<string, AttachmentRecord>>();
register(record: AttachmentRecord): void {
let records = this.recordsBySession.get(record.sessionId);
if (!records) {
records = new Map();
this.recordsBySession.set(record.sessionId, records);
}
records.set(record.attachmentId, record);
// Evict oldest (insertion-order) entries beyond the cap.
while (records.size > MAX_ATTACHMENTS_PER_SESSION) {
const oldest = records.keys().next().value;
if (oldest === undefined) break;
records.delete(oldest);
}
}
get(sessionId: string, attachmentId: string): AttachmentRecord | undefined {
return this.recordsBySession.get(sessionId)?.get(attachmentId);
}
findByFilePath(sessionId: string, filePath: string): AttachmentRecord | undefined {
const records = this.recordsBySession.get(sessionId);
if (!records) return undefined;
for (const record of records.values()) {
if (record.filePath === filePath) return record;
}
return undefined;
}
clearSession(sessionId: string): void {
this.recordsBySession.delete(sessionId);
}
}
export const attachmentRegistry = new AttachmentRegistry();
export function isSupportedAttachmentExtension(extension: string): boolean {
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
}
export function getAttachmentType(extension: string): AttachmentDetectedType {
const normalized = extension.toLowerCase().replace(/^\./, '');
if (normalized === 'png') return 'image';
if (normalized === 'pdf') return 'pdf';
if (normalized === 'pptx') return 'presentation';
if (normalized === 'md') return 'markdown';
if (normalized === 'txt') return 'text';
return 'document';
}
export function buildAttachmentRoutes(
sessionId: string,
attachmentId: string
): {
rawUrl: string;
previewUrl: string;
thumbnailUrl: string;
} {
const encodedId = encodeURIComponent(attachmentId);
return {
rawUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/raw`,
previewUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/preview`,
thumbnailUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/thumbnail`,
};
}
export function buildFileThumbnailRoute(sessionId: string, relativePath: string): string {
return `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(relativePath)}`;
}
export function attachmentRecordToEvent(record: AttachmentRecord): AttachmentRegistrationResult {
const routes = buildAttachmentRoutes(record.sessionId, record.attachmentId);
return {
sessionId: record.sessionId,
filePath: record.fileName,
relativePath: '',
fileName: record.fileName,
extension: record.extension,
attachmentType: record.attachmentType,
timestamp: record.timestamp,
size: record.size,
attachmentId: record.attachmentId,
source: record.source,
...routes,
};
}
/** Options for {@link registerExternalAttachment}. */
export interface RegisterExternalAttachmentOptions {
/**
* The registering session's working directory. Required to enforce workspace
* confinement — either when the global mode is enabled
* (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) or when
* {@link forceWorkspaceConfinement} is set for this call.
*/
sessionWorkingDir?: string;
/**
* Force workspace confinement for THIS registration regardless of the global
* setting. Used by the terminal-output `codeman://attach` magic-link scanner:
* terminal output is attacker-influenceable (a prompt-injected session can
* print an arbitrary path), so passive magic links may only reference files
* inside the session workspace. Deliberate cross-workspace attachment still
* works through the explicit, Origin-guarded `POST /attachments` route and the
* `codeman attach` CLI (which POSTs directly when a session id is known).
*/
forceWorkspaceConfinement?: boolean;
}
export async function registerExternalAttachment(
sessionId: string,
requestedPath: string,
options: RegisterExternalAttachmentOptions = {}
): Promise<AttachmentRegistrationResult> {
if (!requestedPath || !isAbsolute(requestedPath)) {
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
}
let resolvedPath: string;
try {
resolvedPath = realpathSync(requestedPath);
} catch {
throw new AttachmentRegistrationError('Attachment file not found', 404);
}
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
// path before doing anything else.
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace || options.forceWorkspaceConfinement) {
// Workspace-confined: the file MUST resolve inside the session's workspace.
// Applies when the global strict mode is on (opt-in, default OFF) OR when
// the caller forces it for this registration (the magic-link scanner — see
// forceWorkspaceConfinement). Strictly more restrictive than the blocklist.
const workingDir = options.sessionWorkingDir;
if (!workingDir || !validateSessionFilePath(workingDir, resolvedPath)) {
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
}
}
// Blocklist (DEFAULT, also applied alongside confinement as defense in
// depth): pre-populated secret locations + the /root and /etc trees + any
// operator-configured extra trees. Symlinks are already resolved above.
// Cross-workspace attachment of non-blocked files stays allowed, so
// codeman-publish and the ~/.codeman review loop keep working.
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
}
const extension = extname(resolvedPath).toLowerCase().replace(/^\./, '');
if (!isSupportedAttachmentExtension(extension)) {
throw new AttachmentRegistrationError('Unsupported attachment type');
}
const stat = await fs.stat(resolvedPath);
if (typeof stat.isFile === 'function' && !stat.isFile()) {
throw new AttachmentRegistrationError('Attachment path is not a file');
}
const existing = attachmentRegistry.findByFilePath(sessionId, resolvedPath);
if (existing) {
existing.size = stat.size;
existing.mtimeMs = stat.mtimeMs ?? 0;
existing.timestamp = Date.now();
return attachmentRecordToEvent(existing);
}
const record: AttachmentRecord = {
attachmentId: `att_${randomUUID()}`,
sessionId,
filePath: resolvedPath,
fileName: basename(resolvedPath),
extension,
attachmentType: getAttachmentType(extension),
size: stat.size,
mtimeMs: stat.mtimeMs ?? 0,
timestamp: Date.now(),
source: 'external',
};
attachmentRegistry.register(record);
return attachmentRecordToEvent(record);
}
+123
View File
@@ -10,11 +10,17 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { createRequire } from 'module';
import http from 'node:http';
import https from 'node:https';
import { readFileSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { dataPath } from './config/instance.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
import { getStore } from './state-store.js';
import { getErrorMessage } from './types.js';
import { isSupportedAttachmentExtension } from './attachment-registry.js';
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { version: string };
@@ -23,6 +29,93 @@ const program = new Command();
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
function makeAttachmentMagicLink(filePath: string): string {
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
}
function readCodemanEnv(): Record<string, string> {
const envPath = dataPath('.env');
try {
const text = readFileSync(envPath, 'utf-8');
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
} catch {
return {};
}
}
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
const envFile = readCodemanEnv();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
const body = JSON.stringify({ path: filePath });
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
const headers: Record<string, string | number> = {
Accept: 'application/json',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
};
if (password) {
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: 'POST',
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
},
(res) => {
res.resume();
res.on('end', () => resolve(Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 300)));
}
);
req.on('error', () => resolve(false));
req.write(body);
req.end();
});
}
program
.command('attach <path>')
.description('Show an attachment card for a local file')
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL or https://127.0.0.1:3000)')
.action(async (filePath, options) => {
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
console.error(chalk.red('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
process.exit(1);
}
const sessionId = options.session || process.env.CODEMAN_SESSION_ID;
const apiUrl = options.url || process.env.CODEMAN_API_URL || 'https://127.0.0.1:3000';
if (sessionId && (await postAttachment(apiUrl, sessionId, filePath))) {
console.log(chalk.green('✓ Attachment card requested'));
return;
}
console.log(makeAttachmentMagicLink(filePath));
});
// ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
@@ -533,4 +626,34 @@ program
}
});
program
.command('doctor')
.alias('check-deps')
.description('Check Codeman tool dependencies (Node, Claude CLI, tmux, LibreOffice, MS Office)')
.option('--json', 'Output structured JSON instead of a table')
.option('--category <name>', 'Only check one category (core|office|other)')
.action(async (options) => {
const { createRealHost, checkAll } = await import('./utils/dependency-checker.js');
const { renderTable, renderJson, computeExitCode } = await import('./utils/dependency-report.js');
const { DEPENDENCY_REGISTRY, TOOL_CATEGORIES } = await import('./config/dependency-registry.js');
if (options.category && !(TOOL_CATEGORIES as readonly string[]).includes(options.category)) {
console.error(`Unknown category "${options.category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`);
process.exit(2);
}
const host = createRealHost();
const registry = options.category
? DEPENDENCY_REGISTRY.filter((t) => t.category === options.category)
: DEPENDENCY_REGISTRY;
const results = checkAll(registry, host);
if (options.json) {
console.log(JSON.stringify(renderJson(results, host.environment), null, 2));
} else {
console.log(renderTable(results, host.environment));
}
process.exit(computeExitCode(results));
});
export { program };
+133
View File
@@ -0,0 +1,133 @@
/**
* @fileoverview Attachment path-guard configuration (COD-53).
*
* Governs which host files may be registered as cross-workspace attachments
* and served to the browser. Two operator-facing knobs, both with safe
* defaults:
*
* 1. **Blocked-path blocklist (DEFAULT, configurable).** Pre-populated with the
* shared secret-location blocklist (`isSensitivePath`) PLUS the directory
* trees `/root` and `/etc` (anything under them is blocked). The operator
* EXTENDS — never shrinks — this set with additional absolute directory
* trees via the settings key `attachmentBlockedPaths: string[]` and/or the
* env var `CODEMAN_ATTACHMENT_BLOCKED_PATHS` (comma-separated).
*
* 2. **Workspace confinement (OPTIONAL, default OFF).** When enabled, an
* attachment must resolve INSIDE the registering session's workingDir
* (reusing `validateSessionFilePath` containment semantics). This is
* strictly more restrictive than the blocklist and breaks intentional
* cross-workspace attachment (codeman-publish, the ~/.codeman review-card
* loop), so it is OFF by default. Toggle via settings
* `attachmentConfineToWorkspace: boolean` and/or env
* `CODEMAN_ATTACHMENT_CONFINE` (`1`/`true`).
*
* All paths passed to the predicates here MUST be absolute and symlink-resolved
* (realpath) by the caller, mirroring `isSensitivePath`'s contract.
*
* @module config/attachment-guard
*/
import { sep } from 'node:path';
import { isSensitivePath } from '../web/sensitive-path.js';
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
/**
* Directory trees blocked by default, IN ADDITION to the secret-location
* blocklist in `isSensitivePath`. Anything resolving under one of these trees
* is rejected. Pre-populated with the root account home and the system config
* tree (which already partially overlaps `isSensitivePath`'s `/etc/shadow`
* etc., but here we block the WHOLE tree).
*/
export const DEFAULT_BLOCKED_TREES: readonly string[] = ['/root', '/etc'];
/** Settings key carrying extra blocked directory trees (extends the defaults). */
export const ATTACHMENT_BLOCKED_PATHS_SETTING = 'attachmentBlockedPaths';
/** Settings key carrying the workspace-confinement toggle. */
export const ATTACHMENT_CONFINE_SETTING = 'attachmentConfineToWorkspace';
/** Resolved attachment-guard configuration. */
export interface AttachmentGuardConfig {
/** Pre-populated default trees PLUS any operator extras. */
blockedTrees: string[];
/** Whether attachments must resolve inside the session workspace. */
confineToWorkspace: boolean;
}
/** Normalizes a tree prefix: trim, drop trailing separators (but keep root). */
function normalizeTree(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
// Strip trailing slashes so '/etc/' and '/etc' behave the same; never reduce
// a bare separator to empty.
const stripped = trimmed.replace(/[/\\]+$/, '');
return stripped || trimmed[0];
}
/**
* Returns true if `absPath` (absolute, symlink-resolved) is the tree itself or
* lives under it. Uses path-separator-aware matching so `/etc` does NOT block
* an unrelated `/etcetera/notes.md`.
*/
export function isUnderTree(absPath: string, tree: string): boolean {
const t = normalizeTree(tree);
if (!t) return false;
if (absPath === t) return true;
return absPath.startsWith(t.endsWith(sep) ? t : t + sep);
}
/** Parses the comma-separated env override into a list of normalized trees. */
function parseEnvBlockedTrees(): string[] {
const raw = process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
if (!raw) return [];
return raw
.split(',')
.map(normalizeTree)
.filter((t) => t.length > 0);
}
/** Parses the env confinement toggle (`1`/`true`/`yes`/`on`, case-insensitive). */
function parseEnvConfine(): boolean | undefined {
const raw = process.env.CODEMAN_ATTACHMENT_CONFINE;
if (raw === undefined) return undefined;
return /^(1|true|yes|on)$/i.test(raw.trim());
}
/**
* Loads the effective attachment-guard config by merging the pre-populated
* defaults with settings.json and env overrides. Env wins over settings for the
* confinement toggle; blocked-tree extras from BOTH sources are unioned on top
* of the defaults (operators can only EXTEND, never shrink, the blocked set).
*/
export async function loadAttachmentGuardConfig(): Promise<AttachmentGuardConfig> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const settingsTrees = Array.isArray(settings[ATTACHMENT_BLOCKED_PATHS_SETTING])
? (settings[ATTACHMENT_BLOCKED_PATHS_SETTING] as unknown[])
.filter((v): v is string => typeof v === 'string')
.map(normalizeTree)
.filter((t) => t.length > 0)
: [];
const blockedTrees = Array.from(new Set([...DEFAULT_BLOCKED_TREES, ...settingsTrees, ...parseEnvBlockedTrees()]));
const envConfine = parseEnvConfine();
const settingsConfine = settings[ATTACHMENT_CONFINE_SETTING] === true;
const confineToWorkspace = envConfine ?? settingsConfine;
return { blockedTrees, confineToWorkspace };
}
/**
* Attachment-specific blocklist check. Builds on the shared `isSensitivePath`
* base (secret locations, shared with `/api/download`) and ADDS the configured
* directory trees (`/root`, `/etc`, plus operator extras). `absPath` must be
* absolute and symlink-resolved.
*
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
* behavior is NOT changed — only attachment registration/serving uses this.
*/
export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly string[]): boolean {
if (isSensitivePath(absPath)) return true;
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
}
+140
View File
@@ -0,0 +1,140 @@
/**
* @fileoverview Static registry of downstream tool dependencies probed by
* `codeman doctor`. Each entry declares per-environment resolvers and the
* skills that use it. EXTENSION POINT: skill-manifest-driven discovery
* (COD follow-up) will merge dynamically-found entries into this list.
*
* @module config/dependency-registry
*/
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
/** The valid `--category` filter values; single source of truth for the type, the CLI
* help text, and CLI input validation. */
export const TOOL_CATEGORIES = ['core', 'office', 'other'] as const;
export type ToolCategory = (typeof TOOL_CATEGORIES)[number];
/** Resolve a binary on the PATH and read its version. */
export interface PathResolver {
kind: 'path';
bins: string[];
versionArg?: string; // default '--version'
versionRegex?: RegExp; // default matches first \d+.\d+(.\d+)?
}
/** Resolve a Windows-installed app reachable from win32 or WSL. */
export interface WindowsSideResolver {
kind: 'windows-side';
appDirs: string[]; // relative to a Program Files root
exes: string[]; // candidate executables; first found wins
}
export interface ResolverSpec {
match: ProbeEnvironment[];
resolver: PathResolver | WindowsSideResolver;
}
export interface ToolDependency {
id: string;
label: string;
category: ToolCategory;
required: boolean;
usedBy?: string[];
minVersion?: string;
resolvers: ResolverSpec[];
installHint?: Partial<Record<ProbeEnvironment, string>>;
}
const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
export const DEPENDENCY_REGISTRY: ToolDependency[] = [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
minVersion: '22.0.0',
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
},
{
id: 'claude',
label: 'Claude CLI',
category: 'core',
required: false,
usedBy: ['Claude Code sessions (default backend)'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }],
installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' },
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
},
{
id: 'opencode',
label: 'OpenCode CLI',
category: 'core',
required: false,
usedBy: ['OpenCode sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }],
},
{
id: 'codex',
label: 'Codex CLI',
category: 'core',
required: false,
usedBy: ['Codex sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
},
{
id: 'libreoffice',
label: 'LibreOffice',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['linux', 'darwin', 'wsl'],
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
},
],
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
},
{
id: 'pdftoppm',
label: 'pdftoppm',
category: 'office',
required: false,
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
resolvers: [
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
],
installHint: {
linux: 'sudo apt install poppler-utils',
darwin: 'brew install poppler',
wsl: 'sudo apt install poppler-utils',
},
},
{
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['wsl', 'win32'],
resolver: {
kind: 'windows-side',
appDirs: ['Microsoft Office/root/Office16'],
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
},
},
],
},
];
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview Per-instance shared hook secret (COD-54).
*
* Claude Code hooks POST to `/api/hook-event` with no Basic-Auth credentials,
* relying on a localhost bypass in `web/middleware/auth.ts`. That bypass is safe
* for loopback-only deploys, but a `cloudflared --url http://127.0.0.1:port`
* tunnel proxies internet traffic INTO the loopback origin, so tunneled requests
* arrive with `req.ip === 127.0.0.1` and would otherwise pass the bypass and
* drive respawn/Ralph signals unauthenticated.
*
* To close that hole WITHOUT breaking the loop's own (credential-less) hook
* channel, every locally-generated hook command now presents a per-instance
* shared secret in the `X-Codeman-Hook-Secret` header. The middleware requires
* a matching secret for the bypass WHEN A TUNNEL IS RUNNING. Tunneled internet
* traffic can't know the secret; local hooks (which we generate) do.
*
* Storage mirrors the VAPID-key pattern in `push-store.ts`: a small file under
* the instance data dir (`dataPath('hook-secret')`), read-if-present /
* generate-if-missing, stable across restarts. 256 bits of hex.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { getDataDir, dataPath } from './instance.js';
/** HTTP header local hooks use to present the shared secret. */
export const HOOK_SECRET_HEADER = 'X-Codeman-Hook-Secret';
/** Number of random bytes in the secret (256 bits → 64 hex chars). */
const SECRET_BYTES = 32;
let cachedSecret: string | null = null;
/**
* Return this instance's hook secret, generating and persisting it on first use.
* Stable across restarts. Cached in-process after the first read.
*/
export function getHookSecret(): string {
if (cachedSecret) return cachedSecret;
const secretFile = dataPath('hook-secret');
if (existsSync(secretFile)) {
try {
const raw = readFileSync(secretFile, 'utf-8').trim();
if (raw) {
cachedSecret = raw;
return cachedSecret;
}
// Empty/whitespace file — fall through and regenerate.
} catch {
// Unreadable — fall through and regenerate.
}
}
const secret = randomBytes(SECRET_BYTES).toString('hex');
try {
mkdirSync(getDataDir(), { recursive: true });
// Owner-only perms — the secret gates the hook bypass.
writeFileSync(secretFile, secret, { mode: 0o600 });
} catch {
// Best-effort persistence: even if the write fails we still return a usable
// secret for this process so hooks/middleware agree within this run.
}
cachedSecret = secret;
return cachedSecret;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview Global concurrency limiter for spawning external document
* converters (pdftoppm / LibreOffice `soffice` / Word-COM `powershell.exe`).
*
* Without a cap, N simultaneous thumbnail/preview requests for *distinct*
* documents fork N converter processes at once — each held open for up to the
* multi-minute conversion timeout. That is a localhost resource-exhaustion
* (fork-bomb-shaped) vector: a handful of large PDFs detected at once can pin
* CPU and RAM. This module serializes converter spawns down to a small fixed
* pool; excess spawns queue (FIFO) until a slot frees. The in-flight cache in
* `document-preview-cache.ts` already de-dups *identical* inputs; this bounds
* the *distinct* case the cache can't.
*
* Permit accounting transfers the slot directly to the next waiter on release
* (rather than decrement-then-reacquire) so the active count can never exceed
* the cap even under interleaved async resumption.
*
* NOT re-entrant: never call `runWithConversionLimit` from inside a task that is
* already holding a slot — a nested acquire under a full pool would deadlock.
* The converter call sites only ever acquire once per request (the office path
* acquires for `soffice` and `pdftoppm` sequentially, not nested).
*/
/**
* Max converter processes allowed to run concurrently across the whole process.
* Override with CODEMAN_MAX_DOCUMENT_CONVERSIONS (clamped to >= 1).
*/
const MAX_CONCURRENT_DOCUMENT_CONVERSIONS = (() => {
const raw = Number(process.env.CODEMAN_MAX_DOCUMENT_CONVERSIONS);
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3;
})();
let active = 0;
const waiters: Array<() => void> = [];
/** Test/diagnostic hook: converters currently holding a slot. */
export function getActiveConversionCount(): number {
return active;
}
function acquire(): Promise<void> {
if (active < MAX_CONCURRENT_DOCUMENT_CONVERSIONS) {
active++;
return Promise.resolve();
}
return new Promise<void>((resolve) => waiters.push(resolve));
}
function release(): void {
const next = waiters.shift();
if (next) {
// Hand the slot straight to the next waiter — `active` stays at the cap.
next();
} else {
active--;
}
}
/** Run `task` once a converter slot is free, releasing the slot afterward. */
export async function runWithConversionLimit<T>(task: () => Promise<T>): Promise<T> {
await acquire();
try {
return await task();
} finally {
release();
}
}
+308
View File
@@ -0,0 +1,308 @@
/**
* @fileoverview Shared disk cache for expensive Office document previews.
*/
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, extname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import { runWithConversionLimit } from './document-conversion-limiter.js';
const execFileAsync = promisify(execFile);
const OFFICE_CONVERSION_TIMEOUT_MS = 5 * 60_000;
const DOCUMENT_PREVIEW_CACHE_DIR = join(tmpdir(), 'codeman-document-preview-cache');
/**
* Cap on persistent converted-PDF files kept in DOCUMENT_PREVIEW_CACHE_DIR.
* The cache key embeds the source mtime, so every edit to a doc orphans its
* prior PDF; without a cap the dir grows unbounded across long-running sessions.
* Override with CODEMAN_MAX_PREVIEW_CACHE_FILES (clamped to >= 1).
*/
const MAX_PREVIEW_CACHE_FILES = (() => {
const raw = Number(process.env.CODEMAN_MAX_PREVIEW_CACHE_FILES);
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 100;
})();
function buildWordExportPdfScript(sourcePath: string, outputPath: string): string {
return `
$ErrorActionPreference = "Stop"
$source = ${toPowerShellSingleQuotedString(sourcePath)}
$output = ${toPowerShellSingleQuotedString(outputPath)}
$word = $null
$doc = $null
try {
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
$doc = $word.Documents.Open($source)
$doc.ExportAsFixedFormat($output, 17)
} finally {
if ($null -ne $doc) {
$doc.Close($false) | Out-Null
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
}
if ($null -ne $word) {
$word.Quit() | Out-Null
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
}
[System.GC]::Collect()
[System.GC]::WaitForPendingFinalizers()
}
`.trim();
}
type OfficePreviewConverter = 'msword' | 'libreoffice';
const inFlightOfficeConversions = new Map<string, Promise<string | null>>();
export function clearDocumentPreviewCache(): void {
inFlightOfficeConversions.clear();
}
/**
* Best-effort LRU-ish eviction for the persistent converted-PDF cache: keeps at
* most MAX_PREVIEW_CACHE_FILES `*.pdf` files in `cacheDir`, deleting the oldest
* by mtime once over the cap. Never throws — a pruning failure must not fail the
* conversion that triggered it. Only `*.pdf` files are considered, so the
* transient `work-*` mkdtemp dirs are ignored.
*/
export async function pruneDocumentPreviewCache(cacheDir: string): Promise<void> {
try {
const entries = await fs.readdir(cacheDir);
const pdfs = entries.filter((name) => name.toLowerCase().endsWith('.pdf'));
if (pdfs.length <= MAX_PREVIEW_CACHE_FILES) return;
const stats = await Promise.all(
pdfs.map(async (name) => {
const fullPath = join(cacheDir, name);
try {
const stat = await fs.stat(fullPath);
return { fullPath, mtimeMs: stat.mtimeMs ?? 0 };
} catch {
return null;
}
})
);
const sorted = stats.filter((s): s is { fullPath: string; mtimeMs: number } => s !== null);
sorted.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
const toRemove = sorted.slice(0, Math.max(0, sorted.length - MAX_PREVIEW_CACHE_FILES));
await Promise.all(toRemove.map((entry) => fs.rm(entry.fullPath, { force: true }).catch(() => {})));
} catch {
// Best-effort: pruning must never break a conversion.
}
}
export async function getOfficePreviewPdfPath(filePath: string, extension: string): Promise<string | null> {
const ext = extension.toLowerCase().replace(/^\./, '');
if (ext !== 'docx' && ext !== 'pptx') return null;
let sourceStat;
try {
sourceStat = await fs.stat(filePath);
} catch {
return null;
}
for (const converter of getOfficePreviewConverters(filePath, ext)) {
const cacheKey = createDocumentPreviewCacheKey(filePath, ext, sourceStat.size, sourceStat.mtimeMs ?? 0, converter);
const cachePath = getOfficePreviewCachePath(filePath, cacheKey, converter);
if (await fileExists(cachePath)) {
return cachePath;
}
const inFlightKey = `${converter}:${cacheKey}`;
const inFlight = inFlightOfficeConversions.get(inFlightKey);
if (inFlight) {
const converted = await inFlight;
if (converted) return converted;
continue;
}
const conversion =
converter === 'msword'
? convertWordDocumentToCachedPdf(filePath, cachePath)
: convertLibreOfficeDocumentToCachedPdf(filePath, cachePath);
inFlightOfficeConversions.set(inFlightKey, conversion);
try {
const converted = await conversion;
if (converted) return converted;
} finally {
inFlightOfficeConversions.delete(inFlightKey);
}
}
return null;
}
function getOfficePreviewConverters(filePath: string, extension: string): OfficePreviewConverter[] {
if (extension === 'docx' && wslMountPathToWindowsPath(filePath)) {
return ['msword', 'libreoffice'];
}
return ['libreoffice'];
}
function createDocumentPreviewCacheKey(
filePath: string,
extension: string,
size: number,
mtimeMs: number,
converter: OfficePreviewConverter
): string {
return createHash('sha256')
.update(JSON.stringify({ cacheVersion: 2, converter, filePath, extension, size, mtimeMs }))
.digest('hex')
.slice(0, 32);
}
function getOfficePreviewCachePath(filePath: string, cacheKey: string, converter: OfficePreviewConverter): string {
if (converter === 'msword') {
const windowsCacheDir = getWindowsUserTempCacheDir(filePath);
if (windowsCacheDir) {
return join(windowsCacheDir, `${cacheKey}.pdf`);
}
}
return join(DOCUMENT_PREVIEW_CACHE_DIR, `${cacheKey}.pdf`);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return typeof stat.isFile !== 'function' || stat.isFile();
} catch {
return false;
}
}
async function convertWordDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
const outputPath = wslMountPathToWindowsPath(cachePath);
if (!outputPath) return null;
let sourceCopyPath: string | undefined;
try {
await fs.mkdir(dirname(cachePath), { recursive: true });
sourceCopyPath = join(dirname(cachePath), `${basename(cachePath, '.pdf')}.docx`);
await fs.copyFile(filePath, sourceCopyPath);
const sourcePath = wslMountPathToWindowsPath(sourceCopyPath);
if (!sourcePath) return null;
await runWithConversionLimit(() =>
execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encodePowerShellCommand(buildWordExportPdfScript(sourcePath, outputPath)),
],
{
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
}
)
);
if (await fileExists(cachePath)) {
await pruneDocumentPreviewCache(dirname(cachePath));
return cachePath;
}
console.warn(`[DocumentPreviewCache] Microsoft Word did not produce PDF output for ${filePath}`);
return null;
} catch (err) {
console.warn(
`[DocumentPreviewCache] Failed to convert DOCX with Microsoft Word (${filePath}):`,
getCacheErrorMessage(err)
);
return null;
} finally {
if (sourceCopyPath) {
await fs.rm(sourceCopyPath, { force: true }).catch(() => {});
}
}
}
async function convertLibreOfficeDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
let workDir: string | undefined;
try {
await fs.mkdir(DOCUMENT_PREVIEW_CACHE_DIR, { recursive: true });
const outDir = await fs.mkdtemp(join(DOCUMENT_PREVIEW_CACHE_DIR, 'work-'));
workDir = outDir;
const profileDir = join(outDir, 'profile');
await fs.mkdir(profileDir, { recursive: true });
await runWithConversionLimit(() =>
execFileAsync(
'soffice',
[
'--headless',
'--nologo',
'--nofirststartwizard',
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
'--convert-to',
'pdf',
'--outdir',
outDir,
filePath,
],
{
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
}
)
);
const converted = (await fs.readdir(outDir)).find((name) => name.toLowerCase().endsWith('.pdf'));
if (!converted) return null;
await fs.rename(join(workDir, converted), cachePath);
await pruneDocumentPreviewCache(DOCUMENT_PREVIEW_CACHE_DIR);
return cachePath;
} catch (err) {
console.warn(
`[DocumentPreviewCache] Failed to convert Office file to PDF (${filePath}):`,
getCacheErrorMessage(err)
);
return null;
} finally {
if (workDir) {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
}
function wslMountPathToWindowsPath(filePath: string): string | null {
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/(.+)$/);
if (!match) return null;
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
}
function getWindowsUserTempCacheDir(filePath: string): string | null {
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/Users\/([^/]+)\//);
if (!match) return null;
return `/mnt/${match[1].toLowerCase()}/Users/${match[2]}/AppData/Local/Temp/codeman-document-preview-cache`;
}
function toPowerShellSingleQuotedString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
function encodePowerShellCommand(script: string): string {
return Buffer.from(script, 'utf16le').toString('base64');
}
export function getPreviewPdfDownloadName(fileName: string, extension: string): string {
return `${basename(fileName, extname(fileName) || `.${extension}`)}.pdf`;
}
function getCacheErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+88
View File
@@ -0,0 +1,88 @@
/**
* @fileoverview Best-effort first-page thumbnails for attachment cards.
*/
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, extname, join } from 'node:path';
import { promisify } from 'node:util';
import { getOfficePreviewPdfPath } from './document-preview-cache.js';
import { runWithConversionLimit } from './document-conversion-limiter.js';
const execFileAsync = promisify(execFile);
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
export interface ThumbnailResult {
content: Buffer;
contentType: 'image/png';
}
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
const ext = extension.toLowerCase().replace(/^\./, '');
try {
await fs.stat(filePath);
if (ext === 'png') {
return { content: await fs.readFile(filePath), contentType: 'image/png' };
}
if (ext === 'pdf') {
return renderPdfFirstPage(filePath);
}
if (ext === 'docx' || ext === 'pptx') {
return renderOfficeFirstPage(filePath);
}
} catch (err) {
console.warn(`[Thumbnailer] Failed to generate ${ext} thumbnail for ${filePath}:`, getThumbnailErrorMessage(err));
return null;
}
return null;
}
async function renderOfficeFirstPage(filePath: string): Promise<ThumbnailResult | null> {
try {
const previewPdfPath = await getOfficePreviewPdfPath(filePath, extname(filePath).toLowerCase().replace(/^\./, ''));
if (!previewPdfPath) return null;
return await renderPdfFirstPage(previewPdfPath);
} catch (err) {
console.warn(
`[Thumbnailer] Failed to convert Office file to PDF for thumbnail (${filePath}):`,
getThumbnailErrorMessage(err)
);
return null;
}
}
async function renderPdfFirstPage(filePath: string): Promise<ThumbnailResult | null> {
let previewDir: string | undefined;
try {
previewDir = await fs.mkdtemp(join(tmpdir(), 'codeman-thumb-pdf-'));
const prefix = join(previewDir, basename(filePath, extname(filePath)));
await runWithConversionLimit(() =>
execFileAsync('pdftoppm', ['-png', '-singlefile', '-f', '1', '-l', '1', '-scale-to', '520', filePath, prefix], {
timeout: THUMBNAIL_CONVERSION_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
})
);
const content = await fs.readFile(`${prefix}.png`);
return { content, contentType: 'image/png' };
} catch (err) {
console.warn(
`[Thumbnailer] Failed to render PDF first page for thumbnail (${filePath}):`,
getThumbnailErrorMessage(err)
);
return null;
} finally {
if (previewDir) {
await fs.rm(previewDir, { recursive: true, force: true }).catch(() => {});
}
}
}
function getThumbnailErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+212 -73
View File
@@ -3,8 +3,9 @@
*
* Generates `.claude/settings.local.json` with hook definitions that POST
* to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks.
* Uses `$CODEMAN_API_URL` and `$CODEMAN_SESSION_ID` env vars (set on every
* managed session) so the config is static per case directory.
* Uses `$CODEMAN_API_URL`, `$CODEMAN_SESSION_ID`, and `$CODEMAN_HOOK_SECRET_FILE`
* env vars (set on every managed session) so the config is static per case
* directory and free of secret values.
*
* Key exports:
* - `generateHooksConfig()` — returns hooks object for settings.local.json
@@ -30,6 +31,30 @@ import { join } from 'node:path';
import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
* writer in this module (hooks, env, model, statusLine) shares this map, so
* concurrent updates to the SAME file — e.g. session-create writing hooks/model
* while an App-Settings toggle injects the statusLine into the same repo — can't
* lose each other's changes through interleaved read-then-write. Per-path chains
* are independent; the map self-prunes when a path's chain goes idle.
*/
const settingsWriteLocks = new Map<string, Promise<unknown>>();
function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
// Tail never rejects, so a failed write doesn't poison subsequent writers.
const tail = run.then(
() => {},
() => {}
);
settingsWriteLocks.set(path, tail);
void tail.then(() => {
if (settingsWriteLocks.get(path) === tail) settingsWriteLocks.delete(path);
});
return run;
}
/**
* Generates the hooks section for .claude/settings.local.json
*
@@ -41,11 +66,18 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
// Read Claude Code's stdin JSON and forward it as the data field.
// Falls back to empty object if stdin is unavailable or malformed.
// COD-54: present the per-instance hook secret so the bypass keeps working while
// a tunnel is running. The value is read from the secret file AT EXECUTION TIME
// (path via $CODEMAN_HOOK_SECRET_FILE, set in every managed session's env), so it
// never lands in this config and rotation needs no respawn. If the var/file is
// missing the header is empty — the middleware then allows the request only on
// the plain loopback bypass (tunnel down), same as pre-secret behavior.
const curlCmd = (event: HookEventType) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- ` +
`2>/dev/null || true`;
@@ -95,29 +127,31 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
if (keysToRemove.length === 0) return;
const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return;
await withSettingsLock(settingsPath, async () => {
if (!existsSync(settingsPath)) return;
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // Malformed — don't rewrite it
}
const env = existing.env as Record<string, string> | undefined;
if (!env) return;
let changed = false;
for (const key of keysToRemove) {
if (key in env) {
delete env[key];
changed = true;
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // Malformed — don't rewrite it
}
}
if (!changed) return;
existing.env = env;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
const env = existing.env as Record<string, string> | undefined;
if (!env) return;
let changed = false;
for (const key of keysToRemove) {
if (key in env) {
delete env[key];
changed = true;
}
}
if (!changed) return;
existing.env = env;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
});
}
/**
@@ -126,30 +160,31 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
*/
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
const currentEnv = (existing.env as Record<string, string>) || {};
for (const [key, value] of Object.entries(envVars)) {
if (value) {
currentEnv[key] = value;
} else {
delete currentEnv[key];
await withSettingsLock(settingsPath, async () => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
}
existing.env = currentEnv;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
const currentEnv = (existing.env as Record<string, string>) || {};
for (const [key, value] of Object.entries(envVars)) {
if (value) {
currentEnv[key] = value;
} else {
delete currentEnv[key];
}
}
existing.env = currentEnv;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
});
}
/**
@@ -158,26 +193,27 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
*/
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {};
await withSettingsLock(settingsPath, async () => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
if (model) {
existing.model = model;
} else {
delete existing.model;
}
if (model) {
existing.model = model;
} else {
delete existing.model;
}
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
});
}
/**
@@ -186,22 +222,125 @@ export async function updateCaseModel(casePath: string, model: string | null): P
*/
export async function writeHooksConfig(casePath: string): Promise<void> {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {};
await withSettingsLock(settingsPath, async () => {
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
// If file is malformed or doesn't exist, start fresh
existing = {};
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
// If file is malformed or doesn't exist, start fresh
existing = {};
}
const hooksConfig = generateHooksConfig();
const merged = { ...existing, ...hooksConfig };
const hooksConfig = generateHooksConfig();
const merged = { ...existing, ...hooksConfig };
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
});
}
/**
* Self-heal a case's hooks block so the COD-91 unconditional hook-secret gate keeps
* accepting its hook events.
*
* `writeHooksConfig` only runs when a case is first CREATED. Cases created before the
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
* gate requires it unconditionally (COD-91), silently 401 on a password-protected
* install. This refreshes the hooks block so those stale curls regain the header.
*
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
* Codeman's own hook curls (they target `/api/hook-event`) that lack the secret header.
* No-op when the file/hooks are absent (we never impose hooks on a user who removed
* them), when the hooks aren't ours, or when the secret is already present — so it never
* clobbers a user's customizations and is cheap enough to call on every Claude spawn.
*/
export async function refreshStaleHookSecret(casePath: string): Promise<void> {
const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return;
await withSettingsLock(settingsPath, async () => {
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // malformed — leave it untouched (case-create owns the happy path)
}
const hooksJson = JSON.stringify(existing.hooks ?? null);
const isOurs = hooksJson.includes('/api/hook-event');
// The generated curl carries this header literal (see generateHooksConfig); its
// absence on our own hooks means they predate COD-54 and need regenerating.
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
if (!isOurs || hasSecret) return;
const merged = { ...existing, ...generateHooksConfig() };
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
});
}
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
const STATUSLINE_MARKER = '/api/status-telemetry';
/**
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
* (present in every managed session via tmux setenv), so the config is static.
*/
export function generateStatusLineCommand(): string {
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
// production setup; without -k curl returns 000 and the statusline shows
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
// footer is never blank if Codeman is unreachable.
return (
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- 2>/dev/null || echo codeman`
);
}
/**
* Add or remove Codeman's plan-usage statusLine exporter in
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
* (command targets `/api/status-telemetry`), so a user's hand-authored
* statusLine is never removed OR overwritten — on both the enable and disable
* paths we bail out when an existing statusLine isn't ours. Callers gate on
* Claude mode. Merges, preserving all other keys (hooks, env, model).
*/
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
const claudeDir = join(casePath, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
await withSettingsLock(settingsPath, async () => {
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // Malformed — don't rewrite it
}
}
const current = existing.statusLine as { command?: unknown } | undefined;
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
if (enabled) {
const desired = generateStatusLineCommand();
if (isOurs && current?.command === desired) return; // already current — skip rewrite
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
} else {
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
delete existing.statusLine;
}
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
});
}
+51 -8
View File
@@ -12,7 +12,7 @@ import { EventEmitter } from 'node:events';
import { watch, type FSWatcher } from 'chokidar';
import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs';
import type { ImageDetectedEvent } from './types.js';
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
@@ -20,7 +20,9 @@ import { KeyedDebouncer } from './utils/index.js';
// ========== Constants ==========
/** Supported image file extensions (lowercase) */
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']);
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
/** Time to wait for file writes to stabilize (ms) */
const STABILITY_THRESHOLD_MS = 500;
@@ -166,8 +168,8 @@ export class ImageWatcher extends EventEmitter {
}
const ext = extname(path).toLowerCase();
// Don't ignore directories (needed for watching to work)
// Ignore files that aren't images
return ext !== '' && !IMAGE_EXTENSIONS.has(ext);
// Ignore files that aren't previewable images/documents
return ext !== '' && !DETECTED_FILE_EXTENSIONS.has(ext);
},
});
@@ -229,15 +231,16 @@ export class ImageWatcher extends EventEmitter {
/**
* Handle a new file being detected.
* Verifies it's an image and emits the detection event.
* Verifies it's a previewable image/document and emits the detection event.
*/
private handleNewFile(sessionId: string, filePath: string): void {
const ext = extname(filePath).toLowerCase();
// Double-check it's an image extension
if (!IMAGE_EXTENSIONS.has(ext)) {
// Double-check it's a supported extension
if (!DETECTED_FILE_EXTENSIONS.has(ext)) {
return;
}
const isAttachment = ATTACHMENT_EXTENSIONS.has(ext);
// Burst limit: skip if too many images detected for this session in a short window
const now = Date.now();
@@ -259,7 +262,11 @@ export class ImageWatcher extends EventEmitter {
// Debounce rapid file creation (e.g., multiple screenshots quickly)
this.fileDeb.schedule(filePath, () => {
this.fileToSession.delete(filePath);
this.emitImageDetected(sessionId, filePath);
if (isAttachment) {
this.emitAttachmentDetected(sessionId, filePath);
} else {
this.emitImageDetected(sessionId, filePath);
}
// Increment burst count on actual emission (not on detection)
const b = this.burstTrackers.get(sessionId);
if (b) b.count++;
@@ -294,6 +301,42 @@ export class ImageWatcher extends EventEmitter {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
}
/**
* Emit the attachment:detected event with file metadata.
*/
private emitAttachmentDetected(sessionId: string, filePath: string): void {
try {
const stat = statSync(filePath);
const fileName = basename(filePath);
const workingDir = this.sessionDirs.get(sessionId);
const relativePath = workingDir ? relative(workingDir, filePath) : fileName;
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
const event: AttachmentDetectedEvent = {
sessionId,
filePath,
relativePath,
fileName,
extension,
attachmentType: this.getAttachmentType(extension),
timestamp: Date.now(),
size: stat.size,
};
this.emit('attachment:detected', event);
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
}
}
private getAttachmentType(extension: string): AttachmentDetectedType {
if (extension === 'png') return 'image';
if (extension === 'pdf') return 'pdf';
if (extension === 'docx') return 'document';
if (extension === 'pptx') return 'presentation';
return 'document';
}
}
// Export singleton instance for convenience
+13
View File
@@ -2779,6 +2779,19 @@ export class RespawnController extends EventEmitter {
return;
}
// Usage-limit pause: Claude can't work and the cycle's /clear would wipe
// the paused conversation — the auto-resume scheduler owns recovery here.
if (this.session.isLimitPaused) {
this.log('Skipping respawn cycle - usage-limit pause active (auto-resume armed)');
this.logAction('health', 'Respawn skipped: usage-limit pause (auto-resume armed)');
this.emit('respawnBlocked', {
reason: 'usage_limit',
details: 'Usage limit reached — waiting for scheduled auto-resume',
});
this.setState('watching');
return;
}
// Start the respawn cycle
this.cycleCount++;
this.log(`Starting respawn cycle #${this.cycleCount}`);
+92
View File
@@ -0,0 +1,92 @@
import { createHash } from 'node:crypto';
import { basename, extname } from 'node:path';
import type { SessionAttachmentHistoryItem } from './types/session.js';
import type { AttachmentDetectedEvent } from './types/tools.js';
import { getAttachmentType } from './attachment-registry.js';
export const ATTACHMENT_HISTORY_LIMIT = 100;
export interface ExternalAttachmentHistoryInput {
sessionId: string;
externalPath: string;
fileName?: string;
extension?: string;
size: number;
mtimeMs?: number;
timestamp?: number;
}
export function normalizeAttachmentExtension(extensionOrPath: string): string {
const value = extensionOrPath.startsWith('.') ? extensionOrPath : extname(extensionOrPath) || extensionOrPath;
return value.toLowerCase().replace(/^\./, '');
}
function historyKey(item: SessionAttachmentHistoryItem): string {
if (item.source === 'external' && item.externalPath) {
return `external:${item.externalPath}`;
}
return `detected:${item.relativePath || item.fileName}`;
}
function safeExternalHistoryId(item: SessionAttachmentHistoryItem): string {
const source = item.externalPath || item.id || item.fileName;
const digest = createHash('sha256').update(source).digest('hex').slice(0, 16);
return `external:${digest}:${item.fileName}`;
}
export function sanitizeAttachmentHistoryItem(item: SessionAttachmentHistoryItem): SessionAttachmentHistoryItem {
const { externalPath: _externalPath, ...safe } = item;
return {
...safe,
id: item.source === 'external' ? safeExternalHistoryId(item) : item.id,
};
}
export function sanitizeAttachmentHistory(
history: readonly SessionAttachmentHistoryItem[]
): SessionAttachmentHistoryItem[] {
return history.map(sanitizeAttachmentHistoryItem);
}
export function upsertAttachmentHistory(
history: readonly SessionAttachmentHistoryItem[],
item: SessionAttachmentHistoryItem
): SessionAttachmentHistoryItem[] {
const nextKey = historyKey(item);
return [item, ...history.filter((existing) => historyKey(existing) !== nextKey)].slice(0, ATTACHMENT_HISTORY_LIMIT);
}
export function buildDetectedAttachmentHistoryItem(event: AttachmentDetectedEvent): SessionAttachmentHistoryItem {
return {
id: `detected:${event.relativePath || event.fileName}`,
sessionId: event.sessionId,
fileName: event.fileName,
extension: normalizeAttachmentExtension(event.extension),
attachmentType: event.attachmentType,
size: event.size,
mtimeMs: 0,
timestamp: event.timestamp,
source: 'detected',
relativePath: event.relativePath,
};
}
export function buildExternalAttachmentHistoryItem(
input: ExternalAttachmentHistoryInput
): SessionAttachmentHistoryItem {
const extension = normalizeAttachmentExtension(input.extension || input.fileName || input.externalPath);
return {
id: `external:${createHash('sha256').update(input.externalPath).digest('hex').slice(0, 16)}:${
input.fileName || basename(input.externalPath)
}`,
sessionId: input.sessionId,
fileName: input.fileName || basename(input.externalPath),
extension,
attachmentType: getAttachmentType(extension),
size: input.size,
mtimeMs: input.mtimeMs ?? 0,
timestamp: input.timestamp ?? Date.now(),
source: 'external',
externalPath: input.externalPath,
};
}
+184 -1
View File
@@ -1,15 +1,25 @@
/**
* @fileoverview Auto-compact and auto-clear automation for Session.
* @fileoverview Auto-compact, auto-clear, and auto-resume automation for Session.
*
* Monitors token counts and triggers /compact or /clear commands when
* configurable thresholds are reached. Waits for Claude to be idle
* before sending commands, with retry logic and mutual exclusion
* (compact and clear never run simultaneously).
*
* Also implements auto-resume on usage limit ("token pause" control):
* when enabled and Claude stops on a usage-limit message ("5-hour limit
* reached ∙ resets 8pm" and friends — see usage-limit-patterns.ts), a timer
* is armed for the parsed reset time plus a safety buffer, then Escape
* (dismisses the rate-limit options dialog if open) and a "continue" prompt
* are sent so work resumes automatically. If the session is still limited,
* the fresh limit message re-arms the scheduler — that retry loop is the
* safety net for clock skew and parse imprecision.
*
* @module session-auto-ops
*/
import { EventEmitter } from 'node:events';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
// ============================================================================
// Timing Constants
@@ -78,6 +88,28 @@ async function executeWhenIdle(
}
}
// ============================================================================
// Auto-resume (usage-limit pause) constants
// ============================================================================
/** Safety buffer after the stated reset time before resuming (2 minutes) */
const RESUME_BUFFER_MS = 2 * 60_000;
/** Minimum delay before an overdue resume fires (lets output settle) */
const RESUME_MIN_DELAY_MS = 5_000;
/** Retry interval when the reset time is stale/past (5 minutes) */
const RESUME_RETRY_MS = 5 * 60_000;
/** Re-detections scheduling within this window of the current schedule are ignored */
const RESUME_DEDUP_TOLERANCE_MS = 90_000;
/** Delay between Escape (dialog dismiss) and the resume prompt */
const RESUME_ESC_DELAY_MS = 600;
/** Prompt sent to resume work after the limit resets */
const RESUME_PROMPT = 'continue';
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
const MIN_AUTO_THRESHOLD = 1000;
@@ -131,6 +163,16 @@ export class SessionAutoOps extends EventEmitter {
private _isClearing: boolean = false;
private _autoClearTimer: NodeJS.Timeout | null = null;
// Auto-resume (usage-limit pause) state
private _autoResumeEnabled: boolean = false;
private _autoResumeTimer: NodeJS.Timeout | null = null;
/** Esc→continue gap timer; detections must NOT cancel a resume in flight */
private _resumeFollowupTimer: NodeJS.Timeout | null = null;
/** When the scheduled resume fires (epoch ms), null when not armed */
private _autoResumeAt: number | null = null;
private _limitPaused: boolean = false;
private _resumeAttempts: number = 0;
private readonly callbacks: AutoOpsCallbacks;
constructor(callbacks: AutoOpsCallbacks, config?: { compactThreshold?: number; clearThreshold?: number }) {
@@ -207,6 +249,145 @@ export class SessionAutoOps extends EventEmitter {
}
}
// ============================================================================
// Auto-resume (usage-limit pause) — getters/setters
// ============================================================================
get autoResumeEnabled(): boolean {
return this._autoResumeEnabled;
}
/** When the scheduled resume fires (epoch ms), or null when not armed. */
get autoResumeAt(): number | null {
return this._autoResumeAt;
}
/** True while the session is believed to be paused on a usage limit. */
get isLimitPaused(): boolean {
return this._limitPaused;
}
setAutoResume(enabled: boolean): void {
this._autoResumeEnabled = enabled;
if (!enabled) {
this._cancelAutoResume('disabled');
}
}
/**
* Restore auto-resume state after a Codeman restart. A persisted pending
* schedule is re-armed; an overdue one fires shortly after boot (the limit
* footer won't reprint on its own, so without this the pause would stall).
*/
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
this._autoResumeEnabled = enabled;
if (!enabled || !resumeAt) return;
const now = Date.now();
this._scheduleResume(Math.max(resumeAt, now + RESUME_MIN_DELAY_MS), resumeAt, 'restored');
}
// ============================================================================
// Auto-resume — detection and scheduling
// ============================================================================
/**
* Scan cleaned terminal output for a usage-limit pause message and (re)arm
* the resume schedule. Called from the session's throttled parser path.
*/
processCleanData(cleanData: string): void {
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
// A resume is in flight (Esc sent, continue pending): output from our own
// Escape can redraw the stale limit footer — don't let it re-arm and
// cancel the continue. Fresh evidence arrives after the prompt is sent.
if (this._resumeFollowupTimer) return;
const detection = detectUsageLimitPause(cleanData);
if (!detection) return;
const now = Date.now();
const overdue = detection.resetAt <= now;
const fireAt = overdue
? now + RESUME_RETRY_MS // stale reset time → gentle retry loop
: Math.max(detection.resetAt + RESUME_BUFFER_MS, now + RESUME_MIN_DELAY_MS);
if (this._autoResumeTimer && this._autoResumeAt !== null) {
// Already armed: the footer redraws constantly, so ignore re-detections
// that land on (or later than) the current schedule. Only an EARLIER
// parsed time replaces it — an overdue retry never preempts a real one.
if (overdue || fireAt >= this._autoResumeAt - RESUME_DEDUP_TOLERANCE_MS) return;
}
this._scheduleResume(fireAt, detection.resetAt, detection.matched);
}
/**
* Claude started working — the limit is lifted (or the user resumed
* manually), so any pending auto-resume is obsolete.
*/
notifyWorking(): void {
this._resumeAttempts = 0;
if (!this._limitPaused && !this._autoResumeTimer && !this._resumeFollowupTimer) return;
this._cancelAutoResume('working');
}
private _scheduleResume(fireAt: number, resetAt: number, matched: string): void {
if (this._autoResumeTimer) {
clearTimeout(this._autoResumeTimer);
this._autoResumeTimer = null;
}
this._limitPaused = true;
this._autoResumeAt = fireAt;
const delay = Math.max(fireAt - Date.now(), 0);
console.log(
`[SessionAutoOps ${this.callbacks.getSessionId()}] Usage-limit pause detected ("${matched.slice(0, 60)}"), auto-resume in ${Math.round(delay / 60000)}min`
);
this._autoResumeTimer = setTimeout(() => void this._fireResume(), delay);
this.emit('limitPauseScheduled', { resetAt, resumeAt: fireAt, matched });
}
private async _fireResume(): Promise<void> {
this._autoResumeTimer = null;
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
if (this.callbacks.isWorking()) {
// Session resumed on its own (or via the user) — nothing to do.
this._cancelAutoResume('working');
return;
}
this._resumeAttempts++;
const attempt = this._resumeAttempts;
this._limitPaused = false; // optimistic: a fresh limit message re-arms us
this._autoResumeAt = null;
// Escape first: dismisses the rate-limit options dialog if Claude opened
// one (harmless at an idle prompt), then the resume prompt after a beat.
await this.callbacks.writeCommand('\x1b');
this._resumeFollowupTimer = setTimeout(() => {
this._resumeFollowupTimer = null;
if (this.callbacks.isStopped()) return;
void this.callbacks.writeCommand(`${RESUME_PROMPT}\r`);
this.emit('limitResume', { attempt });
}, RESUME_ESC_DELAY_MS);
}
private _cancelAutoResume(reason: 'disabled' | 'working' | 'stopped'): void {
const wasArmed = this._autoResumeTimer !== null || this._resumeFollowupTimer !== null || this._limitPaused;
if (this._autoResumeTimer) {
clearTimeout(this._autoResumeTimer);
this._autoResumeTimer = null;
}
if (this._resumeFollowupTimer) {
clearTimeout(this._resumeFollowupTimer);
this._resumeFollowupTimer = null;
}
this._limitPaused = false;
this._autoResumeAt = null;
if (wasArmed && reason !== 'stopped') {
this.emit('limitResumeCancelled', { reason });
}
}
// ============================================================================
// Threshold checks
// ============================================================================
@@ -321,5 +502,7 @@ export class SessionAutoOps extends EventEmitter {
this._autoClearTimer = null;
}
this._isClearing = false;
this._cancelAutoResume('stopped');
}
}
+5
View File
@@ -11,6 +11,7 @@
import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { dataPath } from './config/instance.js';
/**
* Build Claude CLI permission flags based on the configured mode.
@@ -113,6 +114,8 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
};
}
@@ -149,5 +152,7 @@ export function buildShellEnv(sessionId: string): Record<string, string | undefi
CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
};
}
+194 -5
View File
@@ -78,7 +78,14 @@ import {
buildShellEnv,
} from './session-cli-builder.js';
import { SessionAutoOps } from './session-auto-ops.js';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
import { SessionTaskCache } from './session-task-cache.js';
import { parseAttachmentMagicLinks } from './attachment-magic.js';
import {
sanitizeAttachmentHistory,
upsertAttachmentHistory as upsertAttachmentHistoryList,
} from './session-attachment-history.js';
import type { SessionAttachmentHistoryItem } from './types/session.js';
export type { BackgroundTask } from './task-tracker.js';
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
@@ -258,6 +265,9 @@ export class Session extends EventEmitter {
private _messages: ClaudeMessage[] = [];
private _lineBuffer: string = '';
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
// Codex only: trailing partial CSI held back so sequences split across PTY
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
private _codexSeqCarry: string = '';
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
@@ -307,6 +317,10 @@ export class Session extends EventEmitter {
private _parentAgentId: string | null = null;
private _childAgentIds: string[] = [];
// Bounded dedup set for terminal attachment magic-links already requested.
private _attachmentMagicSeen = new Set<string>();
private _attachmentHistory: SessionAttachmentHistoryItem[] = [];
// Nice prioritying configuration
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
@@ -397,6 +411,8 @@ export class Session extends EventEmitter {
envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Restored per-session attachment history. May include server-private external paths. */
attachmentHistory?: SessionAttachmentHistoryItem[];
}
) {
super();
@@ -463,6 +479,9 @@ export class Session extends EventEmitter {
if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort;
}
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
this.restoreAttachmentHistory(config.attachmentHistory);
}
// Initialize task tracker and forward events (store handlers for cleanup)
this._taskTracker = new TaskTracker();
@@ -520,6 +539,9 @@ export class Session extends EventEmitter {
this._totalOutputTokens = 0;
this.emit('autoClear', data);
});
this._autoOps.on('limitPauseScheduled', (data) => this.emit('limitPauseScheduled', data));
this._autoOps.on('limitResume', (data) => this.emit('limitResume', data));
this._autoOps.on('limitResumeCancelled', (data) => this.emit('limitResumeCancelled', data));
}
get status(): SessionStatus {
@@ -825,6 +847,39 @@ export class Session extends EventEmitter {
this._autoOps.setAutoCompact(enabled, threshold, prompt);
}
get autoResumeEnabled(): boolean {
return this._autoOps.autoResumeEnabled;
}
/** When the scheduled usage-limit auto-resume fires (epoch ms), or null. */
get autoResumeAt(): number | null {
return this._autoOps.autoResumeAt;
}
/** True while the session is paused on a Claude usage limit (auto-resume armed). */
get isLimitPaused(): boolean {
return this._autoOps.isLimitPaused;
}
setAutoResume(enabled: boolean): void {
this._autoOps.setAutoResume(enabled);
// Users typically enable this WHILE a session already sits paused — the
// limit footer won't reprint on its own, so scan the recent buffer once.
// Only a future reset time counts: stale scrollback must not arm a resume.
if (enabled && !isExternalCliMode(this.mode)) {
const tail = this._terminalBuffer.value.slice(-8192).replace(ANSI_ESCAPE_PATTERN_FULL, '');
const detection = detectUsageLimitPause(tail);
if (detection && detection.resetAt > Date.now()) {
this._autoOps.processCleanData(tail);
}
}
}
/** Restore auto-resume state (and a pending schedule) after Codeman restart. */
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
this._autoOps.restoreAutoResume(enabled, resumeAt);
}
get imageWatcherEnabled(): boolean {
return this._imageWatcherEnabled;
}
@@ -853,6 +908,30 @@ export class Session extends EventEmitter {
return this._status === 'idle' || this._status === 'busy';
}
get attachmentHistory(): SessionAttachmentHistoryItem[] {
return sanitizeAttachmentHistory(this._attachmentHistory);
}
upsertAttachmentHistory(item: SessionAttachmentHistoryItem): void {
this._attachmentHistory = upsertAttachmentHistoryList(this._attachmentHistory, item);
}
restoreAttachmentHistory(history: SessionAttachmentHistoryItem[] | undefined): void {
this._attachmentHistory = [];
for (const item of [...(history ?? [])].reverse()) {
// Guard against malformed/legacy on-disk entries (null, non-object, or
// missing required fields). historyKey() dereferences source/fileName, so
// a bad item would otherwise throw inside the constructor and abort the
// entire mux-recovery loop.
if (!item || typeof item !== 'object' || !item.source || !item.fileName) continue;
this.upsertAttachmentHistory(item);
}
}
getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] | undefined {
return this._attachmentHistory.length > 0 ? this._attachmentHistory.map((item) => ({ ...item })) : undefined;
}
toState(): SessionState {
return {
id: this.id,
@@ -869,6 +948,8 @@ export class Session extends EventEmitter {
autoCompactEnabled: this._autoOps.autoCompactEnabled,
autoCompactThreshold: this._autoOps.autoCompactThreshold,
autoCompactPrompt: this._autoOps.autoCompactPrompt,
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
imageWatcherEnabled: this._imageWatcherEnabled,
totalCost: this._totalCost,
inputTokens: this._totalInputTokens,
@@ -890,6 +971,7 @@ export class Session extends EventEmitter {
codexConfig: this._codexConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
// envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls
@@ -1052,6 +1134,61 @@ export class Session extends EventEmitter {
}
private _handleTerminalOutput(data: string): void {
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
// enables that hijack the scroll wheel so the user can't reach scrollback:
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
// scrollback) — \x1b[?...l switches back.
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
// the visible viewport — are left intact; the TUI repaints those rows.)
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
// xterm.js forwards wheel events to codex instead of scrolling the
// viewport, so the conversation is in scrollback but unreachable.
// (Focus events at ?1004 are left alone — codeman uses them for
// active-tab detection.)
// Strip them at the source so neither the persisted buffer nor the live
// SSE/WS stream carries them, keeping everything in the main buffer with
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
// cells they actually target, so the non-erased rows keep their content.
if (this.mode === 'codex') {
// Reassemble sequences split across PTY chunk boundaries first: a chunk
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
// strip below and leave xterm stuck in the scrollback-less alt buffer
// until the next buffer replay. Hold back an incomplete digit-only CSI
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
// prepend it to the next chunk; complete sequences are never held.
data = this._codexSeqCarry + data;
this._codexSeqCarry = '';
// eslint-disable-next-line no-control-regex
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
if (splitTail) {
this._codexSeqCarry = splitTail[0];
data = data.slice(0, -splitTail[0].length);
if (!data) return;
}
data = data
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[3J/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
}
// Scan terminal output for `codeman://attach?path=...` magic links and emit
// an attachmentRequested event for each newly-seen absolute path. The web
// server turns these into registered attachment cards.
const attachmentPaths = parseAttachmentMagicLinks(data);
for (const attachmentPath of attachmentPaths) {
if (this._attachmentMagicSeen.has(attachmentPath)) continue;
this._attachmentMagicSeen.add(attachmentPath);
if (this._attachmentMagicSeen.size > 200) {
const oldest = this._attachmentMagicSeen.values().next().value;
if (oldest) this._attachmentMagicSeen.delete(oldest);
}
this.emit('attachmentRequested', { sessionId: this.id, path: attachmentPath, timestamp: Date.now() });
}
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
@@ -1250,6 +1387,7 @@ export class Session extends EventEmitter {
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
}
this._awaitingIdleConfirmation = false;
if (this.activityTimeout) clearTimeout(this.activityTimeout);
@@ -1356,6 +1494,11 @@ export class Session extends EventEmitter {
this._bashToolParser.processCleanData(getCleanData());
}
// Usage-limit pause detection (auto-resume on usage limit)
if (this._autoOps.autoResumeEnabled) {
this._autoOps.processCleanData(getCleanData());
}
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
if (rawData.includes('token')) {
this.parseTokensFromStatusLine(getCleanData());
@@ -1384,6 +1527,7 @@ export class Session extends EventEmitter {
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
this._awaitingIdleConfirmation = false;
if (this.activityTimeout) clearTimeout(this.activityTimeout);
}
@@ -1665,6 +1809,7 @@ export class Session extends EventEmitter {
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._codexSeqCarry = '';
this._lastActivityAt = Date.now();
}
@@ -2096,9 +2241,29 @@ export class Session extends EventEmitter {
*/
private _desktopSizeClaims = new Set<symbol>();
/**
* A desktop sizing claim only blocks small-viewport resizes while the
* desktop is RECENTLY ACTIVE (claim registration or typed input within this
* window). An abandoned-but-connected desktop tab (left open at home, screen
* locked) must not hold a phone's view hostage: without this, the phone
* renders a desktop-width stream in a narrow xterm — mid-word wraps, tmux
* dot-fill, and Ink overdraw soup (the 0.9.8–0.9.12 mobile regression).
*/
private static readonly DESKTOP_CLAIM_IDLE_MS = 90_000;
/** Last evidence of a live desktop user (claim registered / typed input). */
private _lastDesktopActivityAt = 0;
/** Last desktop-typed dimensions, for re-asserting after a mobile override. */
private _lastDesktopDims: { cols: number; rows: number } | null = null;
/** True while a small viewport reflowed the pane past an idle desktop claim. */
private _mobileSizeOverride = false;
/** Register a live desktop sizing claim (see _desktopSizeClaims). */
claimDesktopSizing(token: symbol): void {
this._desktopSizeClaims.add(token);
this._lastDesktopActivityAt = Date.now();
}
/** Release a desktop sizing claim when its connection goes away. */
@@ -2106,23 +2271,47 @@ export class Session extends EventEmitter {
this._desktopSizeClaims.delete(token);
}
/**
* Record desktop user activity (typed input over a claim-holding socket).
* If a phone reflowed the pane while the desktop was idle, the desktop
* layout is restored — "whoever is actively using the session wins".
*/
noteDesktopActivity(): void {
this._lastDesktopActivityAt = Date.now();
if (this._mobileSizeOverride && this._lastDesktopDims) {
this._mobileSizeOverride = false;
this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' });
}
}
/**
* Resizes the PTY terminal dimensions.
* Skips the resize if dimensions haven't changed to avoid triggering
* unnecessary Ink full-screen redraws (visible flicker on tab switch).
*
* Arbitration: while a desktop connection holds a sizing claim, resizes from
* small viewports (mobile/tablet) are ignored entirely — shrink AND grow
* would both reflow the desktop view. Without a desktop connected, small
* viewports control the PTY size freely.
* Arbitration: while a desktop connection holds a sizing claim AND has been
* active within DESKTOP_CLAIM_IDLE_MS, resizes from small viewports
* (mobile/tablet) are ignored — shrink AND grow would both reflow the
* desktop view. Once the desktop goes idle, a phone may take the pane (the
* desktop re-asserts its size on its next typed input via
* noteDesktopActivity). Without a desktop connected, small viewports
* control the PTY size freely.
*
* @param cols - Number of columns (width in characters)
* @param rows - Number of rows (height in lines)
*/
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
if (options.viewportType === 'desktop') {
this._lastDesktopDims = { cols, rows };
this._lastDesktopActivityAt = Date.now();
this._mobileSizeOverride = false;
}
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
return;
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
return;
}
this._mobileSizeOverride = true;
}
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
this._ptyCols = cols;
+9 -1
View File
@@ -426,7 +426,12 @@ export function formatPaneSnapshot(
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
): string {
const cols = Math.max(1, geometry.cols);
const paintCols = Math.max(1, cols - 1);
// Paint the full pane width. Earlier this dropped the rightmost column
// (cols - 1) out of caution about last-column autowrap, but every painted
// row is immediately followed by an absolute cursor-position CSI (the next
// row's `\x1b[r;1H`, or the final cursor move), which cancels xterm's
// pending-wrap state before any further glyph — so the last column is safe.
const paintCols = cols;
const rows = Math.max(1, geometry.rows);
const parts: string[] = [];
for (let row = 0; row < Math.min(lines.length, rows); row++) {
@@ -857,6 +862,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
// Path only (not the secret value): hook curl commands cat the file at
// execution time, so the COD-54 hook secret stays off the command line.
`export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
+41
View File
@@ -25,6 +25,7 @@
*/
import type { RespawnConfig } from './respawn.js';
import type { AttachmentDetectedType } from './tools.js';
/** Status of a Claude session */
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
@@ -101,6 +102,40 @@ export interface SessionConfig {
*/
export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';
export type SessionAttachmentHistorySource = 'detected' | 'external';
/**
* Session-scoped attachment history entry.
*
* `externalPath` is server-private. It may be present in the internal persisted
* history copy, but API-bound session state must sanitize it before returning
* to the browser.
*/
export interface SessionAttachmentHistoryItem {
/** Stable history identity used for dedupe and list rendering */
id: string;
/** Codeman session ID this item belongs to */
sessionId: string;
/** Display filename */
fileName: string;
/** Lowercase extension without a leading dot */
extension: string;
/** Viewer category used by the web UI */
attachmentType: AttachmentDetectedType;
/** File size in bytes */
size: number;
/** Last modified timestamp in milliseconds, if known */
mtimeMs: number;
/** Last time this attachment was seen or explicitly published */
timestamp: number;
/** How the attachment entered the session */
source: SessionAttachmentHistorySource;
/** Workspace-relative path for detected session files */
relativePath?: string;
/** Server-private absolute path for explicitly published external files */
externalPath?: string;
}
/**
* Current state of a session
*/
@@ -133,6 +168,10 @@ export interface SessionState {
autoCompactThreshold?: number;
/** Auto-compact prompt */
autoCompactPrompt?: string;
/** Auto-resume on usage limit enabled */
autoResumeEnabled?: boolean;
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
autoResumeAt?: number;
/** Image watcher enabled for this session */
imageWatcherEnabled?: boolean;
/** Total cost in USD */
@@ -179,6 +218,8 @@ export interface SessionState {
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Sanitized per-session attachment history. */
attachmentHistory?: SessionAttachmentHistoryItem[];
}
/**
+37 -1
View File
@@ -7,13 +7,14 @@
* - ActiveBashTool — a live bash command with extracted file paths and status
* - ActiveBashToolStatus — 'running' | 'completed'
* - ImageDetectedEvent — screenshot/image file detection trigger for UI popup
* - AttachmentDetectedEvent — document/image file detection trigger for attachment cards
*
* Cross-domain relationships:
* - ActiveBashTool.sessionId links to SessionState.id (session domain)
* - ImageDetectedEvent.sessionId links to SessionState.id (session domain)
*
* Both types are in-memory only (not persisted). Broadcast via SSE events
* `subagent:tool_call` and `image:detected`. Parsed by BashToolParser
* `subagent:tool_call`, `image:detected`, and `attachment:detected`. Parsed by BashToolParser
* (`src/bash-tool-parser.ts`).
*/
@@ -61,3 +62,38 @@ export interface ImageDetectedEvent {
/** File size in bytes */
size: number;
}
export type AttachmentDetectedType = 'image' | 'pdf' | 'document' | 'presentation' | 'markdown' | 'text';
/**
* Event emitted when a new previewable attachment file is detected in a session's
* working directory. Used to render a compact attachment card in the web UI.
*/
export interface AttachmentDetectedEvent {
/** Codeman session ID where the attachment was detected */
sessionId: string;
/** Full path to the detected attachment file */
filePath: string;
/** Path relative to the session's working directory (for file-raw/file-preview endpoints) */
relativePath: string;
/** Attachment file name (basename) */
fileName: string;
/** Lowercase extension without a leading dot */
extension: string;
/** Viewer category used by the web UI */
attachmentType: AttachmentDetectedType;
/** Timestamp when the attachment was detected */
timestamp: number;
/** File size in bytes */
size: number;
/** Registered attachment id for explicit live external attachments */
attachmentId?: string;
/** Source of the attachment card request */
source?: 'detected' | 'external';
/** Raw file route for explicit attachments */
rawUrl?: string;
/** Inline preview route for explicit attachments */
previewUrl?: string;
/** First-page thumbnail route for card previews */
thumbnailUrl?: string;
}
+210
View File
@@ -0,0 +1,210 @@
/**
* @fileoverview Pure detection of Claude Code usage-limit pause messages.
*
* When a Claude subscription limit (5-hour rolling window, weekly, Opus weekly,
* or extra-usage balance) is hit, the Claude Code TUI stops working and prints a
* status line with the reset time. These helpers detect that state in cleaned
* (ANSI-stripped) terminal output and parse the reset time, so the session
* auto-resume feature (SessionAutoOps) can schedule a "continue" nudge.
*
* Message shapes covered (observed across Claude Code 1.0.x–2.1.x, 2025–2026):
* - `5-hour limit reached ∙ resets 8pm` (v1.0.109+ footer)
* - `Session limit reached ∙ resets 8pm`
* - `Weekly limit reached ∙ resets 6pm`
* - `Opus weekly limit reached ∙ resets Oct 6, 1pm`
* - `Limit reached · resets 1pm (America/Chicago) · /upgrade to Max…` (v2.0.55+)
* - `You've hit your limit · resets 1:40pm (America/New_York)` (v2.1.x)
* - `You've hit your weekly limit · resets Mon 12:00am`
* - `You've hit your limit · resets May 5 at 9pm (America/New_York)`
* - `You're out of extra usage · resets 1pm (America/Los_Angeles)`
* - `Claude usage limit reached. Your limit will reset at 2pm (America/New_York)` (v1.0.x inline)
* - `Claude AI usage limit reached|1755309600` (raw API, epoch seconds)
*
* Deliberately conservative: a limit phrase WITHOUT a parseable reset time is
* ignored (returns null) so ordinary conversation text mentioning "limit
* reached" can't arm the scheduler. The downstream retry loop (re-detection
* after each resume attempt) compensates for any parsing imprecision.
*
* All functions are pure (caller passes `now`) for testability.
*
* @module usage-limit-patterns
*/
/** Result of scanning terminal output for a usage-limit pause. */
export interface UsageLimitDetection {
/**
* Epoch ms when the limit resets. May be in the past when the matched
* message is stale (caller should treat past values as "retry soon").
*/
resetAt: number;
/** Matched message snippet (for logging and UI). */
matched: string;
}
/**
* Limit phrases that indicate Claude stopped on a usage limit.
* `\blimit reached` covers all "<X> limit reached" footer variants.
*/
const LIMIT_PHRASE_PATTERN =
/(?:\blimit\s+reached\b|you'?ve\s+hit\s+your\s+(?:\w+\s+)?limit\b|you'?re\s+out\s+of\s+extra\s+usage\b)/gi;
/**
* Reset-time spec following a limit phrase. Captures:
* 1 month (weekly resets >1 day out: "Oct 6, 1pm" / "May 5 at 9pm")
* 2 day-of-month
* 3 day-of-week ("Mon 12:00am")
* 4 hour (12h) 5 minutes 6 am/pm 7 IANA timezone in parens (optional)
* `resets?` + optional `at` also covers the v1.0.x "will reset at 2pm" form.
*/
const RESET_TIME_PATTERN =
/\bresets?\s+(?:at\s+)?(?:(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:\s*,\s*|\s+at\s+)|(sun|mon|tue|wed|thu|fri|sat)[a-z]*\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*\(([^()\n]{1,64})\))?/i;
/** Raw API form: `Claude AI usage limit reached|1755309600` (epoch seconds). */
const EPOCH_LIMIT_PATTERN = /\busage\s+limit\s+reached\|(\d{9,11})\b/gi;
/** How far after a limit phrase the reset-time spec may appear (chars). */
const RESET_TIME_WINDOW = 160;
/** Parsed reset spec must not be further out than this (weekly max ≈ 7 days). */
const MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60 * 1000;
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Current UTC offset of an IANA timezone in ms, or null if unresolvable
* (e.g. the `(Etc/Unknown)` failure variant Claude Code can print).
* DST transitions inside the wait window can skew the result by an hour;
* the auto-resume retry loop absorbs that.
*/
function zoneOffsetMs(timeZone: string, at: number): number | null {
try {
const dtf = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' });
const name = dtf.formatToParts(at).find((p) => p.type === 'timeZoneName')?.value;
if (!name) return null;
const m = /^GMT(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(name);
if (!m) return null;
if (!m[1]) return 0; // plain "GMT"
const sign = m[1] === '-' ? -1 : 1;
return sign * (parseInt(m[2], 10) * 60 + (m[3] ? parseInt(m[3], 10) : 0)) * 60_000;
} catch {
return null;
}
}
interface ResetSpec {
month?: number; // 0-11
dayOfMonth?: number; // 1-31
dayOfWeek?: number; // 0-6 (Sun-Sat)
hour: number; // 0-23
minute: number; // 0-59
timeZone?: string;
}
/**
* Compute the epoch ms for a parsed reset spec. Times are wall-clock in the
* given IANA timezone when present (and resolvable), otherwise server-local —
* Claude CLI runs on the same host as Codeman, so local time is the right
* default. Returns null when the spec is implausible (> ~8 days out).
*/
function resolveResetSpec(spec: ResetSpec, now: number): number | null {
const offset = spec.timeZone ? zoneOffsetMs(spec.timeZone, now) : null;
// Wall-clock view of "now": shifted-UTC when a zone offset is known,
// server-local otherwise. Read/build components with the matching API.
const useZone = offset !== null;
const wallNow = useZone ? new Date(now + offset) : new Date(now);
const get = {
year: () => (useZone ? wallNow.getUTCFullYear() : wallNow.getFullYear()),
month: () => (useZone ? wallNow.getUTCMonth() : wallNow.getMonth()),
date: () => (useZone ? wallNow.getUTCDate() : wallNow.getDate()),
day: () => (useZone ? wallNow.getUTCDay() : wallNow.getDay()),
};
const build = (y: number, mo: number, d: number): number => {
const wall = useZone
? Date.UTC(y, mo, d, spec.hour, spec.minute)
: new Date(y, mo, d, spec.hour, spec.minute).getTime();
return useZone ? wall - offset : wall;
};
let ts: number;
if (spec.month !== undefined && spec.dayOfMonth !== undefined) {
// Explicit date ("Oct 6, 1pm"). More than 2 days in the past → assume year
// rollover (message seen near New Year); slightly past → stale, keep as-is.
ts = build(get.year(), spec.month, spec.dayOfMonth);
if (ts < now - 2 * DAY_MS) {
ts = build(get.year() + 1, spec.month, spec.dayOfMonth);
}
} else if (spec.dayOfWeek !== undefined) {
// Day-of-week ("Mon 12:00am") → next occurrence.
const delta = (spec.dayOfWeek - get.day() + 7) % 7;
ts = build(get.year(), get.month(), get.date() + delta);
if (ts <= now) ts += 7 * DAY_MS;
} else {
// Time-only ("resets 8pm") → next occurrence within 24h.
ts = build(get.year(), get.month(), get.date());
if (ts <= now) ts += DAY_MS;
}
if (ts > now + MAX_RESET_HORIZON_MS) return null;
return ts;
}
/** Parse the reset-time spec found within `window`, or null. */
function parseResetTime(window: string, now: number): number | null {
const m = RESET_TIME_PATTERN.exec(window);
if (!m) return null;
const hour12 = parseInt(m[4], 10);
const minute = m[5] ? parseInt(m[5], 10) : 0;
if (hour12 < 1 || hour12 > 12 || minute > 59) return null;
const pm = m[6].toLowerCase() === 'pm';
const hour = (hour12 % 12) + (pm ? 12 : 0);
const spec: ResetSpec = { hour, minute };
if (m[1] && m[2]) {
spec.month = MONTHS.indexOf(m[1].toLowerCase());
spec.dayOfMonth = parseInt(m[2], 10);
if (spec.dayOfMonth < 1 || spec.dayOfMonth > 31) return null;
} else if (m[3]) {
spec.dayOfWeek = WEEKDAYS.indexOf(m[3].toLowerCase());
}
if (m[7]) spec.timeZone = m[7].trim();
return resolveResetSpec(spec, now);
}
/**
* Scan cleaned (ANSI-stripped) terminal output for a usage-limit pause message
* with a parseable reset time. Returns the LAST parseable occurrence in the
* chunk (most recent on screen), or null when none is found.
*/
export function detectUsageLimitPause(cleanData: string, now: number = Date.now()): UsageLimitDetection | null {
if (!cleanData || !/limit|extra usage/i.test(cleanData)) return null;
let result: UsageLimitDetection | null = null;
// Raw API epoch form
EPOCH_LIMIT_PATTERN.lastIndex = 0;
let em: RegExpExecArray | null;
while ((em = EPOCH_LIMIT_PATTERN.exec(cleanData)) !== null) {
const resetAt = parseInt(em[1], 10) * 1000;
if (resetAt > now + MAX_RESET_HORIZON_MS) continue;
result = { resetAt, matched: em[0] };
}
// TUI phrase + "resets <time>" forms
LIMIT_PHRASE_PATTERN.lastIndex = 0;
let pm: RegExpExecArray | null;
while ((pm = LIMIT_PHRASE_PATTERN.exec(cleanData)) !== null) {
const window = cleanData.slice(pm.index, pm.index + RESET_TIME_WINDOW);
const resetAt = parseResetTime(window, now);
if (resetAt !== null) {
result = { resetAt, matched: window.slice(0, 80).trim() };
}
}
return result;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* @fileoverview Pure parsing + formatting of Claude Code statusline telemetry.
*
* Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command`
* on each render. On Pro/Max subscriptions that blob carries a `rate_limits`
* object with the 5-hour rolling and 7-day weekly plan windows. The
* Codeman-managed statusLine exporter (see `hooks-config.generateStatusLineCommand`)
* POSTs that blob to `/api/status-telemetry`; these helpers normalize the subset
* Codeman displays and format the compact in-terminal footer string.
*
* Confirmed schema (empirically captured, CC 2.1.177, Claude Max — see
* `docs/usage-limits-display-plan.md`):
* rate_limits.{five_hour,seven_day}.{used_percentage: number 0-100,
* resets_at: number EPOCH-SECONDS}
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
* before the first API response and for non-subscriber auth — both yield null.
*
* All functions are pure for testability. See `test/usage-telemetry.test.ts`.
*
* @module usage-telemetry
*/
/** A single normalized plan-usage window. */
export interface UsageWindow {
/** Percent of the window consumed, 0–100. */
usedPercentage: number;
/** Epoch MILLISECONDS when the window resets (statusline reports seconds). */
resetAt: number;
}
/** Normalized telemetry Codeman broadcasts to the UI. */
export interface StatusTelemetry {
fiveHour?: UsageWindow;
sevenDay?: UsageWindow;
/** Context-window percent used, 0–100 (bonus field from the same payload). */
contextUsedPercentage?: number;
/** Session cost in USD (bonus field). */
costUsd?: number;
/** Model display name, e.g. "Opus 4.8 (1M context)" (bonus field). */
modelDisplayName?: string;
}
/** Raw subset of the statusline stdin JSON (snake_case, as Claude emits it). */
export interface RawStatuslinePayload {
rate_limits?: {
five_hour?: { used_percentage?: number; resets_at?: number };
seven_day?: { used_percentage?: number; resets_at?: number };
};
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
cost?: { total_cost_usd?: number };
model?: { display_name?: string };
}
function clampPct(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function parseWindow(w?: { used_percentage?: number; resets_at?: number }): UsageWindow | undefined {
if (!w || typeof w.used_percentage !== 'number' || typeof w.resets_at !== 'number') return undefined;
if (!Number.isFinite(w.resets_at) || w.resets_at <= 0) return undefined;
return { usedPercentage: clampPct(w.used_percentage), resetAt: Math.round(w.resets_at * 1000) };
}
/**
* Normalize a raw statusline payload to the telemetry Codeman displays. Returns
* null when there is no plan-limit data to show (pre-first-response or a
* non-subscriber account) so the caller can skip broadcasting.
*/
export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): StatusTelemetry | null {
if (!data) return null;
const fiveHour = parseWindow(data.rate_limits?.five_hour);
const sevenDay = parseWindow(data.rate_limits?.seven_day);
if (!fiveHour && !sevenDay) return null;
const t: StatusTelemetry = {};
if (fiveHour) t.fiveHour = fiveHour;
if (sevenDay) t.sevenDay = sevenDay;
if (typeof data.context_window?.used_percentage === 'number') {
t.contextUsedPercentage = clampPct(data.context_window.used_percentage);
}
if (typeof data.cost?.total_cost_usd === 'number' && Number.isFinite(data.cost.total_cost_usd)) {
t.costUsd = data.cost.total_cost_usd;
}
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
t.modelDisplayName = data.model.display_name.slice(0, 60);
}
return t;
}
/**
* Current-session status for the in-terminal statusline footer. This is the
* "status of the current session" the user sees in Claude's footer — distinct
* from the account-wide plan limits, which live ONLY in the Codeman header chip.
*/
export interface SessionStatus {
modelDisplayName?: string;
inputTokens?: number;
outputTokens?: number;
contextUsedPercentage?: number;
}
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
function withCommas(n: number): string {
return Math.max(0, Math.round(n))
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/** Extract current-session status (footer) from the raw payload. */
export function parseSessionStatus(data: RawStatuslinePayload | undefined): SessionStatus | null {
if (!data) return null;
const s: SessionStatus = {};
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
s.modelDisplayName = data.model.display_name.slice(0, 60);
}
const cw = data.context_window;
if (typeof cw?.total_input_tokens === 'number' && Number.isFinite(cw.total_input_tokens)) {
s.inputTokens = Math.max(0, cw.total_input_tokens);
}
if (typeof cw?.total_output_tokens === 'number' && Number.isFinite(cw.total_output_tokens)) {
s.outputTokens = Math.max(0, cw.total_output_tokens);
}
if (typeof cw?.used_percentage === 'number') {
s.contextUsedPercentage = clampPct(cw.used_percentage);
}
return Object.keys(s).length ? s : null;
}
/**
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
* which live in the Codeman header chip. Claude requires a statusLine command to
* emit the rate_limits JSON at all, so this is what that command prints back.
*/
export function formatSessionStatusText(s: SessionStatus | null): string {
if (!s) return 'codeman';
const groups: string[] = [];
if (s.modelDisplayName) groups.push(s.modelDisplayName);
const tok: string[] = [];
if (s.inputTokens != null) tok.push(`in:${withCommas(s.inputTokens)}`);
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
if (tok.length) groups.push(tok.join(' '));
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
return groups.length ? groups.join(' ') : 'codeman';
}
/**
* Stable signature for change-detection — the statusline fires on every
* assistant message, so the route only rebroadcasts when this value changes.
*
* Keys on EXACTLY the values the header chip displays: the two windows' ROUNDED
* percentages (the chip renders `Math.round`) + their reset times. Deliberately
* excludes contextUsedPercentage / costUsd / modelDisplayName — none are shown
* in the chip, and contextUsedPercentage in particular drifts on every assistant
* message, which would defeat the dedup and fan out a redundant SSE broadcast +
* localStorage write + identical chip re-render each time.
*/
export function telemetrySignature(t: StatusTelemetry): string {
return JSON.stringify([
t.fiveHour ? Math.round(t.fiveHour.usedPercentage) : null,
t.fiveHour?.resetAt ?? null,
t.sevenDay ? Math.round(t.sevenDay.usedPercentage) : null,
t.sevenDay?.resetAt ?? null,
]);
}
+211
View File
@@ -0,0 +1,211 @@
/**
* @fileoverview Probe engine for `codeman doctor`. Resolves each registry tool
* against an injectable ProbeHost (real impl uses child_process/fs; tests inject
* fakes) and returns structured results. Pure given the host — no global I/O.
*
* @module utils/dependency-checker
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
export interface EnvDetectionInputs {
platform: NodeJS.Platform;
procVersion: string;
hasWindowsInterop: boolean;
}
export function detectEnvironment(inputs: EnvDetectionInputs): ProbeEnvironment {
if (inputs.platform === 'win32') return 'win32';
if (inputs.platform === 'darwin') return 'darwin';
const isWsl = /microsoft|wsl/i.test(inputs.procVersion) && inputs.hasWindowsInterop;
return isWsl ? 'wsl' : 'linux';
}
const DEFAULT_VERSION_RE = /(\d+\.\d+(?:\.\d+)?)/;
export function extractVersion(text: string, re?: RegExp): string | undefined {
const m = (re ?? DEFAULT_VERSION_RE).exec(text);
return m ? m[1] : undefined;
}
/** Returns -1 if a < b, 0 if equal, 1 if a > b (numeric, component-wise). */
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d < 0 ? -1 : 1;
}
return 0;
}
export type ToolStatus = 'ok' | 'missing' | 'outdated' | 'skipped' | 'error';
export interface ToolResult {
id: string;
label: string;
category: ToolCategory;
required: boolean;
usedBy: string[];
status: ToolStatus;
version?: string;
path?: string;
installHint?: string;
reason?: string;
}
export interface ProbeHost {
environment: ProbeEnvironment;
which(bin: string): string | null;
fileExists(path: string): boolean;
runVersion(bin: string, args: string[]): string | null;
windowsProgramRoots(): string[];
windowsFileVersion(winPath: string): string | null;
}
function finalize(
base: Pick<ToolResult, 'id' | 'label' | 'category' | 'required' | 'usedBy'>,
tool: ToolDependency,
path: string,
version: string | undefined
): ToolResult {
if (tool.minVersion) {
if (!version) return { ...base, status: 'error', path, reason: 'version required but could not be parsed' };
if (compareVersions(version, tool.minVersion) < 0) return { ...base, status: 'outdated', path, version };
}
return { ...base, status: 'ok', path, version };
}
export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
const base = {
id: tool.id,
label: tool.label,
category: tool.category,
required: tool.required,
usedBy: tool.usedBy ?? [],
};
const installHint = tool.installHint?.[host.environment];
const spec = tool.resolvers.find((r) => r.match.includes(host.environment));
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
if (spec.resolver.kind === 'path') {
const { bins, versionArg, versionRegex } = spec.resolver;
for (const bin of bins) {
const resolved = host.which(bin);
if (resolved) {
const out = host.runVersion(bin, [versionArg ?? '--version']);
const version = out ? extractVersion(out, versionRegex) : undefined;
return finalize(base, tool, resolved, version);
}
}
return { ...base, status: 'missing', installHint };
}
// windows-side
const { appDirs, exes } = spec.resolver;
for (const root of host.windowsProgramRoots()) {
for (const dir of appDirs) {
for (const exe of exes) {
const winPath = `${root}/${dir}/${exe}`;
if (host.fileExists(winPath)) {
const raw = host.windowsFileVersion(winPath);
const version = raw ? extractVersion(raw) : undefined;
return finalize(base, tool, winPath, version);
}
}
}
}
return { ...base, status: 'missing', installHint };
}
export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResult[] {
return registry.map((tool) => checkTool(tool, host));
}
function safeWhich(bin: string): string | null {
try {
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const first = out.split(/\r?\n/)[0]?.trim();
return first && existsSync(first) ? first : null;
} catch {
return null;
}
}
function safeRunVersion(bin: string, args: string[]): string | null {
try {
return execFileSync(bin, args, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch (err: unknown) {
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
const stdout = (err as { stdout?: Buffer | string })?.stdout;
return stdout ? stdout.toString() : null;
}
}
function readProcVersion(): string {
try {
return readFileSync('/proc/version', 'utf-8');
} catch {
return '';
}
}
function listWindowsProgramRoots(): string[] {
const roots: string[] = [];
try {
for (const entry of readdirSync('/mnt')) {
for (const pf of ['Program Files', 'Program Files (x86)']) {
const root = `/mnt/${entry}/${pf}`;
if (existsSync(root)) roots.push(root);
}
}
} catch {
// /mnt absent (not WSL) -> no roots
}
return roots;
}
function readWindowsFileVersion(winPath: string): string | null {
try {
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const out = execFileSync(
'powershell.exe',
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
return out || null;
} catch {
return null;
}
}
export function createRealHost(): ProbeHost {
const environment = detectEnvironment({
platform: process.platform,
procVersion: readProcVersion(),
hasWindowsInterop: safeWhich('cmd.exe') !== null || safeWhich('powershell.exe') !== null,
});
return {
environment,
which: safeWhich,
fileExists: existsSync,
runVersion: safeRunVersion,
windowsProgramRoots: listWindowsProgramRoots,
windowsFileVersion: readWindowsFileVersion,
};
}
+75
View File
@@ -0,0 +1,75 @@
/**
* @fileoverview Renders ToolResult[] from the dependency checker into a
* human-readable grouped table or JSON, and computes the process exit code.
* Plain text only (no color) so output is stable and snapshot-friendly; the
* CLI layer may colorize.
*
* @module utils/dependency-report
*/
import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js';
import type { ToolResult, ToolStatus } from './dependency-checker.js';
const CATEGORY_ORDER: ToolCategory[] = ['core', 'office', 'other'];
function glyph(r: ToolResult): string {
if (r.status === 'ok') return '✓';
if (r.status === 'skipped') return '○';
return r.required ? '✗' : '○';
}
function statusText(r: ToolResult): string {
if (r.status === 'ok') return r.version ?? 'installed';
if (r.status === 'outdated') return `${r.version ?? '?'} (below minimum)`;
if (r.status === 'skipped') return 'n/a';
if (r.status === 'error') return 'version error';
return 'not found';
}
export function computeExitCode(results: ToolResult[]): number {
const failed = results.some(
(r) => r.required && (r.status === 'missing' || r.status === 'outdated' || r.status === 'error')
);
return failed ? 1 : 0;
}
export function renderTable(results: ToolResult[], environment: ProbeEnvironment): string {
const lines: string[] = [`Codeman dependency check — ${environment}`, ''];
for (const category of CATEGORY_ORDER) {
const rows = results.filter((r) => r.category === category);
if (rows.length === 0) continue;
lines.push(category.toUpperCase());
for (const r of rows) {
const detail = r.path ? ` ${r.path}` : '';
lines.push(` ${glyph(r)} ${r.label.padEnd(14)} ${statusText(r).padEnd(22)}${detail}`);
if (r.usedBy.length) lines.push(` used by: ${r.usedBy.join(', ')}`);
if (r.installHint) lines.push(` install: ${r.installHint}`);
}
lines.push('');
}
const ok = results.filter((r) => r.status === 'ok').length;
const requiredMissing = results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length;
const optionalMissing = results.filter((r) => !r.required && r.status === 'missing').length;
lines.push(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`);
return lines.join('\n');
}
export interface DependencyReportJson {
platform: { environment: ProbeEnvironment };
summary: { ok: number; requiredMissing: number; optionalMissing: number; exitCode: number };
tools: ToolResult[];
}
export function renderJson(results: ToolResult[], environment: ProbeEnvironment): DependencyReportJson {
const byStatus = (s: ToolStatus) => results.filter((r) => r.status === s).length;
return {
platform: { environment },
summary: {
ok: byStatus('ok'),
requiredMissing: results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length,
optionalMissing: results.filter((r) => !r.required && r.status === 'missing').length,
exitCode: computeExitCode(results),
},
tools: results,
};
}
+57 -8
View File
@@ -19,6 +19,7 @@ import {
AUTH_FAILURE_MAX,
AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
// Auth session cookie name
export const AUTH_COOKIE_NAME = 'codeman_session';
@@ -28,12 +29,16 @@ interface AuthState {
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
authFailures: StaleExpirationMap<string, number> | null;
qrAuthFailures: StaleExpirationMap<string, number> | null;
hookSecretFailures: StaleExpirationMap<string, number> | null;
}
/**
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set.
*
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the
* shared hook secret unconditionally (COD-91) — see the onRequest hook below.
*
* @returns AuthState for lifecycle management (dispose on server stop)
*/
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
@@ -41,6 +46,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
authSessions: null,
authFailures: null,
qrAuthFailures: null,
hookSecretFailures: null,
};
const authPassword = process.env.CODEMAN_PASSWORD;
@@ -67,24 +73,67 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
refreshOnGet: false,
});
// Separate hook-secret failure counter (COD-54). MUST NOT share authFailures:
// legacy (pre-secret) hook configs fire constantly from 127.0.0.1, and counting
// their 401s against the shared bucket would 429 every cookie-less request from
// loopback — locking out the Basic-Auth login path (and, through a tunnel, every
// client, since tunneled traffic also arrives as 127.0.0.1).
state.hookSecretFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
const authSessions = state.authSessions;
const authFailures = state.authFailures;
const hookSecretFailures = state.hookSecretFailures;
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
function sendAuthRateLimit(
reply: FastifyReply,
clientIp: string,
failures: StaleExpirationMap<string, number> = authFailures
): void {
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later');
}
app.addHook('onRequest', (req, reply, done) => {
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
// Safe: validated by HookEventSchema, only triggers broadcasts.
// Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN.
if (req.url === '/api/hook-event' && req.method === 'POST') {
// Hook events + statusline telemetry come from local Claude Code (curl from
// localhost) — no Basic-Auth credentials available. Validated downstream by
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
//
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
// would pass. COD-91: require the shared hook secret on the loopback bypass
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
// from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
// always closes the gap without breaking the legitimate hook channel.
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
const ip = req.ip;
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
done();
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) {
// Always require the shared secret (constant-time compare).
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
done();
return;
}
// Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
// (never authFailures, which would lock out the login path).
const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
return;
}
hookSecretFailures.set(hookIp, hookFailures + 1);
reply.code(401).send('Unauthorized: hook secret required');
return;
}
// Non-localhost hook requests fall through to normal auth
+10
View File
@@ -6,6 +6,16 @@ export function isExplicitlyEnabled(value: string | undefined): boolean {
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
}
/**
* True when unauthenticated network exposure is acceptable: either a password is
* set (auth active) or the operator explicitly acknowledged it. Used by the
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
*/
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
if (process.env.CODEMAN_PASSWORD) return true;
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
}
export function isLoopbackBindHost(host: string): boolean {
const normalized = host
.trim()
+22
View File
@@ -0,0 +1,22 @@
/**
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
*
* The status-telemetry route writes the latest broadcast value here; the SSE
* init snapshot (`getLightState`) replays it so the header "Plan Usage Limits"
* chip shows immediately on a fresh page load / SSE reconnect — before any new
* statusline render arrives, and without relying on per-browser localStorage.
*
* Null until the first telemetry of the process; cleared naturally on restart.
*
* @module plan-usage-latest
*/
let latest: Record<string, unknown> | null = null;
export function setLatestPlanUsage(value: Record<string, unknown>): void {
latest = value;
}
export function getLatestPlanUsage(): Record<string, unknown> | null {
return latest;
}
+390 -34
View File
@@ -153,7 +153,11 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SESSION_IDLE, '_onSessionIdle'],
[SSE_EVENTS.SESSION_WORKING, '_onSessionWorking'],
[SSE_EVENTS.SESSION_AUTO_CLEAR, '_onSessionAutoClear'],
[SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'],
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
// Scheduled runs
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
@@ -219,6 +223,7 @@ const _SSE_HANDLER_MAP = [
// Images
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
// Tunnel
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
@@ -383,6 +388,11 @@ class CodemanApp {
// Image popup windows (auto-open for detected screenshots/images)
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
this.attachmentCards = new Map(); // Map<attachmentId, { element, sessionId, filePath }>
this.attachmentCardStack = null;
this.attachmentHistoryCounts = new Map(); // Map<sessionId, count>
this.attachmentHistoryItems = [];
this.attachmentHistoryDrawerOpen = false;
// File browser state (methods in panels-ui.js)
this.fileBrowserData = null;
@@ -576,8 +586,9 @@ class CodemanApp {
// Apply keyboard bar mode from settings
const _kbSettings = this.loadAppSettingsFromStorage();
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
this.bindMobileHeaderUtilityToggle?.();
this.applyHeaderVisibilitySettings();
this.restorePlanUsageChip();
this.applySkin();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
// Remove mobile-init class now that JS has applied visibility settings.
@@ -635,6 +646,7 @@ class CodemanApp {
// Load server-stored settings (async, re-applies visibility after load)
this.loadAppSettingsFromServer(settingsPromise).then(() => {
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
});
@@ -755,16 +767,34 @@ class CodemanApp {
if (e.key === 'Escape') {
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
}
// Alt+1-9: switch to Codeman session by index
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
const idx = parseInt(e.key) - 1;
if (idx < this.sessionOrder.length) {
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const code = e.code || '';
const digitMatch = code.match(/^Digit([1-9])$/);
if (digitMatch) {
const idx = parseInt(digitMatch[1], 10) - 1;
if (idx < this.sessionOrder.length) {
e.preventDefault();
this.selectSession(this.sessionOrder[idx]);
}
return;
}
if (e.code === 'BracketLeft') {
e.preventDefault();
this.selectSession(this.sessionOrder[idx]);
this.prevSession();
return;
}
if (e.code === 'BracketRight') {
e.preventDefault();
this.nextSession();
return;
}
return;
}
// Match against shortcut table
@@ -1289,28 +1319,14 @@ class CodemanApp {
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
_sanitizeHtml(html) {
const tpl = document.createElement('template');
tpl.innerHTML = html;
const frag = tpl.content;
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
el.remove();
if (typeof window !== 'undefined' && typeof window.sanitizeMarkdownHtml === 'function') {
return window.sanitizeMarkdownHtml(html);
}
for (const el of frag.querySelectorAll('*')) {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) {
el.removeAttribute(attr.name);
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
const val = attr.value.replace(/\s/g, '').toLowerCase();
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
el.removeAttribute(attr.name);
}
}
}
}
const div = document.createElement('div');
div.appendChild(frag);
return div.innerHTML;
// Fail closed: DOMPurify unavailable — never return un-sanitized HTML.
return String(html == null ? '' : html)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
@@ -1768,6 +1784,33 @@ class CodemanApp {
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
}
_onSessionLimitPauseScheduled(data) {
const session = this.sessions.get(data.sessionId);
if (session) session.autoResumeAt = data.resumeAt;
const at = new Date(data.resumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (data.sessionId === this.activeSessionId) {
this.showToast(`Usage limit reached — auto-resume at ${at}`, 'warning');
}
this._notifySession(data.sessionId, 'warning', 'limit-pause', 'Usage Limit Reached', `Auto-resume scheduled for ${at}`);
this.updateAutoResumeStatus(data.sessionId);
}
_onSessionLimitResume(data) {
const session = this.sessions.get(data.sessionId);
if (session) session.autoResumeAt = undefined;
if (data.sessionId === this.activeSessionId) {
this.showToast('Usage limit reset — work resumed automatically', 'success');
}
this._notifySession(data.sessionId, 'info', 'limit-resume', 'Auto-Resumed', 'Usage limit reset — continuing work');
this.updateAutoResumeStatus(data.sessionId);
}
_onSessionLimitResumeCancelled(data) {
const session = this.sessions.get(data.sessionId);
if (session) session.autoResumeAt = undefined;
this.updateAutoResumeStatus(data.sessionId);
}
_onSessionCliInfo(data) {
const session = this.sessions.get(data.sessionId);
if (session) {
@@ -1781,6 +1824,58 @@ class CodemanApp {
}
}
// Claude plan usage limits (5-hour + weekly) — account-global, so the latest
// sample from any session drives the shared header chip.
_onSessionStatusTelemetry(data) {
this.updatePlanUsageChip(data);
// Persist last-known so the chip shows immediately on the next page load /
// SSE reconnect, instead of staying blank until a session next renders.
try {
localStorage.setItem('codeman:planUsage', JSON.stringify({ t: Date.now(), data }));
} catch {}
}
// Repopulate the chip from the last-known value on page load (account-global,
// slow-moving; ignored if older than 12h). Live events refresh it.
restorePlanUsageChip() {
try {
const raw = localStorage.getItem('codeman:planUsage');
if (!raw) return;
const saved = JSON.parse(raw);
if (saved?.data && Date.now() - (saved.t || 0) < 12 * 3600 * 1000) {
this.updatePlanUsageChip(saved.data);
}
} catch {}
}
updatePlanUsageChip(data) {
const chip = document.getElementById('planUsageChip');
if (!chip || !data) return;
const pct = (w) => (w && typeof w.usedPercentage === 'number' ? Math.round(w.usedPercentage) : null);
const five = pct(data.fiveHour);
const seven = pct(data.sevenDay);
if (five === null && seven === null) return;
// Per-window color by how much is used up: green < 60%, yellow 60–84%, red ≥ 85%.
const colorClass = (p) => (p >= 85 ? 'pu-red' : p >= 60 ? 'pu-yellow' : 'pu-green');
// innerHTML here is XSS-safe ONLY because every interpolated value is a
// coerced finite number and the labels/classes are fixed literals. If a
// string field (e.g. modelDisplayName, which the route also broadcasts) is
// ever shown in this chip, render it via textContent — never interpolate an
// untrusted string into this template.
const seg = (label, p) => {
if (p === null) return '';
const n = Math.round(Number(p));
if (!Number.isFinite(n)) return '';
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
};
chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('<span class="pu-sep">·</span>');
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
chip.title =
`Claude plan usage\n` +
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
}
// Scheduled runs
_onScheduledCreated(data) {
this.currentRun = data;
@@ -1843,6 +1938,7 @@ class CodemanApp {
// selectSession's earlier resizes ran before this WS existed, so they
// went over HTTP, which never claims (see ws-routes sizingToken).
this.sendResize(sessionId)?.catch?.(() => {});
this._startMobileResizeRetry(sessionId);
}
};
@@ -1868,6 +1964,7 @@ class CodemanApp {
this._ws = null;
this._wsSessionId = null;
this._wsReady = false;
this._stopMobileResizeRetry();
// Reconnect on unexpected close (server restart, network blip, ping timeout).
// Don't reconnect if we intentionally disconnected (_disconnectWs nulls onclose)
@@ -1893,6 +1990,7 @@ class CodemanApp {
_disconnectWs() {
this._clearTimer('_wsReconnectTimer');
this._wsReconnectAttempts = 0;
this._stopMobileResizeRetry();
if (this._ws) {
this._ws.onclose = null; // Prevent re-entrant cleanup
this._ws.close();
@@ -1902,6 +2000,41 @@ class CodemanApp {
}
}
/**
* Small-viewport claim-idle retry. While a desktop sizing claim is "hot",
* the server ignores this device's resize (Session.DESKTOP_CLAIM_IDLE_MS),
* and the single resize sent on attach is deduped client-side — without a
* retry, a phone that attached under an active desktop would render a
* desktop-width stream forever. Re-send the current dims periodically (a
* server-side no-op once the pane already matches) so the pane reflows to
* this device shortly after the desktop goes idle. Visible-tab only: a
* phone in a pocket must not steal the pane from an active desktop.
*/
_startMobileResizeRetry(sessionId) {
this._stopMobileResizeRetry();
const type =
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
? MobileDetection.getDeviceType()
: 'desktop';
if (type === 'desktop') return;
this._mobileResizeRetryTimer = setInterval(() => {
if (document.visibilityState !== 'visible') return;
if (!this._wsReady || this._wsSessionId !== sessionId) return;
// Same guard as throttledResize: while the virtual keyboard is up, a
// fit()+SIGWINCH at the shrunken row count makes Ink re-render garbage
// and shifts the accessory toolbar mid-typing. Retry after it closes.
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
this.sendResize(sessionId)?.catch?.(() => {});
}, MOBILE_RESIZE_RETRY_MS);
}
_stopMobileResizeRetry() {
if (this._mobileResizeRetryTimer) {
clearInterval(this._mobileResizeRetryTimer);
this._mobileResizeRetryTimer = null;
}
}
/**
* Send input to server without blocking the keystroke flush cycle.
* Uses a sequential promise chain to preserve character ordering
@@ -2036,7 +2169,13 @@ class CodemanApp {
// Mobile defaults ship cjkInputEnabled: false (native terminal input by
// default on touch), but an explicit user enable is honored everywhere —
// the App Settings toggle must not be a silent no-op on phones.
const showCjk = this._serverCjkOverride || (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false);
// The welcome/home screen (no active session) has nothing to type into.
// Force-hide the CJK textarea there — otherwise the `position: fixed`
// `.cjk-input-visible` rule floats it over the welcome overlay and blocks
// content. Re-synced on session enter/leave via hideWelcome()/showWelcome().
const cjkUserEnabled =
this._serverCjkOverride || (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false);
const showCjk = cjkUserEnabled && !!this.activeSessionId;
cjkEl.classList.toggle('cjk-input-visible', !!showCjk);
document.body.classList.toggle('cjk-input-visible', !!showCjk);
cjkEl.style.display = showCjk ? 'block' : 'none';
@@ -2056,6 +2195,7 @@ class CodemanApp {
this.ralphStates.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this._xtermSnapshots?.clear();
this.projectInsights.clear();
this.teams.clear();
this.teamTasks.clear();
@@ -2093,9 +2233,9 @@ class CodemanApp {
KeyboardHandler.cleanup();
MobileDetection.init();
KeyboardHandler.init();
this.bindMobileHeaderUtilityToggle?.();
// Clear tab alerts
this.tabAlerts.clear();
this.attachmentHistoryCounts.clear();
// Clear shown completions (used for duplicate notification prevention)
if (this._shownCompletions) {
this._shownCompletions.clear();
@@ -2141,6 +2281,10 @@ class CodemanApp {
this._serverCjkOverride = data.inputCjkForm || false;
this._updateCjkInputState();
// Plan-usage chip: server's last-known telemetry, so it shows immediately on
// a fresh load / reconnect (authoritative; wins over the localStorage restore).
if (data.planUsage) this.updatePlanUsageChip(data.planUsage);
// Update version displays (header and toolbar)
if (data.version) {
const versionEl = this.$('versionDisplay');
@@ -2521,6 +2665,39 @@ class CodemanApp {
this._fullRenderSessionTabs();
}
this.updateTabOverflowMode();
}
// Auto-wrap desktop session tabs to a second row when they overflow one row,
// unless the user has pinned the manual two-row layout (tabTwoRows). Mobile/
// tablet keep horizontal scroll. Policy lives in constants.js for unit testing.
updateTabOverflowMode() {
const container = this.$('sessionTabs');
if (!container) return;
const deviceType = MobileDetection.getDeviceType();
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false;
if (manualTwoRows || deviceType !== 'desktop') {
container.classList.remove('tabs-auto-wrap');
return;
}
// Measure the natural one-row overflow, then enable wrapping only if needed.
container.classList.remove('tabs-auto-wrap');
const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs
? window.CodemanTabOverflow.shouldAutoWrapTabs({
deviceType,
manualTwoRows,
tabCount: this.sessions.size,
scrollWidth: container.scrollWidth,
clientWidth: container.clientWidth,
})
: container.scrollWidth > container.clientWidth + 1;
container.classList.toggle('tabs-auto-wrap', shouldWrap);
}
_fullRenderSessionTabs() {
@@ -2597,6 +2774,12 @@ class CodemanApp {
// Update connection lines after tabs change (positions may have shifted)
this.updateConnectionLines();
// Re-evaluate desktop auto-wrap for every full rebuild, including the incremental
// branch's early `_fullRenderSessionTabs(); return;` paths and the manual two-rows
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
// tabs-auto-wrap class until the next content render.
this.updateTabOverflowMode();
}
// Set up arrow key navigation for session tabs (accessibility)
@@ -2847,7 +3030,107 @@ class CodemanApp {
* terminal write queue, IME composition, and local echo flush.
* @param {string} newSessionId - The session being switched TO.
*/
_isUsableXtermSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== 'string' || snapshot.length < 8) return false;
const visibleText = snapshot
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
.replace(/\x1b[()][0-2A-Z]/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.trim();
return visibleText.length >= 3;
}
/**
* Persist one xterm snapshot to localStorage, bounded to a fixed key budget
* regardless of how many sessions are live, and resilient to quota errors.
* The previous inline version only pruned snapshots for sessions that no
* longer existed AND pruned only after a successful setItem — so once the
* quota filled (e.g. >10 live sessions at the 20-session target) the write
* threw before the prune could run, permanently disabling persistence.
*/
_persistXtermSnapshot(key, snapshot) {
const PREFIX = 'codeman-xs-';
const MAX_KEYS = 10;
const others = () => Object.keys(localStorage).filter((k) => k.startsWith(PREFIX) && k !== key);
try {
// Evict down to the budget before writing a NEW key, dead sessions first
// then oldest. (Overwriting an existing key doesn't grow the key count.)
if (localStorage.getItem(key) === null) {
const live = new Set(Array.from(this.sessions?.keys?.() || []));
const pool = others().sort(
(a, b) =>
Number(live.has(a.slice(PREFIX.length))) - Number(live.has(b.slice(PREFIX.length)))
);
while (pool.length >= MAX_KEYS) localStorage.removeItem(pool.shift());
}
try {
localStorage.setItem(key, snapshot);
} catch (_quota) {
// Quota exceeded: drop other snapshots one at a time and retry so a full
// quota can't permanently disable persistence.
for (const victim of others()) {
localStorage.removeItem(victim);
try {
localStorage.setItem(key, snapshot);
return;
} catch (_again) {
/* keep evicting */
}
}
try { localStorage.removeItem(key); } catch {}
}
} catch (_unavailable) {
/* localStorage unavailable (Safari private mode / disabled) — in-memory only */
}
}
_cleanupPreviousSession(newSessionId) {
// Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
// colors/attrs) before the terminal gets cleared/reset. Lets us restore the
// exact view on switch-back rather than replaying codex's byte stream, which
// drops earlier conversation from each TUI redraw and ends up showing only
// the latest (idle) frame.
// Shell sessions are never restored from a snapshot (restore is gated on
// mode !== 'shell'), so skip the serialize() + cache slot + localStorage
// quota for them. Unknown/undefined mode still snapshots, matching restore.
const outgoingSession = this.activeSessionId ? this.sessions?.get?.(this.activeSessionId) : null;
if (
this.activeSessionId &&
outgoingSession?.mode !== 'shell' &&
this._serializeAddon &&
this._xtermSnapshots
) {
try {
const snapshot = this._serializeAddon.serialize({ scrollback: 1000 });
if (this._isUsableXtermSnapshot(snapshot)) {
// Delete-before-set so re-touching a session moves it to the end of
// the Map's insertion order — otherwise eviction is FIFO and can drop
// the most-recently-used session instead of the least.
this._xtermSnapshots.delete(this.activeSessionId);
this._xtermSnapshots.set(this.activeSessionId, snapshot);
// Cap in-memory snapshot cache at 20 entries; evict oldest on overflow.
if (this._xtermSnapshots.size > 20) {
const oldest = this._xtermSnapshots.keys().next().value;
this._xtermSnapshots.delete(oldest);
}
// Persist to localStorage so the snapshot survives tab discard /
// browser reload (Chrome discards inactive tabs after idle periods,
// wiping in-memory state). Cap per-snapshot at 256KB; codex
// buffer-replay produces a visual mess of stacked banner redraws when
// no snapshot exists, so persistence matters more here than for claude.
if (snapshot.length < 256 * 1024) {
this._persistXtermSnapshot(`codeman-xs-${this.activeSessionId}`, snapshot);
}
} else {
this._xtermSnapshots.delete(this.activeSessionId);
try { localStorage.removeItem(`codeman-xs-${this.activeSessionId}`); } catch {}
}
} catch (_err) {
/* Serialize failed — fall back to server buffer replay */
}
}
// Close WebSocket for previous session (new one opens after buffer load)
this._disconnectWs();
@@ -2948,6 +3231,8 @@ class CodemanApp {
if (this.activeSessionId === sessionId && !forceReload) return;
if (this.activeSessionId === sessionId && forceReload) {
this.terminalBufferCache?.delete(sessionId);
this._xtermSnapshots?.delete(sessionId);
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
this._clearTimer('syncWaitTimeout');
this.pendingWrites = [];
this.writeFrameScheduled = false;
@@ -2993,6 +3278,10 @@ class CodemanApp {
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
this._updateActiveTabImmediate(sessionId);
this.renderSessionTabs();
this.updateAttachmentHistoryBadge?.();
if (this.attachmentHistoryDrawerOpen) {
this.loadAttachmentHistory?.(sessionId);
}
this._updateLocalEchoState();
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
@@ -3068,7 +3357,57 @@ class CodemanApp {
return;
}
// xterm snapshot restore: if we have a serialized xterm state from a
// previous visit to this session, restore the user's exact prior view
// (viewport + scrollback + colors) for an instant first paint. For codex
// this is also a correctness fix — its byte-stream replay shows only the
// latest TUI frame (the idle welcome banner) because codex doesn't include
// earlier conversation in its current redraw. For claude/opencode/gemini
// the replay is already complete, so the snapshot is purely a faster,
// scroll-preserving first paint before the canonical fetch reconciles.
//
// Try in-memory first (fast); fall back to localStorage so snapshots
// survive tab discards / browser reloads.
let snapshot = this._xtermSnapshots?.get(sessionId);
if (snapshot && !this._isUsableXtermSnapshot(snapshot)) {
this._xtermSnapshots?.delete(sessionId);
snapshot = null;
}
if (!snapshot) {
try {
const persisted = localStorage.getItem(`codeman-xs-${sessionId}`);
if (persisted && this._isUsableXtermSnapshot(persisted)) {
snapshot = persisted;
// Hoist into in-memory cache for next time (delete-before-set keeps
// the Map in LRU order so the just-used session isn't evicted first).
this._xtermSnapshots?.delete(sessionId);
this._xtermSnapshots?.set(sessionId, persisted);
} else if (persisted) {
localStorage.removeItem(`codeman-xs-${sessionId}`);
}
} catch (_e) {
/* localStorage unavailable — proceed without snapshot */
}
}
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
let restoredSnapshot = false;
if (snapshot && !sessionIsBusy && session?.mode !== 'shell') {
_crashDiag.log(`SNAPSHOT_RESTORE: ${(snapshot.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
this._resetTerminalForReplay();
await new Promise((resolve) => this.terminal.write(snapshot, resolve));
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
this.scrollToLastNonEmptyLine();
_crashDiag.log('SNAPSHOT_RESTORE_DONE');
// Snapshot restore is only first paint. Inactive tabs intentionally
// unsubscribe from high-volume terminal output, so they can miss bytes
// emitted while away. Keep going and replace the snapshot with the
// canonical live tmux pane frame from /terminal.
restoredSnapshot = true;
}
// Instant cache restore for IDLE sessions only.
// For busy sessions, the cache is always stale — writing it first causes a
@@ -3076,7 +3415,8 @@ class CodemanApp {
// blank and rewrites with fresh data. Skip the cache and write the fresh
// buffer once for a single clean transition.
const cachedBuffer = this.terminalBufferCache.get(sessionId);
if (cachedBuffer && !sessionIsBusy) {
let clearedForBusy = false;
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
this._resetTerminalForReplay();
@@ -3090,6 +3430,7 @@ class CodemanApp {
} else if (sessionIsBusy) {
// Clear stale content immediately — fresh buffer is being fetched
this._resetTerminalForReplay();
clearedForBusy = true;
_crashDiag.log('CACHE_SKIP_BUSY');
}
@@ -3120,9 +3461,11 @@ class CodemanApp {
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
// very visible, causing the terminal to flash blank then repaint.
// Busy sessions skip cache restore and clear the terminal before fetching,
// so they must replay the fetched buffer even when it matches cache.
const needsRewrite = sessionIsBusy || data.terminalBuffer !== cachedBuffer;
// A snapshot restore or a busy-clear leaves the terminal showing
// something other than the cache, so the fetched buffer must be
// replayed even when it byte-matches the cache.
const needsRewrite =
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
if (needsRewrite) {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
@@ -3298,6 +3641,8 @@ class CodemanApp {
}
this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
this._xtermSnapshots?.delete(sessionId);
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
@@ -3307,10 +3652,15 @@ class CodemanApp {
this.projectInsights.delete(sessionId);
this.pendingHooks.delete(sessionId);
this.tabAlerts.delete(sessionId);
this.attachmentHistoryCounts.delete(sessionId);
if (this.attachmentHistoryDrawerOpen && this.activeSessionId === sessionId) {
this.closeAttachmentHistory?.();
}
this.terminalLoadStates.delete(sessionId);
this.clearCountdownTimers(sessionId);
this.closeSessionLogViewerWindows(sessionId);
this.closeSessionImagePopups(sessionId);
this.closeSessionAttachmentCards(sessionId);
this.closeSessionSubagentWindows(sessionId, true);
// Clean up idle timer
@@ -3471,6 +3821,12 @@ class CodemanApp {
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.terminalLoadStates.clear();
this._xtermSnapshots?.clear();
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith('codeman-xs-')) localStorage.removeItem(k);
}
} catch {}
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.respawnStatus = {};
+21
View File
@@ -51,6 +51,7 @@ const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping
const NOTIFICATION_LIST_CAP = 100; // Max notifications in list
const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
@@ -113,9 +114,24 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK
// Expose for tests. `const` declarations at the top of a non-module script
// are global lexical bindings but not `window` properties, so explicit
// assignment is the test-visible API surface.
// Desktop tab-overflow policy: auto-wrap the session tabs to a second row when
// they overflow one row (and the user hasn't pinned the manual two-row layout).
function shouldAutoWrapTabs(input) {
if (!input || input.deviceType !== 'desktop') return false;
if (input.manualTwoRows) return false;
if ((input.tabCount || 0) < 2) return false;
const scrollWidth = Number(input.scrollWidth) || 0;
const clientWidth = Number(input.clientWidth) || 0;
return scrollWidth > clientWidth + 1;
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
};
}
// Scheduler API — prioritize terminal writes over background UI updates.
@@ -243,10 +259,14 @@ const SSE_EVENTS = {
SESSION_WORKING: 'session:working',
SESSION_AUTO_CLEAR: 'session:autoClear',
SESSION_AUTO_COMPACT: 'session:autoCompact',
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
SESSION_LIMIT_RESUME: 'session:limitResume',
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
SESSION_CLI_INFO: 'session:cliInfo',
SESSION_MESSAGE: 'session:message',
SESSION_INTERACTIVE: 'session:interactive',
SESSION_RUNNING: 'session:running',
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
// Scheduled runs
SCHEDULED_CREATED: 'scheduled:created',
@@ -332,6 +352,7 @@ const SSE_EVENTS = {
// Images
IMAGE_DETECTED: 'image:detected',
ATTACHMENT_DETECTED: 'attachment:detected',
// Tunnel
TUNNEL_STARTED: 'tunnel:started',
Binary file not shown.
Binary file not shown.
+88 -53
View File
@@ -8,7 +8,7 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="description" content="Claude Code session manager with web interface">
<meta name="theme-color" content="#0a0a0a">
<meta name="theme-color" content="#11151c">
<meta name="google" content="notranslate">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -30,21 +30,31 @@
'defer' preserves execution order (xterm loads before fit addon). -->
<script defer src="vendor/xterm.min.js"></script>
<script defer src="vendor/xterm-addon-fit.min.js"></script>
<!-- SerializeAddon: snapshots xterm state (viewport + scrollback + attrs) for
per-session restore on tab switches. Lets codex tabs survive switch-away
without codeman having to replay codex's byte stream (which loses earlier
conversation because codex's TUI redraws drop it from the viewport). -->
<script defer src="vendor/xterm-addon-serialize.min.js"></script>
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js"></script>
<script defer src="vendor/marked.min.js"></script>
<!-- DOMPurify (allowlist HTML sanitizer for rendered markdown).
Must load before sanitize-html.js (which wires it) and app.js (which calls it). -->
<script defer src="vendor/dompurify.min.js"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b}
.skeleton-header{height:40px;background:rgba(19,19,22,0.85);border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
.skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
.skeleton-terminal{flex:1;background:#0d0d0d}
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)}
.skeleton-terminal{flex:1;background:#161b23}
.skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
.app-loaded .loading-skeleton{display:none}
</style>
</head>
@@ -69,10 +79,6 @@
<span class="logo" onclick="app.goHome()" title="Go to main page">Codeman</span>
</div>
<button class="mobile-header-utility-toggle" id="mobileHeaderUtilityToggle" type="button" aria-label="Toggle header utilities" aria-controls="headerRight" aria-expanded="false" title="Header utilities">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</button>
<!-- Session Tabs -->
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
</div>
@@ -80,7 +86,7 @@
<!-- Detached single-session window title (shown only in solo mode) -->
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
<div class="header-right mobile-collapsed" id="headerRight">
<div class="header-right" id="headerRight">
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
<span class="tunnel-dot"></span>
@@ -111,7 +117,12 @@
</div>
</div>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
</button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
@@ -177,8 +188,8 @@
<svg viewBox="0 0 36 36" class="ralph-ring-svg">
<defs>
<linearGradient id="ralphGradientMini" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#22c55e" />
<stop offset="0%" stop-color="#3ec8ee" />
<stop offset="100%" stop-color="#2b8fd9" />
</linearGradient>
</defs>
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
@@ -223,8 +234,8 @@
<svg viewBox="0 0 100 100" class="ralph-ring-svg-large">
<defs>
<linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#22c55e" />
<stop offset="0%" stop-color="#3ec8ee" />
<stop offset="100%" stop-color="#2b8fd9" />
</linearGradient>
</defs>
<circle class="ralph-ring-track" cx="50" cy="50" r="42" />
@@ -497,7 +508,8 @@
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
@@ -608,17 +620,19 @@
<div class="modal-tab-content" id="respawn-tab">
<!-- Respawn Settings Section -->
<div class="session-respawn-section" id="sessionRespawnSection">
<div class="respawn-header">
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-indicator"></span>
<span class="respawn-status-text">Not active</span>
</div>
<div class="respawn-actions">
<button class="btn-toolbar btn-success btn-sm" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable</button>
<button class="btn-toolbar btn-danger btn-sm" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop</button>
</div>
<div class="auto-resume-box">
<label class="checkbox-inline">
<input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()">
<span>Auto-resume when usage limit resets</span>
</label>
<span class="auto-resume-status" id="autoResumeStatus"></span>
<span class="form-hint">If Claude pauses on a usage limit ("limit reached &middot; resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.</span>
</div>
<div class="respawn-loop-box">
<div class="respawn-loop-title">Respawn loop</div>
<span class="form-hint respawn-loop-hint">One autonomous work cycle: whenever Claude goes idle, Codeman sends the update prompt, optionally runs /clear + /init, and kickstarts the next round &mdash; repeating for the chosen duration. All settings below belong to this loop; configure them, then press Enable.</span>
<div class="form-row">
<label>Duration</label>
<div class="duration-presets">
@@ -655,10 +669,10 @@
<p class="form-hint" id="presetDescriptionHint"></p>
</div>
<div class="form-section-header">Respawn Cycle</div>
<div class="form-section-header">Cycle Steps</div>
<div class="form-row">
<label>1. Update Prompt</label>
<textarea id="modalRespawnPrompt" rows="3" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 60px;">update all the docs and CLAUDE.md</textarea>
<textarea id="modalRespawnPrompt" rows="1" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;">update all the docs and CLAUDE.md</textarea>
</div>
<div class="respawn-options-row" style="margin: 8px 0;">
@@ -670,22 +684,29 @@
<input type="checkbox" id="modalRespawnSendInit" checked onchange="app.autoSaveRespawnConfig()">
<span>3. Send /init</span>
</label>
</div>
<div class="form-row">
<label>4. Kickstart Prompt</label>
<textarea id="modalRespawnKickstart" rows="2" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 40px;"></textarea>
<span class="form-hint">Sent only when /init completes but Claude stays idle</span>
</div>
<div class="form-section-header">Behavior</div>
<div class="respawn-options-row">
<label class="checkbox-inline">
<label class="checkbox-inline" title="Presses Enter for plan approvals and default question options">
<input type="checkbox" id="modalRespawnAutoAccept" checked onchange="app.autoSaveRespawnConfig()">
<span>Auto-accept prompts</span>
</label>
</div>
<span class="form-hint">Auto-accept presses Enter for plan approvals and default question options</span>
<div class="form-row">
<label>4. Kickstart Prompt</label>
<textarea id="modalRespawnKickstart" rows="1" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;"></textarea>
<span class="form-hint">Sent only when /init completes but Claude stays idle &middot; Auto-accept presses Enter for plan approvals and default options</span>
</div>
<div class="respawn-header">
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-indicator"></span>
<span class="respawn-status-text">Not active</span>
</div>
<div class="respawn-actions">
<button class="btn-toolbar btn-success btn-sm" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable</button>
<button class="btn-toolbar btn-danger btn-sm" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop</button>
</div>
</div>
</div><!-- End respawn-loop-box -->
</div>
</div><!-- End respawn-tab -->
@@ -908,6 +929,16 @@
<!-- Display Tab -->
<div class="modal-tab-content" id="settings-display">
<div class="settings-grid">
<!-- Appearance Section -->
<div class="settings-section-header">Appearance</div>
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
<span class="settings-item-label">Skin</span>
<select id="appSettingsSkin" class="form-select">
<option value="daylight-blue">Daylight Blue</option>
<option value="daylight-green">Daylight Green</option>
<option value="og">OG Codeman</option>
</select>
</div>
<!-- Input Section -->
<div class="settings-section-header">Input</div>
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
@@ -954,6 +985,13 @@
<!-- Header Displays Section -->
<div class="settings-section-header">Header Displays</div>
<div class="settings-item" title="Show Claude plan usage limits (5-hour & weekly) in the header. Applies to newly created sessions.">
<span class="settings-item-label">Plan Usage Limits</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowPlanUsageLimits">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show A-/A+ font size buttons in header">
<span class="settings-item-label">Font Controls</span>
<label class="switch switch-sm">
@@ -968,20 +1006,6 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show total token count in header">
<span class="settings-item-label">Token Count</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowTokenCount" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show estimated cost next to token count">
<span class="settings-item-label">Show Cost ($)</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowCost">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show session lifecycle log button in header">
<span class="settings-item-label">Lifecycle Log</span>
<label class="switch switch-sm">
@@ -996,6 +1020,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
<span class="settings-item-label">Attachments Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowAttachmentsButton">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
<span class="settings-item-label">Multi-monitor Button</span>
<label class="switch switch-sm">
@@ -1156,6 +1187,8 @@
<option value="claude-fable-5">Fable 5</option>
<option value="opus[1m]">Opus (1M context)</option>
<option value="opus">Opus</option>
<option value="claude-opus-4-6[1m]">Opus 4.6 (1M context)</option>
<option value="claude-opus-4-6">Opus 4.6</option>
<option value="sonnet">Sonnet</option>
<option value="haiku">Haiku</option>
</select>
@@ -1885,6 +1918,8 @@
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<script defer src="app.js"></script>
<script defer src="terminal-ui.js"></script>
<script defer src="respawn-ui.js"></script>
+13 -2
View File
@@ -139,6 +139,9 @@ const MobileDetection = {
resizeTimeout = setTimeout(() => {
this.updateBodyClass();
this.updateAppHeight();
// Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only
// other trigger is a tab content render. No-op on mobile/tablet (method bails).
if (typeof app !== 'undefined') app.updateTabOverflowMode?.();
}, 100);
};
window.addEventListener('resize', this._resizeHandler);
@@ -304,8 +307,16 @@ const KeyboardHandler = {
// translate up so it sits at the bottom of the visual viewport.
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
// when the user types in xterm's hidden textarea.
const appEl = document.querySelector('.app');
const layoutHeight = appEl?.getBoundingClientRect().bottom || window.innerHeight;
//
// MUST measure against the LAYOUT viewport (window.innerHeight): the
// bars are position:fixed, which anchors to the layout viewport — on
// iOS that keeps its full height while the keyboard is open. Measuring
// the shrunken .app instead (its height tracks --app-height = visual
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
// and accessory bar behind the OS keyboard (0.9.8 regression). On
// Android the layout viewport itself shrinks with the keyboard, so
// innerHeight === visualBottom and the offset is naturally 0 there.
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
+93 -107
View File
@@ -36,6 +36,13 @@ html.mobile-init .file-browser-panel {
html {
touch-action: manipulation;
}
/* No "open in new window" (detach) on phones/tablets — popped-out browser
windows aren't usable there. !important beats the hover/detached reveal
rules in styles.css */
.session-tab .tab-detach {
display: none !important;
}
}
/* ============================================================================
@@ -94,55 +101,12 @@ html.mobile-init .file-browser-panel {
gap: 0.25rem;
}
.mobile-header-utility-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
margin: -4px 0.2rem -4px 0;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-muted);
order: -1;
position: relative;
z-index: 2;
flex-shrink: 0;
}
.mobile-header-utility-toggle.active,
.mobile-header-utility-toggle:active {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
/* Inline, always-visible header utilities (eye / multimonitor). The
position:fixed collapsible tray (02fa3f3) had its expand toggle reverted on
master but left this container permanently `mobile-collapsed` → the response
viewer eye became unreachable on phones. Restored to the simple inline flow. */
.header-right {
position: fixed;
top: calc(52px + var(--safe-area-top));
left: calc(0.5rem + var(--safe-area-left));
right: auto;
display: flex;
align-items: center;
gap: 0.35rem;
max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right));
padding: 0.35rem;
background: rgba(10, 10, 10, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
overflow-x: auto;
scrollbar-width: none;
z-index: 2000;
}
.header-right::-webkit-scrollbar {
display: none;
}
.header-right.mobile-collapsed {
display: none;
}
.btn-icon-header:not(.btn-sm) {
@@ -224,6 +188,12 @@ html.mobile-init .file-browser-panel {
/* ---- Settings Modal: Tablet Optimizations ---- */
/* Modals must stack above the fixed tablet header (z-index 1200) so the
modal header with the close button stays visible */
.modal {
z-index: 1300;
}
.modal-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
@@ -446,49 +416,8 @@ html.mobile-init .file-browser-panel {
}
.header-right {
position: fixed;
top: calc(40px + var(--safe-area-top));
left: calc(0.3rem + var(--safe-area-left));
right: auto;
display: flex;
align-items: center;
padding-left: 0.2rem;
gap: 0.1rem;
max-width: calc(100vw - 0.6rem - var(--safe-area-left) - var(--safe-area-right));
padding: 0.25rem;
background: rgba(10, 10, 10, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
overflow-x: auto;
scrollbar-width: none;
border-left: none;
z-index: 2000;
}
.header-right::-webkit-scrollbar {
display: none;
}
.header-right.mobile-collapsed {
display: none;
}
.mobile-header-utility-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
margin: -6px 0.15rem -6px 0;
background: transparent;
border: none;
border-radius: 5px;
color: var(--text-muted);
order: -1;
position: relative;
z-index: 2;
flex-shrink: 0;
border-left: none;
}
@@ -505,7 +434,9 @@ html.mobile-init .file-browser-panel {
height: 12px;
}
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar */
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar.
(The attachments button is opt-in / default-hidden everywhere via its own
--hidden marker, so it needs no mobile-specific rule here.) */
.btn-icon-header.btn-settings,
.btn-icon-header.btn-lifecycle-log {
display: none !important;
@@ -1258,6 +1189,13 @@ html.mobile-init .file-browser-panel {
max-height: 35vh;
}
/* Modals must stack above the fixed mobile header (z-index 1200), or the
modal header with the close button is buried underneath it and the
full-screen modal cannot be dismissed */
.modal {
z-index: 1300;
}
/* Full-screen modals on phones */
.modal-content {
width: 100%;
@@ -1571,42 +1509,36 @@ html.mobile-init .file-browser-panel {
border-radius: 5px;
}
/* Duration preset buttons — grid layout, 4 columns for even spacing */
/* Duration preset buttons — one compact row */
.duration-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
}
.duration-preset-btn {
min-height: 32px;
padding: 0.2rem 0.25rem;
font-size: 0.65rem;
border-radius: 5px;
min-height: 24px;
padding: 0.1rem 0.4rem;
font-size: 0.6rem;
border-radius: 4px;
text-align: center;
}
/* Custom duration — spans full row below */
.duration-custom {
grid-column: 1 / -1;
display: flex;
gap: 0.25rem;
gap: 0.2rem;
align-items: center;
}
.duration-custom .duration-preset-btn {
flex: 0 0 auto;
min-width: 50px;
}
.duration-custom-input.visible {
flex: 1;
flex: 0 1 auto;
}
.duration-custom-input input {
width: 100%;
min-height: 28px;
width: 64px;
min-height: 24px;
font-size: 16px; /* Prevents iOS zoom */
padding: 0.1rem 0.3rem;
}
/* Preset selector row — full-width dropdown, buttons below */
@@ -1665,6 +1597,16 @@ html.mobile-init .file-browser-panel {
height: 18px;
}
/* Respawn-loop box title matches the auto-resume title; its cycle-step
checkboxes match the step labels */
#sessionOptionsModal .respawn-loop-title {
font-size: 0.75rem;
}
#sessionOptionsModal .respawn-loop-box .checkbox-inline {
font-size: 0.65rem;
}
/* Respawn options row — stack if needed */
#sessionOptionsModal .respawn-options-row {
gap: 0.5rem;
@@ -2365,3 +2307,47 @@ html.mobile-init .file-browser-panel {
display: block;
}
}
@media (max-width: 430px) {
/* Attachment history (COD-18): full-screen sheet on phones */
.attachment-history-drawer {
top: 0;
bottom: 0;
width: 100%;
max-width: 100%;
height: 100vh;
height: 100dvh;
border-left: none;
border-radius: 0;
padding-top: var(--safe-area-top);
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
padding-bottom: var(--safe-area-bottom);
}
.attachment-history-header {
padding: 12px;
}
.attachment-history-list {
padding: 6px;
}
.attachment-history-item {
grid-template-columns: 104px minmax(0, 1fr);
gap: 8px;
padding: 8px 6px;
}
.attachment-history-thumb {
width: 104px;
}
.attachment-history-actions {
gap: 4px;
}
.attachment-history-actions button {
padding: 4px 7px;
}
}
+484 -26
View File
@@ -1550,29 +1550,7 @@ Object.assign(CodemanApp.prototype, {
}
const terminal = new Terminal({
theme: {
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
theme: { ...window.codemanCurrentXtermTheme() },
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontSize: 12,
lineHeight: 1.2,
@@ -2465,8 +2443,8 @@ Object.assign(CodemanApp.prototype, {
this.saveAppSettingsToStorage(settings);
},
async openFilePreview(filePath) {
if (!this.activeSessionId || !filePath) return;
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
if (!sessionId || !filePath) return;
const overlay = this.$('filePreviewOverlay');
const titleEl = this.$('filePreviewTitle');
@@ -2481,8 +2459,53 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
footerEl.textContent = '';
const ext = (filePath.split('.').pop() || '').toLowerCase();
// Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) {
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
footerEl.textContent = ext.toUpperCase();
if (IMAGE_EXTS.has(ext)) {
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
} else if (ext === 'pdf') {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'docx' || ext === 'pptx') {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else {
try {
const res = await fetch(`${base}/raw`);
if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text();
bodyEl.innerHTML = `<pre><code>${escapeHtml(text)}</code></pre>`;
} catch (err) {
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
}
return;
}
// Workspace-path (auto-detected, unregistered) attachments: Office docs are
// converted to PDF server-side via the file-preview route; PDFs stream raw.
// Both render inline in an iframe. Without this, docx/pptx/pdf fall through
// to file-content below, which would dump the binary bytes as mojibake.
if (ext === 'docx' || ext === 'pptx') {
footerEl.textContent = ext.toUpperCase();
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
if (ext === 'pdf') {
footerEl.textContent = 'PDF';
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
if (!res.ok) throw new Error('Failed to load file');
const result = await res.json();
@@ -2520,6 +2543,441 @@ Object.assign(CodemanApp.prototype, {
this.filePreviewContent = '';
},
// ═══════════════════════════════════════════════════════════════
// Attachment Cards (detected documents/images)
// ═══════════════════════════════════════════════════════════════
// SSE `attachment:detected` consumer: surface a dismissible card for the file
// and bump the per-session history unread count (refreshing the open drawer).
_onAttachmentDetected(data) {
console.log('[Attachment Detected]', data);
this.addAttachmentCard(data);
if (data.sessionId) {
const current =
this.attachmentHistoryCounts.get(data.sessionId) ??
this.sessions.get(data.sessionId)?.attachmentHistory?.length ??
0;
this.attachmentHistoryCounts.set(data.sessionId, Math.min(current + 1, 100));
if (data.sessionId === this.activeSessionId) {
this.updateAttachmentHistoryBadge();
if (this.attachmentHistoryDrawerOpen) {
this._debouncedCall(
'attachmentHistoryRefresh',
() => {
// The drawer may have closed or the active session changed during
// the debounce window — don't refresh for a stale session.
if (this.attachmentHistoryDrawerOpen && this.activeSessionId === data.sessionId) {
this.loadAttachmentHistory(data.sessionId);
}
},
250
);
}
}
}
},
// Lazily create the floating stack the cards live in (appended to <body>).
ensureAttachmentCardStack() {
let stack = this.attachmentCardStack || document.getElementById('attachmentCardStack');
if (!stack) {
stack = document.createElement('div');
stack.id = 'attachmentCardStack';
stack.className = 'attachment-card-stack';
document.body.appendChild(stack);
}
this.attachmentCardStack = stack;
return stack;
},
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
const url = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
window.open(url, '_blank');
},
addAttachmentCard(attachmentEvent) {
const {
sessionId,
relativePath,
fileName,
timestamp,
size,
attachmentType,
extension,
attachmentId,
rawUrl,
previewUrl,
thumbnailUrl,
} = attachmentEvent;
const filePath = relativePath || fileName;
const cardId = attachmentId || `${sessionId}-${timestamp}-${fileName}`;
if (this.attachmentCards.has(cardId)) {
const existing = this.attachmentCards.get(cardId);
existing.element.focus?.();
return;
}
const MAX_ATTACHMENT_CARDS = 10;
if (this.attachmentCards.size >= MAX_ATTACHMENT_CARDS) {
const oldestId = this.attachmentCards.keys().next().value;
if (oldestId) this.closeAttachmentCard(oldestId);
}
const stack = this.ensureAttachmentCardStack();
const session = this.sessions.get(sessionId);
const sessionName = session?.name || sessionId.substring(0, 8);
const attachmentRawUrl =
rawUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
const attachmentPreviewUrl =
previewUrl ||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
const attachmentThumbnailUrl =
thumbnailUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
const card = document.createElement('article');
card.className = `attachment-card attachment-${escapeHtml(attachmentType || 'file')}`;
card.tabIndex = 0;
card.dataset.attachmentId = cardId;
card.dataset.previewUrl = attachmentPreviewUrl || '';
card.innerHTML = `
<div class="attachment-thumbnail">
${attachmentThumbnailUrl ? `<img class="attachment-thumbnail-img" src="${escapeHtml(attachmentThumbnailUrl)}" alt="">` : ''}
<div class="attachment-thumbnail-fallback ${attachmentThumbnailUrl ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
</div>
<div class="attachment-card-main">
<div class="attachment-file-name" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</div>
<div class="attachment-file-meta">
<span>${escapeHtml(sessionName)}</span>
<span>${this.formatFileSize(size || 0)}</span>
</div>
<div class="attachment-actions">
<button type="button" class="attachment-preview-btn">Preview</button>
<a href="${escapeHtml(downloadUrl)}">Download</a>
<button type="button" class="attachment-open-btn">Open</button>
</div>
</div>
<button type="button" class="attachment-close-btn" title="Dismiss">&times;</button>
`;
const attachmentThumbnailImg = card.querySelector('.attachment-thumbnail-img');
if (attachmentThumbnailImg) {
attachmentThumbnailImg.onerror = () => {
attachmentThumbnailImg.remove();
card.querySelector('.attachment-thumbnail-fallback')?.classList.add('visible');
};
}
card.querySelector('.attachment-preview-btn')?.addEventListener('click', () => {
this.openFilePreview(filePath, sessionId, attachmentId || null);
});
card.querySelector('.attachment-open-btn')?.addEventListener('click', () => {
this.openAttachmentInNewTab(sessionId, filePath, attachmentId || null);
});
card.querySelector('.attachment-close-btn')?.addEventListener('click', () => {
this.closeAttachmentCard(cardId);
});
stack.prepend(card);
this.attachmentCards.set(cardId, { element: card, sessionId, filePath });
this._refreshAttachmentClearAll();
},
// Centralized show/hide for the stack's "Clear all" control. Both addAttachmentCard and
// closeAttachmentCard call this so the control appears on the 2nd card and hides at <=1.
_refreshAttachmentClearAll() {
const stack = this.attachmentCardStack;
if (!stack) return;
let control = stack.querySelector('.attachment-clear-all');
if (this.attachmentCards.size < 2) {
if (control) control.hidden = true;
return;
}
if (!control) {
control = document.createElement('button');
control.type = 'button';
control.className = 'attachment-clear-all';
control.textContent = 'Clear all';
control.title = 'Dismiss all attachment cards';
control.addEventListener('click', () => this.closeAllAttachmentCards());
stack.prepend(control);
}
control.hidden = false;
},
closeAttachmentCard(attachmentId) {
const cardData = this.attachmentCards.get(attachmentId);
if (!cardData) return;
cardData.element.remove();
this.attachmentCards.delete(attachmentId);
if (this.attachmentCardStack && this.attachmentCards.size === 0) {
this.attachmentCardStack.remove();
this.attachmentCardStack = null;
} else {
this._refreshAttachmentClearAll();
}
},
closeAllAttachmentCards() {
for (const attachmentId of [...this.attachmentCards.keys()]) {
this.closeAttachmentCard(attachmentId);
}
},
closeSessionAttachmentCards(sessionId) {
const toClose = [];
for (const [attachmentId, data] of this.attachmentCards) {
if (data.sessionId === sessionId) toClose.push(attachmentId);
}
for (const attachmentId of toClose) {
this.closeAttachmentCard(attachmentId);
}
},
// ═══════════════════════════════════════════════════════════════
// Attachment History Drawer
// ═══════════════════════════════════════════════════════════════
updateAttachmentHistoryBadge(count = null) {
const badge = document.getElementById('attachmentHistoryBadge');
const button = document.getElementById('attachmentsHistoryBtn');
const sessionId = this.activeSessionId;
const nextCount = count ?? (sessionId ? this.attachmentHistoryCounts.get(sessionId) || 0 : 0);
if (badge) {
badge.textContent = nextCount > 99 ? '99+' : String(nextCount);
badge.style.display = nextCount > 0 ? '' : 'none';
}
if (button) {
button.classList.toggle('active', this.attachmentHistoryDrawerOpen);
button.setAttribute('aria-expanded', this.attachmentHistoryDrawerOpen ? 'true' : 'false');
}
},
ensureAttachmentHistoryDrawer() {
let drawer = document.getElementById('attachmentHistoryDrawer');
if (drawer) return drawer;
drawer = document.createElement('aside');
drawer.id = 'attachmentHistoryDrawer';
drawer.className = 'attachment-history-drawer';
drawer.setAttribute('aria-label', 'Attachment history');
drawer.innerHTML = `
<div class="attachment-history-header">
<div>
<div class="attachment-history-title">Attachments</div>
<div class="attachment-history-subtitle" id="attachmentHistorySubtitle">0 files</div>
</div>
<div class="attachment-history-header-actions">
<button type="button" class="btn-icon-sm" id="attachmentHistoryRefreshBtn" title="Refresh" aria-label="Refresh attachments">&#x21BB;</button>
<button type="button" class="btn-icon-sm" id="attachmentHistoryCloseBtn" title="Close" aria-label="Close attachments">&times;</button>
</div>
</div>
<div class="attachment-history-list" id="attachmentHistoryList"></div>
`;
document.body.appendChild(drawer);
drawer.querySelector('#attachmentHistoryRefreshBtn')?.addEventListener('click', () => {
this.loadAttachmentHistory(this.activeSessionId);
});
drawer.querySelector('#attachmentHistoryCloseBtn')?.addEventListener('click', () => {
this.closeAttachmentHistory();
});
return drawer;
},
async toggleAttachmentHistory() {
if (this.attachmentHistoryDrawerOpen) {
this.closeAttachmentHistory();
return;
}
await this.openAttachmentHistory();
},
async openAttachmentHistory() {
const drawer = this.ensureAttachmentHistoryDrawer();
this.attachmentHistoryDrawerOpen = true;
drawer.classList.add('open');
this.updateAttachmentHistoryBadge();
await this.loadAttachmentHistory(this.activeSessionId);
},
closeAttachmentHistory() {
const drawer = document.getElementById('attachmentHistoryDrawer');
this.attachmentHistoryDrawerOpen = false;
drawer?.classList.remove('open');
// Cancel any pending debounced refresh so it can't fire against a closed drawer.
if (this._debounceTimers?.attachmentHistoryRefresh) {
clearTimeout(this._debounceTimers.attachmentHistoryRefresh);
this._debounceTimers.attachmentHistoryRefresh = null;
}
this.updateAttachmentHistoryBadge();
},
async loadAttachmentHistory(sessionId = this.activeSessionId) {
const drawer = this.ensureAttachmentHistoryDrawer();
const list = drawer.querySelector('#attachmentHistoryList');
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
if (!list || !subtitle) return;
if (!sessionId) {
this.attachmentHistoryItems = [];
subtitle.textContent = 'No session';
list.innerHTML = '<div class="attachment-history-empty">No active session</div>';
this.updateAttachmentHistoryBadge(0);
return;
}
list.innerHTML = '<div class="attachment-history-empty">Loading...</div>';
try {
const res = await fetch(`/api/sessions/${sessionId}/attachments`);
if (!res.ok) throw new Error('Failed to load attachments');
const result = await res.json();
if (!result.success) throw new Error(result.error || 'Failed to load attachments');
const items = result.data?.items || [];
this.attachmentHistoryItems = items;
this.attachmentHistoryCounts.set(sessionId, items.length);
this.updateAttachmentHistoryBadge(items.length);
this.renderAttachmentHistory(items);
} catch (err) {
console.error('Failed to load attachment history:', err);
subtitle.textContent = 'Unavailable';
list.innerHTML = `<div class="attachment-history-empty">Error: ${escapeHtml(err.message)}</div>`;
}
},
renderAttachmentHistory(items = this.attachmentHistoryItems || []) {
const drawer = this.ensureAttachmentHistoryDrawer();
const list = drawer.querySelector('#attachmentHistoryList');
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
if (!list || !subtitle) return;
subtitle.textContent = `${items.length} ${items.length === 1 ? 'file' : 'files'}`;
if (items.length === 0) {
list.innerHTML = `
<div class="attachment-history-empty">
<div class="attachment-history-empty-title">No attachments yet</div>
<div>Show a file here by running:</div>
<code>codeman attach /absolute/path/to/file.pptx</code>
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
</div>
`;
return;
}
list.innerHTML = items.map((item) => this.renderAttachmentHistoryItem(item)).join('');
list.querySelectorAll('.attachment-history-thumb-img').forEach((img) => {
img.onerror = () => {
img.remove();
const fallback = img.closest('.attachment-history-thumb')?.querySelector('.attachment-history-thumb-fallback');
fallback?.classList.add('visible');
};
});
list.querySelectorAll('[data-attachment-action]').forEach((button) => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-history-id');
const action = button.getAttribute('data-attachment-action');
if (!id || !action) return;
if (action === 'preview') this.previewAttachmentHistoryItem(id);
if (action === 'download') this.downloadAttachmentHistoryItem(id);
if (action === 'open') this.openAttachmentHistoryItem(id);
if (action === 'reshow') this.reshowAttachmentCard(id);
});
});
},
renderAttachmentHistoryItem(item) {
const typeLabel = (item.extension || item.attachmentType || 'file').toUpperCase();
const meta = [
item.source === 'external' ? 'published' : 'workspace',
this.formatFileSize(item.size || 0),
item.missing ? 'missing' : '',
]
.filter(Boolean)
.join(' • ');
const thumb =
item.thumbnailUrl && !item.missing
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
: '';
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
return `
<div class="attachment-history-item ${item.missing ? 'missing' : ''}" data-history-item="${escapeHtml(item.id)}">
<div class="attachment-history-thumb">
${thumb}
<div class="attachment-history-thumb-fallback ${thumb ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
</div>
<div class="attachment-history-item-main">
<div class="attachment-history-file-name" title="${escapeHtml(item.fileName)}">${escapeHtml(item.fileName)}</div>
<div class="attachment-history-meta">${escapeHtml(meta)}</div>
<div class="attachment-history-actions">
<button type="button" data-attachment-action="preview" data-history-id="${escapeHtml(item.id)}" ${disabled}>Preview</button>
<button type="button" data-attachment-action="download" data-history-id="${escapeHtml(item.id)}" ${disabled}>Download</button>
<button type="button" data-attachment-action="open" data-history-id="${escapeHtml(item.id)}" ${disabled}>Open</button>
<button type="button" data-attachment-action="reshow" data-history-id="${escapeHtml(item.id)}" ${disabled}>Card</button>
</div>
</div>
</div>
`;
},
getAttachmentHistoryItem(itemId) {
return (this.attachmentHistoryItems || []).find((item) => item.id === itemId) || null;
},
previewAttachmentHistoryItem(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing) return;
const path = item.relativePath || item.fileName;
this.openFilePreview(path, item.sessionId, item.attachmentId || null);
// Close the drawer so the preview window is unobstructed.
this.closeAttachmentHistory();
},
openAttachmentHistoryItem(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing) return;
if (item.rawUrl || item.url) {
window.open(item.rawUrl || item.url, '_blank');
return;
}
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
},
downloadAttachmentHistoryItem(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing || !item.downloadUrl) return;
window.open(item.downloadUrl, '_blank');
},
reshowAttachmentCard(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing) return;
this.addAttachmentCard({
sessionId: item.sessionId,
relativePath: item.relativePath,
fileName: item.fileName,
// Use the item's own timestamp (not Date.now()) so the derived cardId is
// stable across clicks — re-showing focuses the existing card instead of
// stacking a duplicate.
timestamp: item.timestamp ?? Date.now(),
size: item.size,
attachmentType: item.attachmentType,
extension: item.extension,
attachmentId: item.attachmentId,
rawUrl: item.rawUrl,
previewUrl: item.previewUrl,
thumbnailUrl: item.thumbnailUrl,
});
},
copyFilePreviewContent() {
if (this.filePreviewContent) {
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
+165
View File
@@ -0,0 +1,165 @@
/**
* @fileoverview Allowlist-based HTML sanitizer for markdown-rendered, agent/transcript-derived
* content that is subsequently assigned via innerHTML (response viewer, attachment markdown
* preview, message bodies).
*
* Security (COD-56): the previous sanitizer was a hand-rolled DENYLIST — it removed a fixed
* set of tags (script/iframe/object/embed/form/base/meta/link/style), stripped on* attrs and a
* few dangerous URL schemes, then re-serialized. Denylists are mXSS-prone: they did not strip
* `svg`/`math` (which carry their own foreign-namespace parsing rules and can smuggle script via
* namespace confusion), did not strip `style` attributes (CSS `expression()`/`url(javascript:)`
* on legacy engines), and had no positive allowlist, so any tag/attribute not explicitly named
* survived. `marked` runs with raw-HTML passthrough, so crafted HTML echoed by an agent flows
* straight into this function.
*
* This module replaces that with DOMPurify (Cure53), an allowlist sanitizer that is the
* industry standard for mXSS defense. It is configured to allow exactly the tag/attribute set
* that markdown rendering legitimately produces (headings, lists, code, blockquotes, links,
* tables, images with safe src) and to FORBID `style`/`svg`/`math` plus all event handlers and
* dangerous URL schemes.
*
* Cross-environment: in the browser this file runs as a classic <script> after
* vendor/dompurify.min.js and wires `window.sanitizeMarkdownHtml`. The factory is also exported
* (window/globalThis + CommonJS) so a jsdom unit test can build a sanitizer bound to a
* jsdom-window DOMPurify instance and exercise the exact same config.
*
* @globals {function} sanitizeMarkdownHtml - (html:string) => string, sanitized HTML
* @globals {function} createMarkdownSanitizer - (DOMPurify) => sanitizeMarkdownHtml (for tests)
* @dependency vendor/dompurify.min.js (provides the global DOMPurify)
* @loadorder 5.6 of 15 — after input-cjk.js(5.5), before app.js(6) (app.js calls it)
*/
(function (root) {
'use strict';
/**
* Tags markdown rendering (marked, gfm) legitimately emits. Anything outside this set is
* dropped by DOMPurify. Deliberately excludes svg/math (mXSS foreign-namespace vectors) and
* form/embed/object/iframe/script/style (no place in rendered markdown).
*/
var ALLOWED_TAGS = [
'a',
'b',
'blockquote',
'br',
'caption',
'code',
'del',
'div',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'ins',
'kbd',
'li',
'mark',
'ol',
'p',
'pre',
'q',
's',
'samp',
'span',
'strong',
'sub',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'ul',
'var',
];
/**
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
* blocks may carry `language-*` classes from marked.
*/
var ALLOWED_ATTR = [
'href',
'src',
'alt',
'title',
'class',
'id',
'name',
'colspan',
'rowspan',
'align',
'width',
'height',
'lang',
'dir',
'start',
'reversed',
'type',
];
/**
* Build a sanitizer bound to a specific DOMPurify instance. The browser passes the global
* DOMPurify; tests pass a jsdom-window-bound instance so the same config is exercised under
* vitest without a real browser.
*/
function createMarkdownSanitizer(DOMPurify) {
if (!DOMPurify || typeof DOMPurify.sanitize !== 'function') {
throw new Error('createMarkdownSanitizer: a DOMPurify instance is required');
}
var CONFIG = {
ALLOWED_TAGS: ALLOWED_TAGS,
ALLOWED_ATTR: ALLOWED_ATTR,
// Defense in depth even though style/svg/math are not in ALLOWED_TAGS: also forbid the
// foreign-namespace roots and style so config drift can't silently re-admit them.
FORBID_TAGS: ['style', 'svg', 'math', 'script', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['style'],
// NOTE: do NOT set USE_PROFILES here. DOMPurify treats USE_PROFILES and
// ALLOWED_TAGS/ALLOWED_ATTR as mutually exclusive — when a profile is set it
// RESETS the allow-lists to the full profile and silently ignores the curated
// lists above, widening the tag set far beyond what markdown emits. Relying on
// the explicit ALLOWED_TAGS/ALLOWED_ATTR keeps the tight allowlist in force;
// FORBID_TAGS/FORBID_ATTR remain as defense-in-depth. DOMPurify still applies
// its default safe-URI handling (blocks javascript:/vbscript:, allows
// http/https/mailto/tel + data: only on image tags).
ALLOW_DATA_ATTR: false,
ADD_ATTR: [],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
// Keep text content of any removed element (so stripping a stray tag doesn't eat prose),
// matching the previous serializer's behavior of dropping the element but not its text.
KEEP_CONTENT: true,
};
return function sanitizeMarkdownHtml(html) {
return DOMPurify.sanitize(html == null ? '' : String(html), CONFIG);
};
}
// Expose the factory for tests (and any non-browser consumer).
if (root) {
root.createMarkdownSanitizer = createMarkdownSanitizer;
// In the browser, vendor/dompurify.min.js has already defined the global DOMPurify.
if (root.DOMPurify && typeof root.DOMPurify.sanitize === 'function') {
root.sanitizeMarkdownHtml = createMarkdownSanitizer(root.DOMPurify);
}
}
// CommonJS export for the vitest/jsdom unit test.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
createMarkdownSanitizer: createMarkdownSanitizer,
ALLOWED_TAGS: ALLOWED_TAGS,
ALLOWED_ATTR: ALLOWED_ATTR,
};
}
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
+44
View File
@@ -370,6 +370,13 @@ Object.assign(CodemanApp.prototype, {
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
// Plan-usage statusLine exporter (App Settings → Display). The server
// ADDS our exporter on create when true; when false it intentionally
// leaves any existing exporter in place (a per-repo settings.local.json
// is shared by sibling sessions, so create-with-false must not yank it
// — see the comment in session-routes create). Disabling the setting
// removes it via the App Settings toggle path (system-routes), not here.
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
})
}).then(r => r.json())
);
@@ -690,6 +697,10 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
// Populate auto-resume on usage limit (token pause control)
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
this.updateAutoResumeStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
@@ -790,6 +801,39 @@ Object.assign(CodemanApp.prototype, {
} catch { /* silent */ }
},
async autoSaveAutoResume() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalAutoResumeEnabled').checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-resume`, { enabled });
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.autoResumeEnabled = enabled;
if (!enabled) session.autoResumeAt = undefined;
}
this.updateAutoResumeStatus(this.editingSessionId);
this.showToast(`Auto-resume on usage limit ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle auto-resume: ' + err.message, 'error');
}
},
// Show "resumes at HH:MM" in the session options modal while a usage-limit
// pause is armed for the session being edited
updateAutoResumeStatus(sessionId) {
const el = document.getElementById('autoResumeStatus');
if (!el || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
if (session?.autoResumeAt && session.autoResumeAt > Date.now()) {
const at = new Date(session.autoResumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
el.textContent = `Usage limit pause active — resumes at ${at}`;
el.classList.add('active');
} else {
el.textContent = '';
el.classList.remove('active');
}
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
+155 -75
View File
@@ -305,15 +305,16 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? defaults.showTokenCount ?? true;
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -844,11 +845,18 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = true;
try {
const newEnabled = !isActive;
await fetch('/api/settings', {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (newEnabled && (await this._handleTunnelEnableRefusal(res))) {
this._dismissTunnelConnecting();
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
return;
}
if (newEnabled) {
this._showTunnelConnecting();
// Poll tunnel status as fallback in case SSE event is missed
@@ -1148,13 +1156,40 @@ Object.assign(CodemanApp.prototype, {
return `${Math.floor(hrs / 24)}d ago`;
},
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* @param {Response|null} res - the fetch Response from the settings PUT
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
try {
const body = await res.json();
if (body && body.error) message = body.error;
} catch {
/* non-JSON body — use the default message */
}
this._dismissTunnelConnecting?.();
this.showToast(message, 'error');
return true;
},
async _tunnelPanelToggle(enable) {
try {
await fetch('/api/settings', {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (enable && (await this._handleTunnelEnableRefusal(res))) {
this.closeTunnelPanel();
return;
}
if (enable) {
this._updateTunnelIndicator(false);
const indicator = document.getElementById('tunnelIndicator');
@@ -1317,7 +1352,8 @@ Object.assign(CodemanApp.prototype, {
async saveAppSettings() {
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prevGestureEnabled = (this.loadAppSettingsFromStorage().gestureControlEnabled ?? false) === true;
const _prev = this.loadAppSettingsFromStorage();
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
const settings = {
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
@@ -1325,15 +1361,15 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
showCost: document.getElementById('appSettingsShowCost').checked,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -1343,6 +1379,7 @@ Object.assign(CodemanApp.prototype, {
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
skin: document.getElementById('appSettingsSkin').value,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
@@ -1360,6 +1397,16 @@ Object.assign(CodemanApp.prototype, {
},
};
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
// UI, but their features still read settings.showTokenCount / settings.showCost
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
// rebuilds `settings` fresh from the DOM (a full replacement, not a merge), so
// without this these keys would be DROPPED on every save and fall back to their
// defaults — silently re-enabling the token chip for anyone who'd turned it off,
// with no UI left to turn it back off. Preserve the prior stored preference.
if (_prev.showTokenCount !== undefined) settings.showTokenCount = _prev.showTokenCount;
if (_prev.showCost !== undefined) settings.showCost = _prev.showCost;
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
@@ -1457,6 +1504,7 @@ Object.assign(CodemanApp.prototype, {
// Apply header visibility immediately
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
@@ -1469,11 +1517,42 @@ Object.assign(CodemanApp.prototype, {
// Apply keyboard bar mode
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
// Save to server (includes notification prefs for cross-browser persistence)
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
// Save to server (includes notification prefs for cross-browser persistence).
// Strip device-specific DISPLAY keys so they never sync across devices —
// localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits
// is per-device too (desktop can show the usage chip while mobile stays hidden).
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
// ENABLE only, so a device with the chip OFF never strips the exporter that
// another device's chip depends on — see system-routes settings handler).
const {
localEchoEnabled: _leo,
cjkInputEnabled: _cjk,
extendedKeyboardBar: _ekb,
skin: _skin,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
...serverSettings
} = settings;
try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
const res = await this._apiPut('/api/settings', {
...serverSettings,
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
this.closeAppSettings();
return;
}
// Save model configuration separately
await this.saveModelConfigFromSettings();
@@ -1604,6 +1683,8 @@ Object.assign(CodemanApp.prototype, {
showFileBrowser: false,
showSubagents: false,
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
// Input
gestureControlEnabled: false,
// Feature toggles - keep tracking on even on mobile
@@ -1613,6 +1694,7 @@ Object.assign(CodemanApp.prototype, {
ralphTrackerEnabled: false,
tabTwoRows: false,
cjkInputEnabled: false,
skin: 'daylight-blue',
};
}
// Desktop defaults - rely on ?? operators in apply functions
@@ -1650,6 +1732,24 @@ Object.assign(CodemanApp.prototype, {
}
},
// Apply the chosen skin live: sets the html[data-skin] attribute, syncs BOTH
// localStorage locations (the standalone 'codeman:skin' key the pre-paint head
// script reads + the app-settings blob field written by saveAppSettingsToStorage),
// updates window.__codemanSkin, and re-themes any live terminals.
applySkin() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.documentElement.setAttribute('data-skin', skin);
window.__codemanSkin = skin;
try {
localStorage.setItem('codeman:skin', skin);
} catch (_e) {
/* private mode */
}
if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin);
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -1687,6 +1787,14 @@ Object.assign(CodemanApp.prototype, {
responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer);
}
// Hide the attachments (history) button when disabled. Opt-in, default OFF —
// marker class, base is display:inline-flex !important.
const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
const attachmentsBtn = document.getElementById('attachmentsHistoryBtn');
if (attachmentsBtn) {
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
}
// Multi-monitor button — hidden by default (App Settings → Display → "Header
// Displays"). The server renders the correct initial state on every reload;
// this handles a live toggle from a settings save (no reload). Toggle the
@@ -1697,6 +1805,16 @@ Object.assign(CodemanApp.prototype, {
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
}
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
// Limits"). Server renders the initial state on reload; this handles a live
// toggle from a settings save. Marker class (base is display:inline-flex
// !important), matching the response-viewer/multimonitor pattern.
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
const planUsageChip = document.getElementById('planUsageChip');
if (planUsageChip) {
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
}
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
const notifBtn = document.querySelector('.btn-notifications');
@@ -1711,70 +1829,6 @@ Object.assign(CodemanApp.prototype, {
}
},
toggleMobileHeaderUtilities() {
const tray = document.getElementById('headerRight');
const toggle = document.getElementById('mobileHeaderUtilityToggle');
if (!tray) return;
const expanded = tray.classList.toggle('mobile-collapsed') === false;
if (toggle) {
toggle.classList.toggle('active', expanded);
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
},
handleMobileHeaderUtilityToggle(event) {
if (event) {
event.preventDefault?.();
event.stopPropagation?.();
const now = Date.now();
if ((event.type === 'click' || event.type === 'touchend') && this._lastMobileHeaderUtilityPointerAt) {
if (now - this._lastMobileHeaderUtilityPointerAt < 500) return;
}
if (event.type === 'click' && this._lastMobileHeaderUtilityTouchAt) {
if (now - this._lastMobileHeaderUtilityTouchAt < 500) return;
}
if (event.type === 'touchend') {
this._lastMobileHeaderUtilityTouchAt = now;
}
if (event.type === 'pointerup') {
this._lastMobileHeaderUtilityPointerAt = now;
}
}
this.toggleMobileHeaderUtilities();
},
bindMobileHeaderUtilityToggle() {
const toggle = document.getElementById('mobileHeaderUtilityToggle');
if (!toggle || this._mobileHeaderUtilityToggleEl === toggle) return;
if (this._mobileHeaderUtilityToggleEl && this._mobileHeaderUtilityToggleHandler) {
this._mobileHeaderUtilityToggleEl.removeEventListener('click', this._mobileHeaderUtilityToggleHandler);
this._mobileHeaderUtilityToggleEl.removeEventListener('touchend', this._mobileHeaderUtilityToggleHandler);
this._mobileHeaderUtilityToggleEl.removeEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
}
this._mobileHeaderUtilityToggleEl = toggle;
this._mobileHeaderUtilityToggleHandler = (event) => this.handleMobileHeaderUtilityToggle(event);
toggle.addEventListener('click', this._mobileHeaderUtilityToggleHandler);
toggle.addEventListener('touchend', this._mobileHeaderUtilityToggleHandler, { passive: false });
toggle.addEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
},
closeMobileHeaderUtilities() {
const tray = document.getElementById('headerRight');
const toggle = document.getElementById('mobileHeaderUtilityToggle');
if (!tray || tray.classList.contains('mobile-collapsed')) return;
tray.classList.add('mobile-collapsed');
if (toggle) {
toggle.classList.remove('active');
toggle.setAttribute('aria-expanded', 'false');
}
},
applyTabWrapSettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -1943,6 +1997,25 @@ Object.assign(CodemanApp.prototype, {
},
async loadAppSettingsFromServer(settingsPromise = null) {
// One-time migration: showPlanUsageLimits became a per-device display setting.
// Before this, it synced from the server, so the (separate) mobile settings blob
// may carry a stale `true` the user never enabled on this device. Clear it once
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
try {
if (
MobileDetection.getDeviceType() === 'mobile' &&
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
) {
const s = this.loadAppSettingsFromStorage();
if (s && s.showPlanUsageLimits) {
s.showPlanUsageLimits = false;
this.saveAppSettingsToStorage(s);
}
localStorage.setItem('codeman:planUsagePerDeviceMigrated', '1');
}
} catch {
/* best-effort migration */
}
try {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
if (settings) {
@@ -1957,7 +2030,14 @@ Object.assign(CodemanApp.prototype, {
'showLifecycleLog', 'showResponseViewer',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
]);
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
// can show it while mobile stays hidden. It used to sync, so an older
// server.json may still carry `true` — drop it so the server value is NEVER
// seeded into a device that didn't explicitly enable it (collection is handled
// separately via the statusLineTelemetry action, not this display flag).
delete appSettings.showPlanUsageLimits;
// Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value
// (prevents cross-device overwrite while fixing settings re-enabling on fresh loads)
+905 -36
View File
File diff suppressed because it is too large Load Diff
+116 -28
View File
@@ -15,6 +15,10 @@
(function (global) {
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
// Grace window after a manual scroll-up gesture during which sticky-scroll is
// suppressed, so high-frequency Codex status redraws don't snap the viewport
// back to the bottom while the user is inspecting earlier output.
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
@@ -24,10 +28,27 @@
return isTerminalQueryResponse(data);
}
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
// theme, so default behavior is unchanged. Shared at module scope and exported on the
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
// a separate IIFE) can read the current skin's palette.
const CODEMAN_XTERM_THEMES = {
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
};
function currentXtermTheme() {
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
}
global.CodemanTerminalInput = {
isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse,
USER_SCROLL_STICKY_SUPPRESS_MS,
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
})(window);
Object.assign(CodemanApp.prototype, {
@@ -42,29 +63,7 @@ Object.assign(CodemanApp.prototype, {
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
this.terminal = new Terminal({
theme: {
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
theme: { ...window.codemanCurrentXtermTheme() },
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
@@ -79,6 +78,23 @@ Object.assign(CodemanApp.prototype, {
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
// SerializeAddon: lets us snapshot the xterm rendered state (viewport +
// scrollback + colors/attrs) when switching away from a tab and restore
// it on switch-back. Needed primarily for codex tabs — codex's TUI drops
// earlier conversation from its current frame, so replaying the server
// byte buffer on tab-switch shows only the latest (idle) frame. The
// snapshot captures what the user was actually looking at.
this._xtermSnapshots = new Map(); // Map<sessionId, serialized-string>
if (typeof SerializeAddon !== 'undefined') {
try {
this._serializeAddon = new SerializeAddon.SerializeAddon();
this.terminal.loadAddon(this._serializeAddon);
} catch (_e) {
/* SerializeAddon failed — snapshot/restore disabled, fallback to buffer-fetch */
this._serializeAddon = null;
}
}
if (typeof Unicode11Addon !== 'undefined') {
try {
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
@@ -98,8 +114,14 @@ Object.assign(CodemanApp.prototype, {
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
// Let Alt+digit pass through to browser (tab switching)
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
// Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
// the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“") are suppressed here too and
// don't leak an escape sequence into the focused terminal on every tab switch.
if (ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code || '')) {
return false;
}
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
// Route through our paste trap which handles both images and text.
@@ -302,6 +324,7 @@ Object.assign(CodemanApp.prototype, {
(ev) => {
ev.preventDefault();
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
},
{ passive: false }
@@ -376,6 +399,7 @@ Object.assign(CodemanApp.prototype, {
const ch = cellHeight();
const lines = Math.trunc(pixelAccum / ch);
if (lines !== 0) {
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
pixelAccum -= lines * ch;
}
@@ -428,6 +452,7 @@ Object.assign(CodemanApp.prototype, {
this._chunkedWriteGen = 0;
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
this._lastUserScrollUpAt = null;
// Handle resize with throttling for performance
this._resizeTimeout = null;
@@ -918,6 +943,9 @@ Object.assign(CodemanApp.prototype, {
this.loadTunnelStatus();
this.loadHistorySessions();
}
// Home screen has no input target — hide the CJK textarea (activeSessionId
// is null by the time we get here). Guarded: defined on the app object.
this._updateCjkInputState?.();
},
hideWelcome() {
@@ -931,6 +959,9 @@ Object.assign(CodemanApp.prototype, {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.remove('expanded');
}
// Entering a session — restore CJK textarea if the user has it enabled
// (activeSessionId is already set by selectSession before this call).
this._updateCjkInputState?.();
},
/**
@@ -1364,6 +1395,22 @@ Object.assign(CodemanApp.prototype, {
return buffer.viewportY >= buffer.baseY - 2;
},
// Record manual scroll gestures so sticky-scroll can give an upward scroll a
// short grace window (see _hasRecentUserScrollUp). A downward scroll that
// lands back at the bottom clears the suppression immediately.
_noteTerminalUserScroll(lines) {
if (lines < 0) {
this._lastUserScrollUpAt = performance.now();
} else if (this.isTerminalAtBottom()) {
this._lastUserScrollUpAt = null;
}
},
_hasRecentUserScrollUp() {
if (typeof this._lastUserScrollUpAt !== 'number') return false;
return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS;
},
batchTerminalWrite(data) {
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
// to prevent interleaving historical buffer data with live SSE data.
@@ -1615,8 +1662,16 @@ Object.assign(CodemanApp.prototype, {
// Per-frame byte budget to prevent main thread blocking.
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
const MAX_FRAME_BYTES = 65536; // 64KB budget per frame
// Codex's TUI emits dense synchronized redraws during thinking/high-effort
// phases, so it gets a smaller first frame to keep per-frame xterm/WebGL
// stalls short; other modes keep the larger 64KB budget.
const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null;
const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536;
let deferred = false;
// If the user recently scrolled up, remember the viewport so we can restore
// it after the write — Codex status redraws would otherwise jump it.
const preserveViewportY =
this._hasRecentUserScrollUp() && this.terminal.buffer?.active ? this.terminal.buffer.active.viewportY : null;
if (_joinedLen <= MAX_FRAME_BYTES) {
this.terminal.write(joined);
@@ -1633,6 +1688,13 @@ Object.assign(CodemanApp.prototype, {
});
}
}
if (
preserveViewportY !== null &&
this.terminal.buffer?.active?.viewportY !== preserveViewportY &&
typeof this.terminal.scrollToLine === 'function'
) {
this.terminal.scrollToLine(preserveViewportY);
}
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
const _dt = performance.now() - _t0;
if (_dt > 100 || deferred)
@@ -1640,8 +1702,11 @@ Object.assign(CodemanApp.prototype, {
`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)`
);
// Sticky scroll: if user was at bottom, keep them there after new output
if (this._wasAtBottomBeforeWrite) {
// Sticky scroll: if user was at bottom, keep them there after new output.
// Give manual scroll-up gestures a short grace window so high-frequency
// Codex status ticks do not snap the viewport back while the user is
// trying to inspect earlier output.
if (this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
this.terminal.scrollToBottom();
}
@@ -2123,4 +2188,27 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('dirDisplay').textContent = value || 'No directory';
}, 100);
},
// Re-theme all live xterm terminals (main + teammate) to the given skin's palette.
// Uses the xterm v5+ live setter (full object assignment triggers a repaint for both
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
applyTerminalSkin(skin) {
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
if (this.terminal) {
this.terminal.options.theme = theme;
try {
this.terminal.refresh(0, this.terminal.rows - 1);
} catch {}
}
if (this.teammateTerminals) {
for (const [, entry] of this.teammateTerminals) {
if (entry && entry.terminal) {
entry.terminal.options.theme = { ...theme };
try {
entry.terminal.refresh(0, entry.terminal.rows - 1);
} catch {}
}
}
}
},
});
File diff suppressed because one or more lines are too long
+568 -22
View File
@@ -3,16 +3,370 @@
* Provides directory listing, file content preview, raw file serving, and tail streaming.
*/
import { FastifyInstance } from 'fastify';
import { FastifyInstance, type FastifyReply } from 'fastify';
import { basename as pathBasename, join } from 'node:path';
import { homedir } from 'node:os';
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import {
AttachmentRegistrationError,
attachmentRecordToEvent,
attachmentRegistry,
buildFileThumbnailRoute,
isSupportedAttachmentExtension,
registerExternalAttachment,
type AttachmentRecord,
} from '../../attachment-registry.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
const MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
ico: 'image/x-icon',
bmp: 'image/bmp',
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
json: 'application/json',
md: 'text/markdown',
txt: 'text/plain',
};
function sanitizeDownloadName(fileName: string): string {
return fileName.replace(/["\\\r\n]/g, '_');
}
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
const headers = reply.getHeaders();
reply.hijack();
for (const [name, value] of Object.entries(headers)) {
if (value !== undefined) {
reply.raw.setHeader(name, value);
}
}
content.on('error', (err) => {
if (reply.raw.headersSent) {
reply.raw.destroy(err);
return;
}
reply.raw.statusCode = 500;
reply.raw.end('Failed to read file');
});
content.pipe(reply.raw);
}
async function serveRawFile(
reply: FastifyReply,
resolvedPath: string,
fileName: string,
extension: string,
download?: boolean
): Promise<void> {
const stat = await fs.stat(resolvedPath);
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
)
);
return;
}
const content = createReadStream(resolvedPath);
const safeName = sanitizeDownloadName(fileName);
if (download || extension === 'svg') {
reply.header(
'Content-Type',
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
);
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
return;
}
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
}
function getAttachmentOr404(
reply: FastifyReply,
sessionId: string,
attachmentId: string
): AttachmentRecord | undefined {
const record = attachmentRegistry.get(sessionId, attachmentId);
if (!record) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found'));
return undefined;
}
return record;
}
/**
* COD-53 defense-in-depth: refuse to stream a record whose underlying path is
* blocked by the active attachment-guard policy, even though registration
* already blocks them. Guards against records that predate the guard or were
* crafted to point at a sensitive file. Resolves symlinks before the check so a
* record pointing at a symlink that now resolves to a sensitive target is also
* caught; if the path can't be resolved (deleted/unreadable) the check still
* runs on the stored path. When workspace confinement is enabled it additionally
* rejects any record outside the session workspace. Returns true (and sends a
* 403) when blocked.
*/
async function resolveServableAttachmentPath(
reply: FastifyReply,
record: AttachmentRecord,
sessionWorkingDir?: string
): Promise<string | null> {
let pathToCheck = record.filePath;
let resolved = false;
try {
pathToCheck = realpathSync(record.filePath);
resolved = true;
} catch {
// Fall back to the stored (already realpath-resolved at registration) path.
}
const guard = await loadAttachmentGuardConfig();
const blocked =
isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) ||
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
(guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck)));
if (blocked) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return null;
}
// Serve the freshly-resolved path, not the stored one: if a path component
// became a symlink after registration, the guard checked the resolved target
// but streaming record.filePath would follow the symlink to a swapped file.
return resolved ? pathToCheck : record.filePath;
}
/**
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
* stream it inline. PDF/PNG and text formats don't need conversion — callers
* redirect those to the raw route instead.
*/
async function serveConvertedPreview(
reply: FastifyReply,
resolvedPath: string,
fileName: string,
extension: string
): Promise<void> {
if (extension !== 'docx' && extension !== 'pptx') {
reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type'));
return;
}
try {
const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension);
if (!previewPath) {
reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed'));
return;
}
const content = await fs.readFile(previewPath);
reply.header('Content-Type', 'application/pdf');
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
reply.header('Cache-Control', 'no-cache');
reply.header('Content-Length', content.length);
reply.header('X-Content-Type-Options', 'nosniff');
reply.send(content);
} catch (err) {
reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`));
}
}
/** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */
async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extension: string): Promise<void> {
const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension);
if (!thumbnail) {
reply.code(204).send();
return;
}
reply.header('Content-Type', thumbnail.contentType);
reply.header('Cache-Control', 'no-cache');
reply.header('X-Content-Type-Options', 'nosniff');
reply.send(thumbnail.content);
}
/**
* Resolve a session's working dir from the live session, falling back to the
* persisted record so preview/thumbnail requests keep working for a session
* that has since detached. Sends a 404 and returns undefined when unknown.
*/
function getKnownSessionWorkingDir(
ctx: SessionPort & ConfigPort,
sessionId: string,
reply: FastifyReply
): string | undefined {
const liveSession = ctx.sessions.get(sessionId);
if (liveSession) return liveSession.workingDir;
const stored = ctx.store.getSession(sessionId);
if (stored) return stored.workingDir;
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
return undefined;
}
// Persisted sessions carry the private (externalPath-bearing) history under a
// `__attachmentHistory` key so the list route can re-register external files.
type StoredSessionWithPrivateAttachmentHistory = SessionState & {
__attachmentHistory?: SessionAttachmentHistoryItem[];
};
type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPath'> & {
missing: boolean;
rawUrl?: string;
url?: string;
previewUrl?: string;
thumbnailUrl?: string;
downloadUrl?: string;
attachmentId?: string;
};
function appendDownloadFlag(url: string): string {
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
}
function getSessionAttachmentHistory(
ctx: SessionPort & ConfigPort,
sessionId: string
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
const liveSession = ctx.sessions.get(sessionId);
if (liveSession) {
return {
workingDir: liveSession.workingDir,
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
};
}
const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined;
if (!stored) return undefined;
return {
workingDir: stored.workingDir,
history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [],
};
}
// History item for a file detected inside the workspace: re-stat for live
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
async function buildDetectedAttachmentRouteItem(
sessionId: string,
workingDir: string,
item: SessionAttachmentHistoryItem
): Promise<AttachmentHistoryRouteItem> {
const safe = sanitizeAttachmentHistoryItem(item);
if (!item.relativePath) {
return { ...safe, missing: true };
}
const validated = validateSessionFilePath(workingDir, item.relativePath);
if (!validated) {
return { ...safe, missing: true };
}
let size = item.size;
let mtimeMs = item.mtimeMs;
try {
const stat = await fs.stat(validated.resolvedPath);
size = stat.size;
mtimeMs = stat.mtimeMs ?? mtimeMs;
} catch {
return { ...safe, missing: true };
}
const encodedPath = encodeURIComponent(item.relativePath);
const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`;
const previewUrl =
item.extension === 'docx' || item.extension === 'pptx'
? `/api/sessions/${sessionId}/file-preview?path=${encodedPath}`
: rawUrl;
const thumbnailUrl = isSupportedAttachmentExtension(item.extension)
? buildFileThumbnailRoute(sessionId, item.relativePath)
: undefined;
return {
...safe,
size,
mtimeMs,
missing: false,
rawUrl,
url: rawUrl,
previewUrl,
thumbnailUrl,
downloadUrl: appendDownloadFlag(rawUrl),
};
}
// History item for an explicitly published external file: re-register it to mint
// a fresh id + by-id routes (the guard runs again), or mark it missing.
async function buildExternalAttachmentRouteItem(
sessionId: string,
item: SessionAttachmentHistoryItem,
sessionWorkingDir?: string
): Promise<AttachmentHistoryRouteItem> {
const safe = sanitizeAttachmentHistoryItem(item);
if (!item.externalPath) {
return { ...safe, missing: true };
}
try {
const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir });
return {
...safe,
fileName: event.fileName,
extension: event.extension,
attachmentType: event.attachmentType,
size: event.size,
missing: false,
attachmentId: event.attachmentId,
rawUrl: event.rawUrl,
url: event.rawUrl,
previewUrl: event.previewUrl,
thumbnailUrl: event.thumbnailUrl,
downloadUrl: appendDownloadFlag(event.rawUrl),
};
} catch (err) {
if (err instanceof AttachmentRegistrationError) {
return { ...safe, missing: true };
}
throw err;
}
}
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
// File tree listing
app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string };
@@ -315,6 +669,214 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
}
});
// ===== Live external attachments =====
// Register an explicit, live external file (absolute host path) as an
// attachment with a stable id so browser requests never carry arbitrary
// paths. Registration enforces the COD-53 attachment-guard policy. Serving is
// by id via the /raw route below; document previews/thumbnails and the
// attachment-history list are layered on separately.
app.post('/api/sessions/:id/attachments', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const body = (req.body || {}) as { path?: string };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
return;
}
try {
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
ctx.broadcast(SseEvent.AttachmentDetected, event);
return { success: true, data: event };
} catch (err) {
if (err instanceof AttachmentRegistrationError) {
reply.code(err.statusCode).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message));
return;
}
return reply
.code(500)
.send(
createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to register attachment: ${getErrorMessage(err)}`)
);
}
});
// List a session's attachment history (live session or persisted), resolving
// each entry to current metadata + routes. External entries are re-registered.
app.get('/api/sessions/:id/attachments', async (req, reply) => {
const { id } = req.params as { id: string };
const sessionHistory = getSessionAttachmentHistory(ctx, id);
if (!sessionHistory) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
return;
}
const items = await Promise.all(
sessionHistory.history.map((item) =>
(item.source === 'external'
? buildExternalAttachmentRouteItem(id, item, sessionHistory.workingDir)
: buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item)
).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))
)
);
return {
success: true,
data: {
items,
count: items.length,
},
};
});
// Metadata poll for a single registered attachment (re-stats for live
// size/mtime as the underlying file is rewritten).
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
if (!(await resolveServableAttachmentPath(reply, record, workingDir))) return;
const event = attachmentRecordToEvent(record);
let size = record.size;
let mtimeMs = record.mtimeMs;
try {
const stat = await fs.stat(record.filePath);
size = stat.size;
mtimeMs = stat.mtimeMs ?? mtimeMs;
} catch {
// File temporarily unavailable mid-write — keep cached values.
}
return {
success: true,
data: {
path: record.fileName,
size,
mtimeMs,
type: record.attachmentType,
extension: record.extension,
url: event.rawUrl,
previewUrl: event.previewUrl,
thumbnailUrl: event.thumbnailUrl,
attachmentId: record.attachmentId,
fileName: record.fileName,
},
};
});
// Serve the raw bytes of a registered attachment by id. Re-checks the
// attachment-guard policy on every request (defense-in-depth) before streaming.
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const { download } = req.query as { download?: string };
const session = findSessionOrFail(ctx, id);
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
if (!servePath) return;
try {
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
} catch (err) {
reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
});
// Serve a converted PDF preview of a registered attachment by id. Office docs
// convert server-side; PDF/PNG/text redirect to the raw route.
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
if (!servePath) return;
// Only Office formats need server-side conversion; PDF/PNG and text formats
// (md/txt) preview directly from their raw bytes.
if (record.extension !== 'docx' && record.extension !== 'pptx') {
reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`);
return;
}
await serveConvertedPreview(reply, servePath, record.fileName, record.extension);
});
// Serve a first-page thumbnail of a registered attachment by id.
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
if (!servePath) return;
await serveThumbnail(reply, servePath, record.extension);
});
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
if (!filePath) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
const validated = validateSessionFilePath(workingDir, filePath);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const { resolvedPath } = validated;
const ext = filePath.split('.').pop()?.toLowerCase() || '';
if (ext !== 'docx' && ext !== 'pptx') {
reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`);
return;
}
await serveConvertedPreview(reply, resolvedPath, filePath, ext);
});
// Serve a first-page thumbnail for a workspace-relative path.
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
if (!workingDir) return;
if (!filePath) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
const validated = validateSessionFilePath(workingDir, filePath);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
if (!isSupportedAttachmentExtension(ext)) {
reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type'));
return;
}
await serveThumbnail(reply, validated.resolvedPath, ext);
});
// Stream file content via tail -f (SSE endpoint)
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
const { id } = req.params as { id: string };
@@ -387,24 +949,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
});
// Session-scoped file download.
// Uses the same realpath-based workspace boundary as file preview/raw routes;
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary.
const SENSITIVE_PATTERNS: RegExp[] = [
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
/\/\.env$/,
/\/\.env\./,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
/\/\.gcloud\/credentials\.db$/,
/\/\.docker\/config\.json$/,
];
function isSensitivePath(absPath: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
}
// the shared sensitive-path blocklist (../sensitive-path.js, also used by the
// attachment guard) remains defense-in-depth, not the primary boundary.
app.get('/api/download', async (req, reply) => {
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
+1
View File
@@ -9,6 +9,7 @@ export { registerFileRoutes } from './file-routes.js';
export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerSystemRoutes } from './system-routes.js';
export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js';
+110 -3
View File
@@ -29,6 +29,7 @@ import {
ResizeSchema,
AutoClearSchema,
AutoCompactSchema,
AutoResumeSchema,
ImageWatcherSchema,
FlickerFilterSchema,
QuickRunSchema,
@@ -44,7 +45,13 @@ import {
validatePathWithinBase,
} from '../route-helpers.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys } from '../../hooks-config.js';
import {
writeHooksConfig,
updateCaseModel,
stripCaseEnvKeys,
applyStatusLineConfig,
refreshStaleHookSecret,
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
@@ -65,6 +72,31 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
const CTRL_L_PATTERN = /\x0c/g;
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
/**
* Match xterm alternate-screen mode toggles + the standalone scrollback-erase.
*
* - DECSET/DECRST 47, 1047, 1049 = enter/exit alternate screen buffer
* (1049 also saves cursor and clears the alt buffer).
* - CSI 3 J = erase saved lines (scrollback).
*
* Codex emits `\x1b[?1049h` and clear-scrollback sequences during startup and
* on repaint. xterm.js obeys them by switching to the alt buffer (no native
* scrollback) and wiping saved lines, so the user's conversation history
* disappears on every tab switch / pane refresh. Stripping these from the
* replayed byte stream keeps everything in the main buffer with scrollback
* intact. Mirrors the live-stream strip in Session._handleTerminalOutput.
*/
// eslint-disable-next-line no-control-regex
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
// eslint-disable-next-line no-control-regex
const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g;
// Mouse-tracking enables (X10/button/any-event/UTF-8/SGR/alt-scroll) — once on,
// xterm.js forwards wheel events to the app instead of scrolling the viewport.
// Live streams are stripped at the source, but buffers persisted BEFORE that
// strip existed can still carry them; strip on replay for parity.
// eslint-disable-next-line no-control-regex
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g;
/**
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
@@ -271,6 +303,28 @@ export function registerSessionRoutes(
await updateCaseModel(workingDir, body.modelOverride || null);
}
// Plan-usage statusLine exporter (App Settings → Display → "Plan Usage
// Limits"). Claude-only; runs for ANY working dir (linked cases / real repos,
// where most sessions live), mirroring updateCaseModel above.
//
// ADD-ONLY: we never remove on create. Sessions in a repo share one
// settings.local.json, so a single create-with-false (e.g. a client whose
// synced setting hadn't loaded yet) must NOT yank the statusLine out from
// under other live sessions in that repo — that breaks their footer + the
// chip's data feed for everyone. The exporter is benign when the chip is off
// (the footer just shows session status). isOurs-guarded so a user's own
// statusLine is never touched.
if ((body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
await applyStatusLineConfig(workingDir, true);
}
// COD-91 self-heal: refresh a pre-secret hooks block in an existing case so the now
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
if ((body.mode ?? 'claude') === 'claude') {
await refreshStaleHookSecret(workingDir).catch(() => {});
}
// Check OpenCode availability if requested
if (body.mode === 'opencode') {
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
@@ -906,7 +960,24 @@ export function registerSessionRoutes(
const query = req.query as { tail?: string };
const session = findSessionOrFail(ctx, id);
const rawBuffer = session.terminalBuffer;
// Prepend the live tmux pane buffer so tab-switch replay shows the current
// on-screen frame, not just the accumulated byte history. This matters for
// TUI modes (codex/opencode) that repaint only their latest frame: the
// accumulated buffer alone replays as the idle banner. We clear the viewport
// (`\x1b[H\x1b[2J`) between the history and the live pane so they don't
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
// returns null when unavailable, in which case we fall back to history.
const muxName = session.muxName;
const liveMuxBuffer =
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
? ctx.mux.captureActivePaneBuffer(muxName)
: null;
const rawBuffer =
liveMuxBuffer !== null && liveMuxBuffer.length > 0
? session.terminalBufferLength > 0
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
: liveMuxBuffer
: session.terminalBuffer;
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const fullSize = rawBuffer.length;
let truncated = false;
@@ -916,7 +987,17 @@ export function registerSessionRoutes(
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
const strippedBuffer = stripInkRedrawBloat(rawBuffer);
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from codex byte streams.
// xterm.js obeys them by switching to its scrollback-less alt buffer and
// wiping saved lines, so conversation history disappears on tab switch.
if (session.mode === 'codex') {
strippedBuffer = strippedBuffer
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
.replace(ERASE_SCROLLBACK_PATTERN, '')
.replace(MOUSE_TRACKING_PATTERN, '');
}
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
@@ -1003,6 +1084,27 @@ export function registerSessionRoutes(
};
});
// ========== Auto-Resume (usage-limit pause) ==========
app.post('/api/sessions/:id/auto-resume', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
session.setAutoResume(body.enabled);
persistAndBroadcastSession(ctx, session);
return {
success: true,
data: {
autoResume: {
enabled: session.autoResumeEnabled,
resumeAt: session.autoResumeAt ?? undefined,
},
},
};
});
// ========== Image Watcher ==========
app.post('/api/sessions/:id/image-watcher', async (req) => {
@@ -1190,6 +1292,11 @@ export function registerSessionRoutes(
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
} else if (mode !== 'opencode') {
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
// the hooks aren't ours or already carry the secret.
await refreshStaleHookSecret(casePath).catch(() => {});
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
+71
View File
@@ -0,0 +1,71 @@
/**
* @fileoverview Status-telemetry route.
*
* Receives Claude Code statusline payloads POSTed by the Codeman-managed
* statusLine exporter (see `hooks-config.generateStatusLineCommand`) and
* broadcasts the parsed plan-usage limits (5-hour + weekly) to SSE clients for
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
*
* Returns a compact plain-text status string for the exporter to print as the
* in-terminal footer (print-through), so injecting our statusLine doesn't leave
* the terminal footer blank.
*/
import { FastifyInstance } from 'fastify';
import { StatusTelemetrySchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import {
parseStatusTelemetry,
parseSessionStatus,
formatSessionStatusText,
telemetrySignature,
type RawStatuslinePayload,
} from '../../usage-telemetry.js';
import { SessionStatusTelemetry } from '../sse-events.js';
import { setLatestPlanUsage } from '../plan-usage-latest.js';
import type { SessionPort, EventPort } from '../ports/index.js';
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
// Last broadcast telemetry signature per session — the statusline fires on
// every assistant message, so we only rebroadcast when the value changes.
const lastSig = new Map<string, string>();
app.post('/api/status-telemetry', async (req, reply) => {
const { sessionId, data } = parseBody(StatusTelemetrySchema, req.body);
reply.type('text/plain; charset=utf-8');
// Unknown session — minimal footer, no broadcast.
if (!ctx.sessions.has(sessionId)) {
lastSig.delete(sessionId);
return 'codeman';
}
const payload = data as RawStatuslinePayload | undefined;
// Plan-usage limits (account-wide) → broadcast to the header chip, when
// present and changed (the statusline fires on every assistant message).
const telemetry = parseStatusTelemetry(payload);
if (telemetry) {
const sig = telemetrySignature(telemetry);
if (lastSig.get(sessionId) !== sig) {
lastSig.set(sessionId, sig);
// Bound the map across long multi-session runs: prune dead sessions.
if (lastSig.size > 256) {
for (const id of [...lastSig.keys()]) {
if (!ctx.sessions.has(id)) lastSig.delete(id);
}
}
const payload = { sessionId, ...telemetry };
setLatestPlanUsage(payload); // replayed in the SSE init snapshot for fresh loads
ctx.broadcast(SessionStatusTelemetry, payload);
}
}
// In-terminal statusline footer → CURRENT SESSION status (model / tokens /
// context %), NOT the plan limits. Available from the first render, even
// before rate_limits appears.
return formatSessionStatusText(parseSessionStatus(payload));
});
}
+42 -1
View File
@@ -14,6 +14,7 @@ import { execSync, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import {
ConfigUpdateSchema,
SettingsUpdateSchema,
@@ -25,6 +26,7 @@ import {
} from '../schemas.js';
import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js';
import { applyStatusLineConfig } from '../../hooks-config.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import {
findSessionOrFail,
@@ -498,6 +500,26 @@ export function registerSystemRoutes(
app.put('/api/settings', async (req) => {
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
// control = effectively RCE) to a public *.trycloudflare.com URL. Because the
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
// unauthenticated. Refuse to start a tunnel unless auth is configured OR the
// operator has acknowledged unauthenticated-network exposure. A public tunnel is
// higher-stakes than a LAN bind, so this is REFUSE (vs the bind guard's warn).
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning() && !isUnauthenticatedNetworkAcknowledged()) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, or set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge an ' +
'unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
try {
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
@@ -509,7 +531,10 @@ export function registerSystemRoutes(
} catch {
/* ignore */
}
const merged = { ...existing, ...settings };
// statusLineTelemetry is an ACTION field (reconcile the plan-usage exporter),
// not a stored setting — strip it before persisting so settings.json stays clean.
const { statusLineTelemetry, ...settingsToStore } = settings;
const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// Handle subagent tracking toggle dynamically
@@ -525,6 +550,22 @@ export function registerSystemRoutes(
}
});
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
// into every ACTIVE Claude session's working dir so the live % starts flowing
// immediately (no new session needed). We deliberately never auto-REMOVE here:
// the exporter is benign/print-through and a per-repo settings.local.json is
// shared by sibling sessions, so one device's "off" must not yank the exporter
// another device's chip depends on. Each dir handled once.
if (statusLineTelemetry === true) {
const dirs = new Set<string>();
for (const session of ctx.sessions.values()) {
if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir);
}
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
}
// Handle tunnel toggle dynamically
if ('tunnelEnabled' in settings) {
const tunnelEnabled = settings.tunnelEnabled as boolean;
+6
View File
@@ -114,6 +114,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
// can ignore small-viewport resizes only while a desktop is actually
// connected (see Session._desktopSizeClaims).
const sizingToken = Symbol('ws-desktop-sizing');
let holdsDesktopClaim = false;
// Attach message handler synchronously BEFORE any async work
// (@fastify/websocket requirement to avoid dropped messages).
@@ -122,6 +123,9 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return;
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
session.write(msg.d);
} else if (
msg.t === 'z' &&
@@ -135,10 +139,12 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
if (viewportType === 'desktop') {
session.claimDesktopSizing(sizingToken);
holdsDesktopClaim = true;
} else if (viewportType) {
// The connection's viewport can change (e.g. browser window
// narrowed past the tablet breakpoint) — drop a stale claim.
session.releaseDesktopSizing(sizingToken);
holdsDesktopClaim = false;
}
if (viewportType) {
session.resize(msg.c, msg.r, { viewportType });
+59
View File
@@ -158,6 +158,8 @@ export const CreateSessionSchema = z.object({
effort: effortLevelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
/** Inject the plan-usage statusLine exporter into the case (App Settings → Display → "Plan Usage Limits"). Claude-only. */
statusLineTelemetry: z.boolean().optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
@@ -186,6 +188,51 @@ export const ResizeSchema = z.object({
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
});
/**
* Schema for POST /api/status-telemetry
* Claude Code statusline payload forwarded by the Codeman-managed statusLine
* exporter (see hooks-config.generateStatusLineCommand). Validates only the
* subset Codeman displays; unknown keys (session_id, transcript_path, cwd, …)
* are stripped by z.object. Auth-exempt like /api/hook-event.
*/
// NOTE: every modeled field is `.nullish()` (not `.optional()`) on purpose.
// Claude's statusline blob is officially shipped but undocumented in exact
// shape, and `z.optional()` REJECTS an explicit `null` (accepts only
// `undefined`) — a single stray `null` (e.g. `cost:{total_cost_usd:null}`)
// would 400 the ENTIRE POST before the deliberately-tolerant parser
// (usage-telemetry.ts, which only acts on `typeof === 'number'/'string'`) ever
// runs, silently killing the chip's data feed. `.nullish()` keeps the schema
// gate as forgiving as the parser it guards.
const RateLimitWindowSchema = z
.object({
used_percentage: z.number().nullish(),
resets_at: z.number().nullish(),
})
.nullish();
export const StatusTelemetrySchema = z.object({
sessionId: z.string().min(1).max(100),
data: z
.object({
rate_limits: z
.object({
five_hour: RateLimitWindowSchema,
seven_day: RateLimitWindowSchema,
})
.nullish(),
context_window: z
.object({
used_percentage: z.number().nullish(),
total_input_tokens: z.number().nullish(),
total_output_tokens: z.number().nullish(),
})
.nullish(),
cost: z.object({ total_cost_usd: z.number().nullish() }).nullish(),
model: z.object({ display_name: z.string().max(100).nullish() }).nullish(),
})
.nullish(),
});
// ========== Case Routes ==========
/**
@@ -321,6 +368,13 @@ export const SettingsUpdateSchema = z
showFileBrowser: z.boolean().optional(),
showSubagents: z.boolean().optional(),
showMultiMonitorButton: z.boolean().optional(),
showPlanUsageLimits: z.boolean().optional(),
// Action field (NOT persisted as a setting): when true, (re)injects the
// plan-usage statusLine exporter into active Claude sessions so live usage %
// starts flowing. Sent on ENABLE only — the chip's DISPLAY is per-device
// (client-side), but telemetry COLLECTION is server-side, so the per-device
// toggle signals it out-of-band here rather than via showPlanUsageLimits.
statusLineTelemetry: z.boolean().optional(),
// Input
gestureControlEnabled: z.boolean().optional(),
// Claude CLI settings
@@ -450,6 +504,11 @@ export const AutoCompactSchema = z.object({
prompt: z.string().max(10000).optional(),
});
/** POST /api/sessions/:id/auto-resume */
export const AutoResumeSchema = z.object({
enabled: z.boolean(),
});
/** POST /api/sessions/:id/image-watcher */
export const ImageWatcherSchema = z.object({
enabled: z.boolean(),
+41
View File
@@ -0,0 +1,41 @@
/**
* @fileoverview Shared sensitive-path blocklist.
*
* A small defense-in-depth blocklist of absolute paths that must never be
* served to the browser regardless of how the path was obtained (workspace
* download, cross-workspace attachment registration, raw/preview serving).
*
* This is intentionally a BLOCKLIST, not a workspace-confinement check:
* cross-workspace attachment is a supported feature (codeman-publish skill +
* the automated review-card loop attaching files under ~/.codeman/), so a
* strict session-workspace boundary would break legitimate use. The blocklist
* rejects well-known secret locations (system password files, SSH keys, cloud
* credentials, dotenv files) while leaving ordinary cross-workspace files
* attachable.
*
* Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a
* symlink pointing at a sensitive target is also caught.
*/
import { homedir } from 'node:os';
const SENSITIVE_PATTERNS: RegExp[] = [
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
/\/\.env$/,
/\/\.env\./,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
/\/\.gcloud\/credentials\.db$/,
/\/\.docker\/config\.json$/,
];
/**
* Returns true if the given ABSOLUTE, symlink-resolved path matches the
* sensitive-file blocklist and must not be served to the browser.
*/
export function isSensitivePath(absPath: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
}
+104 -2
View File
@@ -41,9 +41,10 @@ import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os';
import { dataPath } from '../config/instance.js';
import { getHookSecret } from '../config/hook-secret.js';
import { EventEmitter } from 'node:events';
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
import type { ClaudeMode, SessionState } from '../types.js';
import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState } from '../types.js';
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import { createMultiplexer } from '../mux-factory.js';
@@ -59,6 +60,11 @@ import {
type SubagentToolResult,
} from '../subagent-watcher.js';
import { imageWatcher } from '../image-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import {
buildDetectedAttachmentHistoryItem,
buildExternalAttachmentHistoryItem,
} from '../session-attachment-history.js';
import { TranscriptWatcher } from '../transcript-watcher.js';
import { TeamWatcher } from '../team-watcher.js';
import { TunnelManager } from '../tunnel-manager.js';
@@ -109,6 +115,7 @@ import {
type PersistedRespawnConfig,
type NiceConfig,
type ImageDetectedEvent,
type AttachmentDetectedEvent,
DEFAULT_NICE_CONFIG,
} from '../types.js';
import {
@@ -121,6 +128,7 @@ import {
import type { EventLoopMonitorHandle } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
import { SseEvent } from './sse-events.js';
import { getLatestPlanUsage } from './plan-usage-latest.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
@@ -132,6 +140,7 @@ import {
registerFileRoutes,
registerScheduledRoutes,
registerHookEventRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
registerSessionRoutes,
@@ -247,12 +256,14 @@ export class WebServer extends EventEmitter {
} | null = null;
private imageWatcherHandlers: {
detected: (event: ImageDetectedEvent) => void;
attachmentDetected: (event: AttachmentDetectedEvent) => void;
error: (error: Error, sessionId?: string) => void;
} | null = null;
private tunnelManager: TunnelManager = new TunnelManager();
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
private authFailures: StaleExpirationMap<string, number> | null = null;
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
@@ -434,12 +445,27 @@ export class WebServer extends EventEmitter {
// Store handlers for cleanup on shutdown
this.imageWatcherHandlers = {
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
attachmentDetected: (event: AttachmentDetectedEvent) => {
const attachmentEvent = {
...event,
source: event.source || 'detected',
thumbnailUrl:
event.thumbnailUrl || buildFileThumbnailRoute(event.sessionId, event.relativePath || event.fileName),
};
const session = this.sessions.get(event.sessionId);
if (session) {
session.upsertAttachmentHistory(buildDetectedAttachmentHistoryItem(attachmentEvent));
this.persistSessionState(session);
}
this.broadcast(SseEvent.AttachmentDetected, attachmentEvent);
},
error: (error: Error, sessionId?: string) => {
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
},
};
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
imageWatcher.on('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
imageWatcher.on('image:error', this.imageWatcherHandlers.error);
}
@@ -449,6 +475,7 @@ export class WebServer extends EventEmitter {
private cleanupImageWatcherListeners(): void {
if (this.imageWatcherHandlers) {
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
imageWatcher.off('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
imageWatcher.off('image:error', this.imageWatcherHandlers.error);
this.imageWatcherHandlers = null;
}
@@ -608,6 +635,7 @@ export class WebServer extends EventEmitter {
this.authSessions = authState.authSessions;
this.authFailures = authState.authFailures;
this.qrAuthFailures = authState.qrAuthFailures;
this.hookSecretFailures = authState.hookSecretFailures;
}
// WebSocket support (terminal I/O — low-latency bidirectional channel)
@@ -801,6 +829,7 @@ export class WebServer extends EventEmitter {
registerFileRoutes(this.app, ctx);
registerScheduledRoutes(this.app, ctx);
registerHookEventRoutes(this.app, ctx);
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
registerSessionRoutes(this.app, ctx);
@@ -891,7 +920,14 @@ export class WebServer extends EventEmitter {
// field kept off SessionState to avoid leaking via API broadcasts.
const base = session.toState();
const envOverrides = session.getEnvOverridesForPersist();
const state = (envOverrides ? { ...base, __envOverrides: envOverrides } : base) as SessionState;
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
// separate from the sanitized public attachmentHistory in toState().
const attachmentHistory = session.getAttachmentHistoryForPersist();
const state = {
...base,
...(envOverrides ? { __envOverrides: envOverrides } : {}),
...(attachmentHistory ? { __attachmentHistory: attachmentHistory } : {}),
} as SessionState;
const controller = this.respawnControllers.get(session.id);
if (controller) {
const config = controller.getConfig();
@@ -1064,6 +1100,8 @@ export class WebServer extends EventEmitter {
session.removeAllListeners();
// Close any active file streams for this session
fileStreamManager.closeSessionStreams(sessionId);
// Drop live external attachment registrations for this session
attachmentRegistry.clearSession(sessionId);
// Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId);
// Clean up pasted images directory for this session
@@ -1109,6 +1147,11 @@ export class WebServer extends EventEmitter {
if (settings.showMultiMonitorButton === true) {
html = html.replace(' btn-multimonitor--hidden', '');
}
// Plan-usage chip: ships hidden (`header-plan-usage--hidden`) and is revealed
// PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It
// used to be server-revealed from a synced setting, but that leaked the desktop
// choice onto mobile — display is now per-device only (like the response viewer).
// Telemetry collection stays server-side via the statusLineTelemetry action.
// Detached single-session ("solo") window: inject the target session id so
// the client can enter solo mode even if a (network-first) service worker
// later serves a cached shell. The client primarily detects solo mode from
@@ -1249,9 +1292,47 @@ export class WebServer extends EventEmitter {
}
},
getStore: () => this.store,
registerAttachment: (id: string, filePath: string) => this.registerAttachment(id, filePath),
};
}
/**
* Register a terminal-requested external file as a live attachment and
* broadcast it. Triggered by the session's `attachmentRequested` event
* (codeman://attach magic links). Because terminal output is
* attacker-influenceable (a prompt-injected session can print an arbitrary
* `codeman://attach?path=` link), the scanned path is FORCE-confined to the
* session workspace — passive magic links can't expose arbitrary host files.
* Deliberate cross-workspace attachment goes through the explicit,
* Origin-guarded `POST /attachments` route (and `codeman attach`, which POSTs
* directly inside a managed session). Registration also enforces the COD-53
* blocklist as defense-in-depth.
*/
private async registerAttachment(sessionId: string, filePath: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
const event = await registerExternalAttachment(sessionId, filePath, {
sessionWorkingDir: session.workingDir,
forceWorkspaceConfinement: true,
});
const record = attachmentRegistry.get(sessionId, event.attachmentId);
if (record) {
session.upsertAttachmentHistory(
buildExternalAttachmentHistoryItem({
sessionId,
externalPath: record.filePath,
fileName: record.fileName,
extension: record.extension,
size: record.size,
mtimeMs: record.mtimeMs,
timestamp: event.timestamp,
})
);
this.persistSessionState(session);
}
this.broadcast(SseEvent.AttachmentDetected, event);
}
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps());
}
@@ -1590,6 +1671,7 @@ export class WebServer extends EventEmitter {
subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents
timestamp: now,
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
};
this.cachedLightState = { data: result, timestamp: now };
@@ -1816,6 +1898,10 @@ export class WebServer extends EventEmitter {
this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host;
process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`;
// Ensure the COD-54 hook secret exists on disk before any session exports
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
getHookSecret();
// Start scheduled runs cleanup timer
this.cleanup.setInterval(
() => {
@@ -1962,6 +2048,11 @@ export class WebServer extends EventEmitter {
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
// by the Session constructor (env var would hard-lock /effort switching).
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
// Prefer the private (externalPath-bearing) history; fall back to the
// sanitized public copy for sessions persisted before that split.
const savedAttachmentHistory =
(savedState as { __attachmentHistory?: SessionAttachmentHistoryItem[] })?.__attachmentHistory ??
savedState?.attachmentHistory;
const session = new Session({
id: muxSession.sessionId, // Preserve the original session ID
workingDir: muxSession.workingDir,
@@ -1974,6 +2065,7 @@ export class WebServer extends EventEmitter {
allowedTools: recoveryClaudeMode.allowedTools,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
});
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
@@ -1993,6 +2085,12 @@ export class WebServer extends EventEmitter {
if (savedState.autoClearEnabled !== undefined || savedState.autoClearThreshold !== undefined) {
session.setAutoClear(savedState.autoClearEnabled ?? false, savedState.autoClearThreshold);
}
// Auto-resume on usage limit (re-arms a pending schedule; an
// overdue one fires shortly after boot — the limit footer won't
// reprint on its own, so the pause would otherwise stall)
if (savedState.autoResumeEnabled) {
session.restoreAutoResume(true, savedState.autoResumeAt);
}
// Token tracking
if (
savedState.inputTokens !== undefined ||
@@ -2292,6 +2390,10 @@ export class WebServer extends EventEmitter {
this.qrAuthFailures.dispose();
this.qrAuthFailures = null;
}
if (this.hookSecretFailures) {
this.hookSecretFailures.dispose();
this.hookSecretFailures = null;
}
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();
+43 -1
View File
@@ -45,6 +45,9 @@ export interface SessionListenerRefs {
taskFailed: (task: BackgroundTask, error: string) => void;
autoClear: (data: { tokens: number; threshold: number }) => void;
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => void;
limitResume: (data: { attempt: number }) => void;
limitResumeCancelled: (data: { reason: string }) => void;
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
ralphLoopUpdate: (state: RalphTrackerState) => void;
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
@@ -55,6 +58,7 @@ export interface SessionListenerRefs {
bashToolStart: (tool: ActiveBashTool) => void;
bashToolEnd: (tool: ActiveBashTool) => void;
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
attachmentRequested: (event: { path: string }) => void;
}
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
@@ -74,10 +78,11 @@ interface SessionListenerDeps {
removeSessionListenerRefs(sessionId: string): void;
cleanupRespawnOnExit(sessionId: string): void;
getStore(): import('../state-store.js').StateStore;
registerAttachment(sessionId: string, filePath: string): Promise<void>;
}
/**
* Creates all 25 session listener handlers, capturing dependencies via closure.
* Creates all 26 session listener handlers, capturing dependencies via closure.
* Call `attachSessionListeners()` after to wire them to the session.
*/
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
@@ -243,6 +248,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
},
/** Broadcasts `session:limitPauseScheduled` — usage-limit pause detected, auto-resume armed.
* Persisted so a pending schedule survives a Codeman restart. */
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => {
deps.broadcast(SseEvent.SessionLimitPauseScheduled, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
deps.persistSessionState(session);
},
/** Broadcasts `session:limitResume` — auto-resume prompt sent after limit reset */
limitResume: (data: { attempt: number }) => {
deps.broadcast(SseEvent.SessionLimitResume, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
deps.persistSessionState(session);
},
/** Broadcasts `session:limitResumeCancelled` — pending auto-resume no longer needed */
limitResumeCancelled: (data: { reason: string }) => {
deps.broadcast(SseEvent.SessionLimitResumeCancelled, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
deps.persistSessionState(session);
},
// ─── CLI Info ────────────────────────────────────────────
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
@@ -330,6 +357,13 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
bashToolsUpdate: (tools: ActiveBashTool[]) => {
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
},
/** Registers an explicit attachment card requested by terminal magic text. */
attachmentRequested: (event: { path: string }) => {
deps.registerAttachment(session.id, event.path).catch((err) => {
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
});
},
};
}
@@ -350,6 +384,9 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('taskFailed', refs.taskFailed);
session.on('autoClear', refs.autoClear);
session.on('autoCompact', refs.autoCompact);
session.on('limitPauseScheduled', refs.limitPauseScheduled);
session.on('limitResume', refs.limitResume);
session.on('limitResumeCancelled', refs.limitResumeCancelled);
session.on('cliInfoUpdated', refs.cliInfoUpdated);
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
@@ -360,6 +397,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('bashToolStart', refs.bashToolStart);
session.on('bashToolEnd', refs.bashToolEnd);
session.on('bashToolsUpdate', refs.bashToolsUpdate);
session.on('attachmentRequested', refs.attachmentRequested);
}
/** Detach all listeners from a session (prevents memory leaks from closure references). */
@@ -379,6 +417,9 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('taskFailed', refs.taskFailed);
session.off('autoClear', refs.autoClear);
session.off('autoCompact', refs.autoCompact);
session.off('limitPauseScheduled', refs.limitPauseScheduled);
session.off('limitResume', refs.limitResume);
session.off('limitResumeCancelled', refs.limitResumeCancelled);
session.off('cliInfoUpdated', refs.cliInfoUpdated);
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
@@ -389,4 +430,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('bashToolStart', refs.bashToolStart);
session.off('bashToolEnd', refs.bashToolEnd);
session.off('bashToolsUpdate', refs.bashToolsUpdate);
session.off('attachmentRequested', refs.attachmentRequested);
}
+15
View File
@@ -73,6 +73,12 @@ export const SessionWorking = 'session:working' as const;
export const SessionAutoClear = 'session:autoClear' as const;
/** Auto-compact triggered for the session. */
export const SessionAutoCompact = 'session:autoCompact' as const;
/** Usage-limit pause detected; auto-resume scheduled. */
export const SessionLimitPauseScheduled = 'session:limitPauseScheduled' as const;
/** Auto-resume prompt sent after a usage-limit reset. */
export const SessionLimitResume = 'session:limitResume' as const;
/** Pending usage-limit auto-resume cancelled (session resumed or feature disabled). */
export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as const;
/** CLI version/model info detected from session output. */
export const SessionCliInfo = 'session:cliInfo' as const;
/** General session message (e.g. status text). */
@@ -81,6 +87,8 @@ export const SessionMessage = 'session:message' as const;
export const SessionInteractive = 'session:interactive' as const;
/** Prompt sent to session for execution. */
export const SessionRunning = 'session:running' as const;
/** Claude plan-usage telemetry (5-hour + weekly limits) parsed from the statusline. */
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
// ─── Session: Ralph ──────────────────────────────────────────────────────────
@@ -278,6 +286,8 @@ export const TunnelQrAuthUsed = 'tunnel:qrAuthUsed' as const;
/** New image file detected (e.g. screenshot upload). */
export const ImageDetected = 'image:detected' as const;
/** New document/image attachment detected in a session working directory. */
export const AttachmentDetected = 'attachment:detected' as const;
// ─── Hooks ───────────────────────────────────────────────────────────────────
@@ -361,10 +371,14 @@ export const SseEvent = {
SessionWorking,
SessionAutoClear,
SessionAutoCompact,
SessionLimitPauseScheduled,
SessionLimitResume,
SessionLimitResumeCancelled,
SessionCliInfo,
SessionMessage,
SessionInteractive,
SessionRunning,
SessionStatusTelemetry,
// Session: Ralph
SessionRalphLoopUpdate,
@@ -470,6 +484,7 @@ export const SseEvent = {
// Image
ImageDetected,
AttachmentDetected,
// Hooks
HookIdlePrompt,
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { parseAttachmentMagicLinks } from '../src/attachment-magic.js';
describe('attachment magic links', () => {
it('extracts absolute paths from codeman attach magic URLs', () => {
const links = parseAttachmentMagicLinks(
'Preview this: codeman://attach?path=%2Fmnt%2Fc%2FDecks%2FBoard%20Update.pptx'
);
expect(links).toEqual(['/mnt/c/Decks/Board Update.pptx']);
});
it('ignores duplicate links in one terminal chunk', () => {
const links = parseAttachmentMagicLinks(
[
'codeman://attach?path=/tmp/report.pdf',
'codeman://attach?path=/tmp/report.pdf',
'codeman://attach?path=/tmp/brief.docx',
].join('\n')
);
expect(links).toEqual(['/tmp/report.pdf', '/tmp/brief.docx']);
});
it('accepts markdown and plain-text magic paths', () => {
const links = parseAttachmentMagicLinks(
['codeman://attach?path=/tmp/notes.md', 'codeman://attach?path=/tmp/run.txt'].join('\n')
);
expect(links).toEqual(['/tmp/notes.md', '/tmp/run.txt']);
});
it('rejects relative or unsupported magic paths', () => {
const links = parseAttachmentMagicLinks(
[
'codeman://attach?path=relative.pdf',
'codeman://attach?path=/tmp/archive.zip',
'codeman://attach?path=/tmp/deck.pptx',
].join('\n')
);
expect(links).toEqual(['/tmp/deck.pptx']);
});
it('emits attachmentRequested from raw terminal output', () => {
const session = new Session({ id: 'session-attach-test', workingDir: '/tmp', mode: 'codex' });
const requested: string[] = [];
session.on('attachmentRequested', (event: { path: string }) => requested.push(event.path));
(session as unknown as { _handleTerminalOutput(data: string): void })._handleTerminalOutput(
'codeman://attach?path=%2Ftmp%2Fdeck.pptx'
);
expect(requested).toEqual(['/tmp/deck.pptx']);
});
});
+7 -6
View File
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi }
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
const AUTH_PORT = 3160;
const NOAUTH_PORT = 3161;
@@ -250,28 +251,28 @@ describe('Auth Security', () => {
});
describe('Hook Event Endpoint', () => {
it('should allow hook events from localhost without auth', async () => {
it('should allow hook events from localhost with the hook secret (no Basic auth)', async () => {
const res = await fetch(`${baseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
body: JSON.stringify({
event: 'stop',
sessionId: 'nonexistent-session',
data: {},
}),
});
// Should pass auth (localhost bypass) but may 404 on session — that's fine
// The key assertion is it does NOT return 401
// Should pass auth (localhost bypass + hook secret) but may 404 on session — that's fine.
// The key assertion is it does NOT return 401 (COD-91: secret required even with no tunnel).
expect(res.status).not.toBe(401);
});
it('should reject hook events with invalid schema', async () => {
const res = await fetch(`${baseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
body: JSON.stringify({ invalid: 'data' }),
});
// Schema validation should catch this
// Past the auth gate (valid secret) → schema validation should catch this (not a 401).
expect(res.status).not.toBe(401); // Not an auth error
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* @fileoverview COD-54 — hook-event auth bypass hardening.
*
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
* --url http://127.0.0.1:port) reach the loopback origin with req.ip ===
* 127.0.0.1 and drive respawn/Ralph signals unauthenticated. The fix gates
* the bypass behind a shared hook secret. COD-91 makes that requirement
* UNCONDITIONAL — the loopback bypass requires the secret whether or not a
* managed tunnel is running, because Codeman can't detect a user's own loopback
* reverse proxy (own cloudflared / `tailscale serve` / nginx → 127.0.0.1).
* Managed-session hooks always present the secret, so the legitimate channel
* keeps working.
*
* Tests:
* - tunnel running + no secret → 401 (closes the hole)
* - tunnel running + bad secret → 401
* - tunnel running + good secret → not 401 (allowed)
* - tunnel NOT running + no secret → 401 (COD-91: secret required unconditionally)
* - tunnel NOT running + good secret → not 401 (allowed)
* - rate limiting: rapid unauthorized hook POSTs eventually 429
*
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { TunnelManager } from '../src/tunnel-manager.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
const TUNNEL_UP_PORT = 3230;
const TUNNEL_DOWN_PORT = 3231;
const RATE_LIMIT_PORT = 3232;
const TEST_USER = 'admin';
const TEST_PASS = 'cod54-test-password';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
function hookBody(): string {
return JSON.stringify({ event: 'stop', sessionId: 'nonexistent-session', data: {} });
}
async function postHook(baseUrl: string, headers: Record<string, string> = {}): Promise<Response> {
return fetch(`${baseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: hookBody(),
});
}
describe('COD-54 hook-event auth — tunnel running requires secret', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Force the middleware's tunnel check to report "running".
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(TUNNEL_UP_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_UP_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('rejects a localhost hook POST WITHOUT the secret header (closes the tunnel hole)', async () => {
const res = await postHook(baseUrl);
expect(res.status).toBe(401);
});
it('rejects a localhost hook POST with a WRONG secret', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: 'wrong-secret-value' });
expect(res.status).toBe(401);
});
it('allows a localhost hook POST WITH the correct secret', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() });
// Passes auth (may 200 with success:false for unknown session) — key is NOT 401.
expect(res.status).not.toBe(401);
});
});
describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel NOT running — loopback-only normal prod case.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
server = new WebServer(TUNNEL_DOWN_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('rejects a localhost hook POST WITHOUT a secret even with no tunnel (COD-91)', async () => {
const res = await postHook(baseUrl);
expect(res.status).toBe(401);
});
it('allows a localhost hook POST WITH the correct secret when no tunnel is running', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() });
expect(res.status).not.toBe(401);
});
});
describe('COD-54 hook-event auth — rate limiting', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(RATE_LIMIT_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${RATE_LIMIT_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('eventually returns 429 for rapid unauthorized hook POSTs', async () => {
let saw429 = false;
// A few more than the failure max to cross the threshold.
for (let i = 0; i < AUTH_FAILURE_MAX + 3; i++) {
const res = await postHook(baseUrl);
if (res.status === 429) {
saw429 = true;
expect(res.headers.get('retry-after')).toMatch(/^\d+$/);
break;
}
expect(res.status).toBe(401);
}
expect(saw429).toBe(true);
});
it('hook-secret failures do NOT lock out the Basic-Auth login path (separate bucket)', async () => {
// The previous test exhausted the hook bucket for 127.0.0.1. Legacy (pre-secret)
// hooks fire constantly, so if they shared authFailures, every cookie-less
// request from loopback would now 429 — locking out login (and, via a tunnel,
// every client). Assert the login path is unaffected:
// 1. A credential-less request still gets a 401 challenge, NOT 429.
const unauthed = await fetch(`${baseUrl}/api/status`);
expect(unauthed.status).toBe(401);
// 2. Correct Basic credentials still authenticate.
const authed = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: 'Basic ' + Buffer.from(`${TEST_USER}:${TEST_PASS}`).toString('base64') },
});
expect(authed.status).toBe(200);
});
});
describe('COD-54 secret delivery — generated hooks + session env present the secret', () => {
it('generated hook curl commands send the secret header, read from the file at exec time', async () => {
const { generateHooksConfig } = await import('../src/hooks-config.js');
const config = generateHooksConfig();
const commands = JSON.stringify(config);
// Header present, value sourced from $CODEMAN_HOOK_SECRET_FILE (not embedded).
expect(commands).toContain(HOOK_SECRET_HEADER);
expect(commands).toContain('$CODEMAN_HOOK_SECRET_FILE');
expect(commands).not.toContain(getHookSecret());
});
it('session env builders export CODEMAN_HOOK_SECRET_FILE (path only, never the value)', async () => {
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
const claudeEnv = buildClaudeEnv('test-session');
const shellEnv = buildShellEnv('test-session');
expect(claudeEnv.CODEMAN_HOOK_SECRET_FILE).toMatch(/hook-secret$/);
expect(shellEnv.CODEMAN_HOOK_SECRET_FILE).toMatch(/hook-secret$/);
expect(JSON.stringify(claudeEnv)).not.toContain(getHookSecret());
});
});
+94
View File
@@ -0,0 +1,94 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
// Structural tests for the xterm snapshot/replay slice (COD-81). app.js has no
// bundler and is hard to drive through a real DOM, so — following the repo's
// existing pattern for app.js — these assert the source structure that makes
// the snapshot first-paint correct rather than executing it.
describe('xterm snapshot/replay (codex tab-switch)', () => {
const appSource = () => readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
it('rejects blank xterm snapshots before saving or restoring them', () => {
const source = appSource();
const helper = source.indexOf('_isUsableXtermSnapshot(snapshot)');
const save = source.indexOf('this._xtermSnapshots.set(this.activeSessionId, snapshot)');
const restore = source.indexOf('SNAPSHOT_RESTORE:', save);
const restoreBlock = source.slice(save, restore);
expect(helper).toBeGreaterThan(-1);
// The save is gated on a usability check immediately above it.
const usabilityGate = source.lastIndexOf('if (this._isUsableXtermSnapshot(snapshot))', save);
expect(usabilityGate).toBeGreaterThan(-1);
expect(usabilityGate).toBeLessThan(save);
// …and so is each restore path (in-memory + persisted).
expect(restoreBlock).toContain('if (snapshot && !this._isUsableXtermSnapshot(snapshot))');
expect(restoreBlock).toContain('persisted && this._isUsableXtermSnapshot(persisted)');
});
it('declares the snapshot-restore flag before selectSession uses it', () => {
const source = appSource();
const selectStart = source.indexOf('async selectSession(sessionId, options = {})');
const declaration = source.indexOf('let restoredSnapshot = false;', selectStart);
const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart);
const rewriteDecision = source.indexOf(
'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer',
selectStart
);
expect(selectStart).toBeGreaterThan(-1);
expect(declaration).toBeGreaterThan(selectStart);
expect(declaration).toBeLessThan(snapshotBranch);
expect(declaration).toBeLessThan(rewriteDecision);
});
it('uses xterm snapshots as first paint but still fetches the canonical terminal frame', () => {
const source = appSource();
const snapshotRestore = source.indexOf('SNAPSHOT_RESTORE:');
const cacheRestore = source.indexOf('Instant cache restore', snapshotRestore);
const fetchStart = source.indexOf("FETCH_START'", snapshotRestore);
const needsRewrite = source.indexOf('const needsRewrite', fetchStart);
const snapshotBlock = source.slice(snapshotRestore, cacheRestore);
const postSnapshotRestore = source.slice(snapshotRestore, needsRewrite + 160);
expect(snapshotRestore).toBeGreaterThan(-1);
expect(cacheRestore).toBeGreaterThan(snapshotRestore);
expect(fetchStart).toBeGreaterThan(cacheRestore);
expect(needsRewrite).toBeGreaterThan(fetchStart);
// Snapshot restore must NOT short-circuit the canonical fetch.
expect(snapshotBlock).not.toContain('this._finishBufferLoad();');
expect(postSnapshotRestore).toContain('restoredSnapshot');
expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
});
it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => {
const source = appSource();
const cacheRestore = source.indexOf('Instant cache restore');
const busyClear = source.indexOf('CACHE_SKIP_BUSY', cacheRestore);
const needsRewrite = source.indexOf('const needsRewrite', busyClear);
const replayBlock = source.slice(cacheRestore, needsRewrite + 160);
expect(cacheRestore).toBeGreaterThan(-1);
expect(busyClear).toBeGreaterThan(cacheRestore);
expect(needsRewrite).toBeGreaterThan(busyClear);
expect(replayBlock).toContain('clearedForBusy');
expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
});
it('loads the SerializeAddon and keeps a per-session snapshot map', () => {
const terminalSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
expect(terminalSource).toContain('this._xtermSnapshots = new Map()');
expect(terminalSource).toContain('new SerializeAddon.SerializeAddon()');
expect(terminalSource).toContain('this.terminal.loadAddon(this._serializeAddon)');
});
it('evicts the in-memory snapshot cache and persists with a bounded localStorage budget', () => {
const source = appSource();
// In-memory cache is LRU-bounded…
expect(source).toContain('if (this._xtermSnapshots.size > 20)');
// …per-snapshot localStorage writes are size-capped…
expect(source).toContain('snapshot.length < 256 * 1024');
// …and the persisted key set is pruned of dead sessions.
expect(source).toContain("k.startsWith('codeman-xs-')");
});
});
+278
View File
@@ -0,0 +1,278 @@
import { describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_ptyRows: number;
};
function handleOutput(session: Session, data: string): void {
(session as unknown as SessionInternals)._handleTerminalOutput(data);
}
describe('Codex terminal output filtering', () => {
it('keeps browser scrollback guards but skips Codeman row repair in hybrid render mode', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex', codexConfig: { renderMode: 'hybrid' } });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const hybridRedraw = '\x1b[?1049h\x1b[55;1H\x1b[2m• Working (21s)\x1b[3J\x1b[?1006h\x1b[?1049l';
handleOutput(session, hybridRedraw);
expect(emitted[0]).toBe('\x1b[55;1H\x1b[2m• Working (21s)');
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(session.terminalBuffer).toBe(emitted[0]);
});
it('preserves Codex erase-display redraws used by the TUI layout engine', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
handleOutput(session, '\x1b[H\x1b[Jidle redraw');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jidle redraw');
});
it('strips Codex scrollback erase without stripping visible-screen erase', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
});
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
// '\x1b[?1049h' split mid-sequence, then '\x1b[3J' split before its final byte.
handleOutput(session, 'before\x1b[?104');
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
handleOutput(session, 'Jtail');
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
});
it('emits nothing for a chunk that is only a partial CSI, and completes it next chunk', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
handleOutput(session, '6h\x1b[55;1H• Working'); // completes ?1006h (stripped); rest passes
expect(emitted).toEqual(['\x1b[55;1H• Working']);
expect(session.terminalBuffer).toBe('\x1b[55;1H• Working');
});
it('preserves Codex erase-display redraw when the user pressed Ctrl+L', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
session.write('\x0c');
handleOutput(session, '\x1b[H\x1b[Jredraw after clear');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jredraw after clear');
});
it('passes native Codex TUI prompt/status redraws through without row repair', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const bottomBandRedraw =
'\x1b[48;2;42;42;42m' +
'\x1b[60;2H\x1b[K' +
'\x1b[61;39H\x1b[K' +
'\x1b[62;2H\x1b[K' +
'\x1b[52;1H\x1b[49m\x1b[2m• \x1b[1mRunning node -e ...' +
'\x1b[60;1H\x1b[48;2;42;42;42m \r\n' +
'\x1b[1m›\x1b[0m\x1b[48;2;42;42;42m \x1b[2mUse /skills to list available skills\r\n' +
'\x1b[63;3H\x1b[49m\x1b[38;2;246;226;183mgpt-5.5 xhigh\x1b[39m' +
'\x1b[2m · \x1b[38;2;242;181;144mContext 42% left\x1b[39m' +
'\x1b[61;3H';
handleOutput(session, bottomBandRedraw);
expect(emitted[0]).not.toContain('\x1b[52;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[60;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[63;1H\x1b[2K');
expect(emitted[0]).toContain(bottomBandRedraw);
});
it('passes Codex advisory rows through without row repair', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const advisoryRedraw =
'\x1b[55;1H\x1b[2mMessages\x1b[Cto\x1b[Cbe submitted\x1b[Cafter\x1b[Cnext toolcall ' +
'(press esc to interrupt and send immediately)\x1b[56;1H';
handleOutput(session, advisoryRedraw);
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[0]).toContain(advisoryRedraw);
});
it('does not clear Codex resume-picker rows just because an option is selected', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const resumePickerRedraw =
'\x1b[1;2H\x1b[36m\x1b[1mResume a previous session' +
'\x1b[3;2H\x1b[2mType to search Filter: \x1b[35m[Cwd]\x1b[39m\x1b[2m All' +
'\x1b[5;3H\x1b[33m\x1b[48;2;42;42;42m\x1b[1m❯ \x1b[2m22h ago ll' +
'\x1b[6;3H\x1b[2m 1d ago $kb-health' +
'\x1b[60;1H\x1b[2m──── 2 / 2 · 100% ─' +
'\x1b[61;1H enter resume esc exit ↑/↓ browse';
handleOutput(session, resumePickerRedraw);
expect(emitted[0]).not.toContain('\x1b[4;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[5;1H\x1b[2K');
expect(emitted[0]).toContain(resumePickerRedraw);
});
it('does not full-clear sparse Codex resume-picker navigation redraws', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
let sparseResumePickerRedraw = '';
for (let row = 1; row <= 51; row++) {
const col = row % 2 === 0 ? 239 : 27;
sparseResumePickerRedraw += `\x1b[${row};${col}H\x1b[K`;
}
sparseResumePickerRedraw +=
'\x1b[21;3H \x1b[2m9d ago \x1b[mreview this webex room webexteams://im?space=672465b0-4fcb-11f1-9d54-51475df86e3a\x1b[K' +
'\x1b[22;3H\x1b[33m\x1b[1m❯ \x1b[m\x1b[33m\x1b[2m9d ago \x1b[m\x1b[33mcisco hybrid mesh firewall includes support for smart switch enforcement...\x1b[K' +
'\x1b[52;229H\x1b[39m\x1b[2m8\x1b[m';
handleOutput(session, sparseResumePickerRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
expect(emitted[0]).toContain(sparseResumePickerRedraw);
});
it('passes Codex UI rows through when the status band moves downward', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(
session,
'\x1b[55;1H\x1b[2m• Working (1s)\x1b[56;1H\x1b[1m›\x1b[0m ask\x1b[57;3Hgpt-5.5 · Context 80% left'
);
handleOutput(
session,
'\x1b[58;1H\x1b[2m• Working (2s)\x1b[59;1H\x1b[1m›\x1b[0m ask\x1b[60;3Hgpt-5.5 · Context 79% left'
);
expect(emitted[1]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[56;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[57;1H\x1b[2K');
expect(emitted[1]).toContain('\x1b[58;1H');
expect(emitted[1]).not.toContain('\x1b[54;1H\x1b[2K');
});
it('does not full-clear the viewport for stable Codex UI rows at the same position', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const stableRedraw =
'\x1b[58;1H\x1b[2m• Working (2s)\x1b[59;1H\x1b[1m›\x1b[0m ask\x1b[60;3Hgpt-5.5 · Context 79% left';
handleOutput(session, stableRedraw);
handleOutput(session, stableRedraw.replace('2s', '3s'));
expect(emitted[1]).not.toContain('\x1b[H\x1b[2J');
});
it('passes status-only Codex Working redraw rows through', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const workingRedraw = '\x1b[55;1H\x1b[2m• Working (21s)';
handleOutput(session, workingRedraw);
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[0]).toContain(workingRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
});
it('passes Codex spinner Working rows that omit elapsed time parentheses through', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 29;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const spinnerRedraw = '\x1b[24;1H\x1b[38;5;254m\x1b[1m•\x1b[CWorking\x1b[27;3H';
handleOutput(session, spinnerRedraw);
expect(emitted[0]).not.toContain('\x1b[24;1H\x1b[2K');
expect(emitted[0]).toContain(spinnerRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
});
it('does not treat ordinary gpt model mentions as Codex status rows', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const outputRow = '\x1b[20;1Hnormal output comparing gpt-5 and another model';
handleOutput(session, outputRow);
expect(emitted[0]).toContain(outputRow);
expect(emitted[0]).not.toContain('\x1b[19;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[20;1H\x1b[2K');
});
it('does not inject row erases during partial Working spinner ticks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(
session,
'\x1b[55;1H\x1b[2m• Working (1s)' +
'\x1b[56;1H\x1b[1m›\x1b[0m ask' +
'\x1b[57;3Hgpt-5.5 xhigh fast · codeman · Working · Context 79% left'
);
handleOutput(session, '\x1b[55;1H\x1b[2m• Working (2s)');
expect(emitted[1]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[56;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[57;1H\x1b[2K');
expect(emitted[1]).toContain('\x1b[55;1H\x1b[2m• Working (2s)');
});
});
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest';
import { DEPENDENCY_REGISTRY } from '../src/config/dependency-registry.js';
import {
detectEnvironment,
extractVersion,
compareVersions,
checkTool,
checkAll,
createRealHost,
} from '../src/utils/dependency-checker.js';
import type { ProbeHost } from '../src/utils/dependency-checker.js';
import type { ProbeEnvironment, ToolDependency } from '../src/config/dependency-registry.js';
describe('DEPENDENCY_REGISTRY', () => {
it('has unique ids', () => {
const ids = DEPENDENCY_REGISTRY.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('hard-requires only node and tmux; agent CLIs and office are optional', () => {
const required = DEPENDENCY_REGISTRY.filter((t) => t.required)
.map((t) => t.id)
.sort();
expect(required).toEqual(['node', 'tmux']);
// all agent CLIs are optional (Codeman runs any of them)
const agentClis = ['claude', 'opencode', 'codex'];
expect(DEPENDENCY_REGISTRY.filter((t) => agentClis.includes(t.id)).every((t) => t.required === false)).toBe(true);
const office = DEPENDENCY_REGISTRY.filter((t) => t.category === 'office');
expect(office.every((t) => t.required === false)).toBe(true);
});
it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => {
const ms = DEPENDENCY_REGISTRY.find((t) => t.id === 'msoffice');
expect(ms).toBeDefined();
const spec = ms!.resolvers.find((r) => r.resolver.kind === 'windows-side');
expect(spec).toBeDefined();
expect([...spec!.match].sort()).toEqual(['win32', 'wsl']);
expect(ms!.resolvers.some((r) => r.match.includes('linux'))).toBe(false);
});
});
describe('detectEnvironment', () => {
it('returns win32/darwin straight from platform', () => {
expect(detectEnvironment({ platform: 'win32', procVersion: '', hasWindowsInterop: false })).toBe('win32');
expect(detectEnvironment({ platform: 'darwin', procVersion: '', hasWindowsInterop: false })).toBe('darwin');
});
it('detects wsl from /proc/version + interop, else linux', () => {
const wsl = detectEnvironment({
platform: 'linux',
procVersion: 'Linux version 6.6 (Microsoft@WSL2)',
hasWindowsInterop: true,
});
expect(wsl).toBe('wsl');
expect(detectEnvironment({ platform: 'linux', procVersion: 'Microsoft', hasWindowsInterop: false })).toBe('linux');
expect(detectEnvironment({ platform: 'linux', procVersion: 'generic', hasWindowsInterop: true })).toBe('linux');
});
});
describe('extractVersion', () => {
it('pulls a dotted version from typical --version output', () => {
expect(extractVersion('v22.22.1')).toBe('22.22.1');
expect(extractVersion('tmux 3.4')).toBe('3.4');
expect(extractVersion('no digits here')).toBeUndefined();
});
it('honors a custom regex', () => {
expect(extractVersion('ProductVersion 16.0.19929.20172', /(\d+\.\d+\.\d+)/)).toBe('16.0.19929');
});
});
describe('compareVersions', () => {
it('orders by numeric components', () => {
expect(compareVersions('18.0.0', '18.0.0')).toBe(0);
expect(compareVersions('16.5.0', '18.0.0')).toBe(-1);
expect(compareVersions('22.22.1', '18.0.0')).toBe(1);
expect(compareVersions('3.4', '3.4.0')).toBe(0);
});
});
function fakeHost(env: ProbeEnvironment, over: Partial<ProbeHost> = {}): ProbeHost {
return {
environment: env,
which: () => null,
fileExists: () => false,
runVersion: () => null,
windowsProgramRoots: () => [],
windowsFileVersion: () => null,
...over,
};
}
const tmuxTool: ToolDependency = {
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
resolvers: [{ match: ['linux', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
};
const nodeTool: ToolDependency = {
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
minVersion: '18.0.0',
resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['node'] } }],
};
const msTool: ToolDependency = {
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
resolvers: [
{
match: ['wsl', 'win32'],
resolver: { kind: 'windows-side', appDirs: ['Microsoft Office/root/Office16'], exes: ['WINWORD.EXE'] },
},
],
};
describe('checkTool', () => {
it('reports ok with path + version when found on PATH', () => {
const host = fakeHost('linux', {
which: (b) => (b === 'tmux' ? '/usr/bin/tmux' : null),
runVersion: () => 'tmux 3.4',
});
expect(checkTool(tmuxTool, host)).toMatchObject({
id: 'tmux',
status: 'ok',
version: '3.4',
path: '/usr/bin/tmux',
});
});
it('reports missing when no bin resolves', () => {
expect(checkTool(tmuxTool, fakeHost('linux'))).toMatchObject({ id: 'tmux', status: 'missing' });
});
it('reports outdated when below minVersion', () => {
const host = fakeHost('linux', { which: () => '/n', runVersion: () => 'v16.5.0' });
expect(checkTool(nodeTool, host)).toMatchObject({ id: 'node', status: 'outdated', version: '16.5.0' });
});
it('reports error when minVersion set but version unparseable', () => {
const host = fakeHost('linux', { which: () => '/n', runVersion: () => 'unknown' });
expect(checkTool(nodeTool, host)).toMatchObject({ id: 'node', status: 'error' });
});
it('reports skipped when no resolver matches the environment', () => {
expect(checkTool(msTool, fakeHost('linux'))).toMatchObject({ id: 'msoffice', status: 'skipped' });
});
it('finds windows-side apps under WSL', () => {
const host = fakeHost('wsl', {
windowsProgramRoots: () => ['/mnt/c/Program Files'],
fileExists: (p) => p === '/mnt/c/Program Files/Microsoft Office/root/Office16/WINWORD.EXE',
windowsFileVersion: () => '16.0.19929.20172',
});
expect(checkTool(msTool, host)).toMatchObject({
id: 'msoffice',
status: 'ok',
version: '16.0.19929',
path: '/mnt/c/Program Files/Microsoft Office/root/Office16/WINWORD.EXE',
});
});
});
describe('checkAll', () => {
it('maps every tool to a result', () => {
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
expect(results.map((r) => r.id)).toEqual(['tmux', 'msoffice']);
});
});
describe('createRealHost', () => {
it('returns a host with a valid detected environment and callable methods', () => {
const host = createRealHost();
expect(['linux', 'darwin', 'win32', 'wsl']).toContain(host.environment);
expect(typeof host.which).toBe('function');
expect(Array.isArray(host.windowsProgramRoots())).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { renderTable, renderJson, computeExitCode } from '../src/utils/dependency-report.js';
import type { ToolResult } from '../src/utils/dependency-checker.js';
const results: ToolResult[] = [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
usedBy: [],
status: 'ok',
version: '22.22.1',
path: '/n',
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
usedBy: [],
status: 'missing',
installHint: 'sudo apt install tmux',
},
{
id: 'libreoffice',
label: 'LibreOffice',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
status: 'missing',
},
{
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
status: 'skipped',
reason: 'not applicable on linux',
},
];
describe('computeExitCode', () => {
it('non-zero when a required tool is missing/outdated/error', () => {
expect(computeExitCode(results)).toBe(1);
});
it('zero when only optional tools are missing', () => {
const ok = results.filter((r) => r.id !== 'tmux');
expect(computeExitCode(ok)).toBe(0);
});
});
describe('renderTable', () => {
it('groups by category and shows status, version, and install hints', () => {
const out = renderTable(results, 'linux');
expect(out).toContain('CORE');
expect(out).toContain('Node.js');
expect(out).toContain('22.22.1');
expect(out).toContain('OFFICE');
expect(out).toContain('document preview');
expect(out).toContain('sudo apt install tmux');
});
});
describe('renderJson', () => {
it('includes environment, summary, and per-tool data', () => {
const json = renderJson(results, 'linux');
expect(json.platform.environment).toBe('linux');
expect(json.summary.exitCode).toBe(1);
expect(json.summary.ok).toBe(1);
expect(json.tools).toHaveLength(4);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { runWithConversionLimit, getActiveConversionCount } from '../src/document-conversion-limiter.js';
describe('document-conversion-limiter', () => {
it('never runs more converters than the cap (default 3) concurrently', async () => {
let running = 0;
let maxObserved = 0;
const task = () => async () => {
running++;
maxObserved = Math.max(maxObserved, running);
// The module's own accounting must also stay within the cap.
expect(getActiveConversionCount()).toBeLessThanOrEqual(3);
await new Promise((resolve) => setTimeout(resolve, 5));
running--;
};
await Promise.all(Array.from({ length: 12 }, () => runWithConversionLimit(task())));
expect(maxObserved).toBeLessThanOrEqual(3);
expect(maxObserved).toBeGreaterThan(1); // proves it genuinely parallelizes, not serializes
expect(getActiveConversionCount()).toBe(0); // every slot released
});
it('processes every queued task even when far more are submitted than the cap', async () => {
let completed = 0;
await Promise.all(
Array.from({ length: 25 }, () =>
runWithConversionLimit(async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
completed++;
})
)
);
expect(completed).toBe(25);
expect(getActiveConversionCount()).toBe(0);
});
it('releases the slot when a task throws', async () => {
await expect(
runWithConversionLimit(async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(getActiveConversionCount()).toBe(0);
});
});
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import fs from 'node:fs/promises';
import { pruneDocumentPreviewCache } from '../src/document-preview-cache.js';
vi.mock('node:fs/promises', () => ({
default: {
readdir: vi.fn(),
stat: vi.fn(),
rm: vi.fn(async () => undefined),
},
}));
const mockedReaddir = vi.mocked(fs.readdir);
const mockedStat = vi.mocked(fs.stat);
const mockedRm = vi.mocked(fs.rm);
describe('pruneDocumentPreviewCache', () => {
beforeEach(() => {
vi.clearAllMocks();
// mtime encoded in the filename (f0 oldest ... f101 newest)
mockedStat.mockImplementation(async (p) => {
const m = /f(\d+)\.pdf$/.exec(String(p));
return { mtimeMs: m ? Number(m[1]) : 0, isFile: () => true } as never;
});
});
it('evicts the oldest *.pdf files once the cache exceeds the cap (default 100)', async () => {
const names = Array.from({ length: 102 }, (_, i) => `f${i}.pdf`);
mockedReaddir.mockResolvedValue(names as never);
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
// 102 - 100 = 2 oldest removed
expect(mockedRm).toHaveBeenCalledTimes(2);
const removed = mockedRm.mock.calls.map((c) => String(c[0]));
expect(removed.some((p) => p.endsWith('f0.pdf'))).toBe(true);
expect(removed.some((p) => p.endsWith('f1.pdf'))).toBe(true);
expect(removed.some((p) => p.endsWith('f101.pdf'))).toBe(false); // newest kept
});
it('ignores non-pdf entries (e.g. transient work-* dirs) when counting', async () => {
const names = [...Array.from({ length: 50 }, (_, i) => `f${i}.pdf`), 'work-abc', 'work-def'];
mockedReaddir.mockResolvedValue(names as never);
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
expect(mockedRm).not.toHaveBeenCalled(); // 50 pdfs <= cap
});
it('never throws when the cache dir cannot be read', async () => {
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
await expect(pruneDocumentPreviewCache('/tmp/missing')).resolves.toBeUndefined();
expect(mockedRm).not.toHaveBeenCalled();
});
});
+109
View File
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { clearDocumentPreviewCache, getOfficePreviewPdfPath } from '../src/document-preview-cache.js';
vi.mock('node:child_process', () => ({
execFile: vi.fn((_cmd, _args, _options, callback) => {
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
return {};
}),
}));
vi.mock('node:fs/promises', () => ({
default: {
stat: vi.fn(),
mkdir: vi.fn(async () => undefined),
copyFile: vi.fn(async () => undefined),
mkdtemp: vi.fn(async () => '/tmp/codeman-document-preview-cache/work-test'),
readdir: vi.fn(async () => ['deck.pdf']),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
const mockedExecFile = vi.mocked(execFile);
const mockedStat = vi.mocked(fs.stat);
const mockedRename = vi.mocked(fs.rename);
describe('document-preview-cache', () => {
beforeEach(() => {
vi.clearAllMocks();
clearDocumentPreviewCache();
let cachedPdfExists = false;
mockedStat.mockImplementation(async (path) => {
const pathText = String(path);
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
if (!cachedPdfExists) throw new Error('ENOENT');
return { size: 4096, isFile: () => true } as never;
}
return { size: 1845494, mtimeMs: 12345, isFile: () => true } as never;
});
mockedRename.mockImplementation(async () => {
cachedPdfExists = true;
});
mockedExecFile.mockImplementation((cmd, _args, _options, callback) => {
if (cmd === 'powershell.exe') {
cachedPdfExists = true;
}
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
return {} as never;
});
});
it('deduplicates concurrent Office preview conversions and reuses the cached PDF', async () => {
const [firstPath, secondPath] = await Promise.all([
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
]);
const thirdPath = await getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx');
expect(firstPath).toBeTruthy();
expect(secondPath).toBe(firstPath);
expect(thirdPath).toBe(firstPath);
expect(mockedExecFile).toHaveBeenCalledTimes(1);
expect(mockedExecFile).toHaveBeenCalledWith(
'soffice',
expect.arrayContaining([
'--headless',
'--convert-to',
'pdf',
expect.stringMatching(/^-env:UserInstallation=file:/),
]),
expect.any(Object),
expect.any(Function)
);
});
it('prefers Microsoft Word for DOCX files on Windows-mounted paths', async () => {
const result = await getOfficePreviewPdfPath(
'/mnt/c/Users/aakhter/Documents/codeman-inline-viewer-test.docx',
'docx'
);
expect(result).toContain('/mnt/c/Users/aakhter/AppData/Local/Temp/codeman-document-preview-cache/');
expect(mockedExecFile).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-NonInteractive', '-EncodedCommand']),
expect.any(Object),
expect.any(Function)
);
const powerShellCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'powershell.exe');
const encodedCommand = powerShellCall?.[1]?.at(-1);
const decodedCommand = Buffer.from(String(encodedCommand), 'base64').toString('utf16le');
expect(decodedCommand).toContain('$source = ');
expect(decodedCommand).toContain('C:\\Users\\aakhter\\AppData\\Local\\Temp\\codeman-document-preview-cache\\');
expect(decodedCommand).toContain('.docx');
expect(decodedCommand).toContain('$output = ');
expect(decodedCommand).toContain('.pdf');
expect(mockedExecFile).not.toHaveBeenCalledWith(
'soffice',
expect.any(Array),
expect.any(Object),
expect.any(Function)
);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { generateFirstPageThumbnail } from '../src/document-thumbnailer.js';
import { clearDocumentPreviewCache } from '../src/document-preview-cache.js';
vi.mock('node:child_process', () => ({
execFile: vi.fn((_cmd, _args, _options, callback) => {
callback(null, { stdout: '', stderr: '' });
return {};
}),
}));
vi.mock('node:fs/promises', () => ({
default: {
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
readFile: vi.fn(async () => Buffer.from('png')),
readdir: vi.fn(async () => ['converted.pdf']),
mkdir: vi.fn(async () => undefined),
mkdtemp: vi.fn(async () => '/tmp/codeman-thumb-test'),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
const mockedExecFile = vi.mocked(execFile);
const mockedStat = vi.mocked(fs.stat);
const mockedReadFile = vi.mocked(fs.readFile);
const mockedReaddir = vi.mocked(fs.readdir);
const mockedMkdir = vi.mocked(fs.mkdir);
const mockedMkdtemp = vi.mocked(fs.mkdtemp);
const mockedRename = vi.mocked(fs.rename);
const mockedRm = vi.mocked(fs.rm);
describe('document-thumbnailer', () => {
beforeEach(() => {
vi.clearAllMocks();
clearDocumentPreviewCache();
mockedStat.mockImplementation(async (path) => {
const pathText = String(path);
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
throw new Error('ENOENT');
}
return { size: 500 * 1024 * 1024, mtimeMs: 12345, isFile: () => true } as never;
});
mockedReadFile.mockResolvedValue(Buffer.from('large thumbnail') as never);
mockedReaddir.mockResolvedValue(['converted.pdf'] as never);
mockedMkdir.mockResolvedValue(undefined as never);
mockedMkdtemp.mockResolvedValue('/tmp/codeman-thumb-test' as never);
mockedRename.mockResolvedValue(undefined as never);
mockedRm.mockResolvedValue(undefined as never);
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
callback(null, { stdout: '', stderr: '' });
return {} as never;
});
});
it('renders first-page thumbnails for large documents without an app-level size cap', async () => {
const result = await generateFirstPageThumbnail('/tmp/large-deck.pdf', 'pdf');
expect(result).toEqual({
content: Buffer.from('large thumbnail'),
contentType: 'image/png',
});
expect(mockedExecFile).toHaveBeenCalledWith(
'pdftoppm',
expect.arrayContaining(['-png', '-singlefile', '-f', '1', '-l', '1']),
expect.any(Object),
expect.any(Function)
);
});
it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => {
mockedMkdtemp.mockImplementation(async (prefix) =>
String(prefix).includes('codeman-document-preview-cache')
? '/tmp/codeman-document-preview-cache/work-test'
: '/tmp/codeman-thumb-pdf-test'
);
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
callback(null, { stdout: '', stderr: '' });
return {} as never;
});
const result = await generateFirstPageThumbnail('/tmp/deck.pptx', 'pptx');
expect(result).toEqual({
content: Buffer.from('large thumbnail'),
contentType: 'image/png',
});
const pdftoppmCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'pdftoppm');
expect(pdftoppmCall?.[1]).toEqual(
expect.arrayContaining([expect.stringContaining('codeman-document-preview-cache')])
);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* COD-91 — `refreshStaleHookSecret` self-heal.
*
* Making the hook-event secret unconditionally required (PR #127) would silently 401 the
* hook curls baked into cases created before the secret header existed (COD-54). Those
* curls live in `.claude/settings.local.json` and `writeHooksConfig` only runs at case
* CREATION, so existing cases never refresh. `refreshStaleHookSecret` regenerates the
* hooks block on session spawn — but ONLY when the case already holds Codeman's own
* pre-secret hook curls, never clobbering a user's customizations.
*
* Pure filesystem logic against a temp dir — no port / server / tmux.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { refreshStaleHookSecret } from '../src/hooks-config.js';
const SECRET_HEADER = 'X-Codeman-Hook-Secret';
// A faithful pre-secret Codeman hook curl (what cases created before COD-54 contain):
// targets /api/hook-event, but with NO X-Codeman-Hook-Secret header.
function staleCodemanHooks() {
return {
Stop: [
{
matcher: '',
hooks: [
{
type: 'command',
command:
"HOOK_DATA=$(cat 2>/dev/null || echo '{}'); " +
'printf \'{"event":"stop","sessionId":"%s","data":%s}\' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ' +
'curl -s -X POST "$CODEMAN_API_URL/api/hook-event" -H \'Content-Type: application/json\' --data @- 2>/dev/null || true',
timeout: 5,
},
],
},
],
};
}
describe('refreshStaleHookSecret', () => {
let dir: string;
let settingsPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-selfheal-'));
mkdirSync(join(dir, '.claude'), { recursive: true });
settingsPath = join(dir, '.claude', 'settings.local.json');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('adds the secret header to a stale Codeman hooks block and preserves other keys', async () => {
writeFileSync(
settingsPath,
JSON.stringify({ env: { CLAUDE_CODE_FOO: '1' }, model: 'opus', hooks: staleCodemanHooks() }, null, 2)
);
await refreshStaleHookSecret(dir);
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_HOOK_SECRET_FILE');
// sibling keys untouched
expect(after.env).toEqual({ CLAUDE_CODE_FOO: '1' });
expect(after.model).toBe('opus');
});
it('leaves a hooks block that already carries the secret unchanged', async () => {
// Seed with a current block by healing a stale one first, then re-heal: second pass must no-op.
writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
await refreshStaleHookSecret(dir);
const healed = readFileSync(settingsPath, 'utf-8');
expect(healed).toContain(SECRET_HEADER);
await refreshStaleHookSecret(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
});
it('does not touch hooks that are not Codeman’s (no /api/hook-event)', async () => {
const foreign = JSON.stringify(
{ hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo hi', timeout: 5 }] }] } },
null,
2
);
writeFileSync(settingsPath, foreign);
await refreshStaleHookSecret(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(foreign);
});
it('is a no-op when settings.local.json is absent (does not create one)', async () => {
await refreshStaleHookSecret(dir);
expect(existsSync(settingsPath)).toBe(false);
});
it('leaves a malformed settings file untouched', async () => {
const garbage = '{ not valid json';
writeFileSync(settingsPath, garbage);
await refreshStaleHookSecret(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage);
});
});
+45 -12
View File
@@ -145,9 +145,9 @@ describe('ImageWatcher', () => {
// ========== Image Detection ==========
describe('image detection', () => {
it('should emit image:detected for .png files', () => {
it('should emit attachment:detected for .png files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.on('attachment:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
@@ -161,9 +161,42 @@ describe('ImageWatcher', () => {
expect(event.fileName).toBe('screenshot.png');
expect(event.filePath).toBe('/home/user/project/screenshot.png');
expect(event.relativePath).toBe('screenshot.png');
expect(event.extension).toBe('png');
expect(event.attachmentType).toBe('image');
expect(event.size).toBe(2048);
});
it('should not emit legacy image:detected for .png attachment cards', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
vi.advanceTimersByTime(300);
expect(handler).not.toHaveBeenCalled();
});
it.each([
['report.pdf', 'pdf'],
['brief.docx', 'document'],
['deck.pptx', 'presentation'],
])('should emit attachment:detected for %s files', (fileName, attachmentType) => {
const handler = vi.fn();
watcher.on('attachment:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', `/home/user/project/${fileName}`);
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0]).toMatchObject({
sessionId: 'session-1',
fileName,
attachmentType,
});
});
it('should emit for .jpg files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
@@ -250,10 +283,10 @@ describe('ImageWatcher', () => {
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.png');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.jpg');
vi.advanceTimersByTime(300);
expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.png');
expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.jpg');
});
});
@@ -268,11 +301,11 @@ describe('ImageWatcher', () => {
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
// Rapid adds of the same file
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
vi.advanceTimersByTime(100); // not yet past debounce
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
vi.advanceTimersByTime(100);
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
vi.advanceTimersByTime(300); // now past debounce from last emit
// Should only emit once (the last debounced one)
@@ -286,8 +319,8 @@ describe('ImageWatcher', () => {
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
chokidarWatcher.emit('add', '/home/user/project/a.png');
chokidarWatcher.emit('add', '/home/user/project/b.png');
chokidarWatcher.emit('add', '/home/user/project/a.jpg');
chokidarWatcher.emit('add', '/home/user/project/b.jpg');
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(2);
@@ -306,7 +339,7 @@ describe('ImageWatcher', () => {
// Emit 25 unique images in quick succession
for (let i = 0; i < 25; i++) {
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`);
vi.advanceTimersByTime(250); // past debounce, within burst window
}
@@ -323,7 +356,7 @@ describe('ImageWatcher', () => {
// Fill up burst limit
for (let i = 0; i < 20; i++) {
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`);
vi.advanceTimersByTime(250);
}
expect(handler).toHaveBeenCalledTimes(20);
@@ -332,7 +365,7 @@ describe('ImageWatcher', () => {
vi.advanceTimersByTime(11_000);
// Should accept new images
chokidarWatcher.emit('add', '/home/user/project/new.png');
chokidarWatcher.emit('add', '/home/user/project/new.jpg');
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(21);
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const appSource = readFileSync('src/web/public/app.js', 'utf8');
const terminalUiSource = readFileSync('src/web/public/terminal-ui.js', 'utf8');
const helpHtml = readFileSync('src/web/public/index.html', 'utf8');
const readme = readFileSync('README.md', 'utf8');
describe('keyboard shortcuts', () => {
it('uses physical Option+number keys so macOS special characters do not break tab switching', () => {
expect(appSource).toContain('e.code ||');
expect(appSource).toContain('Digit([1-9])');
expect(appSource).toContain('parseInt(digitMatch[1], 10) - 1');
});
it('provides Option+bracket shortcuts for previous and next session', () => {
expect(appSource).toContain("e.code === 'BracketLeft'");
expect(appSource).toContain("e.code === 'BracketRight'");
expect(appSource).toContain('this.prevSession()');
expect(appSource).toContain('this.nextSession()');
});
it('suppresses xterm PTY injection for the same physical Alt nav codes (no ESC leak)', () => {
// terminal-ui.js must gate its xterm pass-through on the SAME physical e.code set the
// app.js handler consumes; otherwise Alt+[ / Alt+] (and Option+digit on remapped macOS
// layouts) switch tabs AND inject ESC<char> into the focused terminal. Keep in sync.
expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code');
});
it('documents the Alt/Option shortcuts in help and README', () => {
expect(helpHtml).toContain('<kbd>Alt/Option</kbd>+<kbd>[</kbd>');
expect(helpHtml).toContain('<kbd>Alt/Option</kbd>+<kbd>]</kbd>');
expect(helpHtml).toContain('<kbd>Alt/Option</kbd>+<kbd>1-9</kbd>');
expect(readme).toContain('`Alt/Option+[` / `Alt/Option+]`');
expect(readme).toContain('`Alt/Option+1`-`Alt/Option+9`');
});
});
+226
View File
@@ -0,0 +1,226 @@
/**
* COD-56 — markdown HTML sanitizer (mXSS hardening).
*
* The response viewer / attachment preview render agent- and transcript-derived markdown to
* HTML via `marked` (raw-HTML passthrough) and assign the result with innerHTML. The HTML must
* be sanitized first. The original sanitizer (`_sanitizeHtml` in app.js) was a hand-rolled
* DENYLIST and is mXSS-prone — it never stripped `svg`/`math`/`style`, so foreign-namespace and
* CSS vectors survived.
*
* This suite drives the EXACT shipping artifacts:
* - src/web/public/vendor/dompurify.min.js (the vendored sanitizer)
* - src/web/public/sanitize-html.js (our allowlist config wired to DOMPurify)
*
* It runs in the DEFAULT node environment (it deliberately does NOT declare a per-file jsdom
* environment) and constructs a jsdom window here, then binds the vendored DOMPurify to it. A
* per-file jsdom environment externalizes node:fs/node:path under vite, which made this suite fail
* to load when
* run in isolation (it only survived the full CI run because an earlier node-env test happened to
* pre-cache node:fs). Building the window in-test keeps fs/path native and the suite order-robust.
*
* It feeds a corpus of mXSS payloads (svg/math/style/namespace-confusion/event-handler) and
* asserts the output carries NO script-executing constructs, that the curated allowlist is
* actually enforced (non-markdown tags dropped), and that legitimate markdown-rendered HTML
* survives unchanged. A faithful re-implementation of the OLD denylist is included and asserted to
* LET payloads through — the gap this fix closes.
*
* No port / server needed.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
const publicDir = join(process.cwd(), 'src/web/public');
// One jsdom window shared by the shipping sanitizer (bound to its DOMPurify) and the old-denylist
// reference impl (which needs a DOM `document`).
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const jsdomDocument = jsdomWindow.document;
/** Build the SHIPPING sanitizer the way the browser does: vendored DOMPurify (bound to our jsdom
* window) + the EXACT CONFIG from sanitize-html.js — so the same allow/forbid lists are exercised
* under vitest without a real browser. */
function loadShippingSanitizer(): (html: string) => string {
const dompurifySrc = readFileSync(join(publicDir, 'vendor/dompurify.min.js'), 'utf8');
const sanitizeSrc = readFileSync(join(publicDir, 'sanitize-html.js'), 'utf8');
// dompurify.min.js is a UMD — evaluate it as CommonJS to obtain the factory (createDOMPurify),
// then bind it to our jsdom window so DOMPurify sanitizes against a real DOM.
const dpModule: { exports: unknown } = { exports: {} };
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', dompurifySrc)(dpModule, dpModule.exports);
const factory = dpModule.exports as (win: unknown) => { sanitize: (h: string, c?: unknown) => string };
const DOMPurify = factory(jsdomWindow);
// sanitize-html.js exposes createMarkdownSanitizer via its CommonJS export.
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
exports: {},
};
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', sanitizeSrc)(sanModule, sanModule.exports);
const create = sanModule.exports.createMarkdownSanitizer;
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
const fn = create(DOMPurify);
if (typeof fn !== 'function') throw new Error('sanitizeMarkdownHtml not wired');
return fn;
}
/** Faithful copy of the OLD denylist _sanitizeHtml (app.js pre-COD-56) — used only to prove RED. */
function oldDenylistSanitize(html: string): string {
const tpl = jsdomDocument.createElement('template');
tpl.innerHTML = html;
const frag = tpl.content;
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
el.remove();
}
for (const el of frag.querySelectorAll('*')) {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) {
el.removeAttribute(attr.name);
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
const val = attr.value.replace(/\s/g, '').toLowerCase();
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
el.removeAttribute(attr.name);
}
}
}
}
const div = jsdomDocument.createElement('div');
div.appendChild(frag);
return div.innerHTML;
}
// mXSS / XSS payloads. Each must be neutralized by the shipping sanitizer.
const PAYLOADS: { name: string; html: string }[] = [
{ name: 'img onerror', html: '<img src=x onerror=alert(1)>' },
{ name: 'svg onload', html: '<svg onload=alert(1)></svg>' },
{ name: 'svg/script', html: '<svg><script>alert(1)</script></svg>' },
{ name: 'svg/style mXSS', html: '<svg><style><img src=x onerror=alert(1)></style></svg>' },
{
name: 'math/mtext/table namespace confusion',
html: '<math><mtext><table><mglyph><style><img src=x onerror=alert(1)></style></table></mtext></math>',
},
{ name: 'style attr expression', html: '<div style="width:expression(alert(1))">x</div>' },
{ name: 'style attr url(javascript:)', html: '<div style="background:url(javascript:alert(1))">x</div>' },
{ name: 'style element', html: '<style>body{background:url("javascript:alert(1)")}</style>' },
{ name: 'noscript wrap', html: '<noscript><p title="</noscript><img src=x onerror=alert(1)>">' },
{ name: 'a javascript: href', html: '<a href="javascript:alert(1)">x</a>' },
{ name: 'iframe srcdoc', html: '<iframe srcdoc="<img src=x onerror=alert(1)>"></iframe>' },
{
name: 'foreignObject mXSS',
html: '<svg><foreignObject><iframe src="javascript:alert(1)"></iframe></foreignObject></svg>',
},
{ name: 'details ontoggle', html: '<details open ontoggle=alert(1)>x</details>' },
{ name: 'object data', html: '<object data="javascript:alert(1)"></object>' },
];
function assertNeutralized(out: string, label: string) {
const lower = out.toLowerCase();
expect(lower, `${label}: no <script>`).not.toContain('<script');
expect(lower, `${label}: no <svg>`).not.toContain('<svg');
expect(lower, `${label}: no <math>`).not.toContain('<math');
expect(lower, `${label}: no <iframe>`).not.toContain('<iframe');
expect(lower, `${label}: no <object>`).not.toContain('<object');
expect(lower, `${label}: no <style>`).not.toContain('<style');
expect(lower, `${label}: no onerror`).not.toContain('onerror');
expect(lower, `${label}: no onload`).not.toContain('onload');
expect(lower, `${label}: no ontoggle`).not.toContain('ontoggle');
expect(lower, `${label}: no style= attr`).not.toMatch(/\sstyle\s*=/);
expect(lower, `${label}: no javascript: scheme`).not.toContain('javascript:');
expect(lower, `${label}: no expression(`).not.toContain('expression(');
}
describe('COD-56 markdown sanitizer (DOMPurify allowlist)', () => {
let sanitize: (html: string) => string;
beforeAll(() => {
sanitize = loadShippingSanitizer();
});
describe('mXSS / XSS payloads are neutralized', () => {
for (const { name, html } of PAYLOADS) {
it(`blocks: ${name}`, () => {
assertNeutralized(sanitize(html), name);
});
}
});
describe('curated allowlist is actually enforced (USE_PROFILES must not override it)', () => {
// These tags are in DOMPurify's default html profile but NOT in the curated ALLOWED_TAGS.
// If USE_PROFILES were set, the profile would override the allowlist and these would survive.
const NON_MARKDOWN_TAGS: { name: string; html: string; tag: string }[] = [
{ name: 'button', html: '<button>click</button>', tag: '<button' },
{ name: 'input', html: '<input value="x">', tag: '<input' },
{ name: 'details', html: '<details open>d</details>', tag: '<details' },
{ name: 'audio', html: '<audio controls></audio>', tag: '<audio' },
{ name: 'select/option', html: '<select><option>o</option></select>', tag: '<select' },
{ name: 'label', html: '<label>l</label>', tag: '<label' },
];
for (const { name, html, tag } of NON_MARKDOWN_TAGS) {
it(`drops non-markdown tag: ${name}`, () => {
expect(sanitize(html).toLowerCase()).not.toContain(tag);
});
}
});
describe('legitimate markdown-rendered HTML survives', () => {
it('keeps bold, links, lists, code, headings, tables, safe images', () => {
const md =
'<h2>Title</h2>' +
'<p><strong>bold</strong> and <em>em</em> and <a href="https://example.com">link</a></p>' +
'<ul><li>one</li><li>two</li></ul>' +
'<pre><code class="language-js">const x = 1;</code></pre>' +
'<blockquote><p>quote</p></blockquote>' +
'<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>c</td></tr></tbody></table>' +
'<img src="https://example.com/a.png" alt="pic">';
const out = sanitize(md);
expect(out).toContain('<strong>bold</strong>');
expect(out).toContain('<em>em</em>');
expect(out).toContain('href="https://example.com"');
expect(out).toContain('<li>one</li>');
expect(out).toContain('<code class="language-js">const x = 1;</code>');
expect(out).toContain('<blockquote>');
expect(out).toContain('<th>h</th>');
expect(out).toContain('<td>c</td>');
expect(out).toContain('src="https://example.com/a.png"');
expect(out).toContain('alt="pic"');
});
it('preserves a relative/inline image src and code fences', () => {
const out = sanitize('<p>see <code>code</code></p><img src="/local/path.png" alt="x">');
expect(out).toContain('<code>code</code>');
expect(out).toContain('src="/local/path.png"');
});
});
// RED EVIDENCE: the OLD denylist let mXSS through. This documents the gap the fix closes;
// it asserts the OLD logic FAILS to neutralize at least the svg/math/style vectors.
describe('RED: the old denylist sanitizer was bypassable', () => {
it('old code leaves <svg> / <math> roots in the output', () => {
// svg/math were never in the denylist tag set -> they survive (mXSS foreign namespace).
const svgOut = oldDenylistSanitize('<svg><circle></circle></svg>').toLowerCase();
const mathOut = oldDenylistSanitize('<math><mtext>x</mtext></math>').toLowerCase();
expect(svgOut).toContain('<svg');
expect(mathOut).toContain('<math');
});
it('old code leaves a CSS-vector style attribute in the output', () => {
const out = oldDenylistSanitize('<div style="background:url(javascript:alert(1))">x</div>').toLowerCase();
// style attributes were never stripped by the denylist.
expect(out).toMatch(/\sstyle\s*=/);
expect(out).toContain('javascript:');
});
it('NEW code closes those same gaps', () => {
expect(sanitize('<svg><circle></circle></svg>').toLowerCase()).not.toContain('<svg');
expect(sanitize('<math><mtext>x</mtext></math>').toLowerCase()).not.toContain('<math');
const out = sanitize('<div style="background:url(javascript:alert(1))">x</div>').toLowerCase();
expect(out).not.toMatch(/\sstyle\s*=/);
expect(out).not.toContain('javascript:');
});
});
});
+145
View File
@@ -0,0 +1,145 @@
// Port: none (pure static analysis — runs in CI, no browser/server).
//
// Regression guard for "a header button leaks onto the cramped mobile header".
// This exact class of bug shipped twice (the plan-usage chip, then the COD-39
// attachments-history button): a new control was added to the header, looked
// fine on desktop, and nobody noticed it cluttering the phone header. The mobile
// Playwright suite that *would* catch it (test/mobile/**) is EXCLUDED from CI, so
// it never gated. This test is intentionally a pure parser of index.html +
// mobile.css so it runs in the normal CI sweep with zero browser dependencies.
//
// Policy: every header button that is VISIBLE BY DEFAULT on desktop must have an
// explicit decision for phones — either it's hidden via an @media (max-width:
// 430px) display:none rule in mobile.css, or it's added to MOBILE_VISIBLE_ALLOWLIST
// below with a reason. A new default-visible header button with neither fails this
// test, forcing the author to decide its mobile behavior.
//
// The real-browser counterpart (actual computed visibility on an emulated phone)
// lives in test/mobile/header-buttons.test.ts.
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import postcss from 'postcss';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const PUBLIC = join(HERE, '../src/web/public');
// Canonical phone width used to decide whether a media query applies on a phone.
// Matches the device the browser-based test emulates (iPhone 14 Pro = 393px CSS).
const PHONE_WIDTH = 393;
// Header buttons intentionally kept VISIBLE in the phone header. Empty today: the
// mobile header is deliberately minimal and essential controls (settings, case)
// live in the toolbar. Add a class here ONLY with a justifying comment.
const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
// Buttons we expect to STAY hidden on phones — an explicit lock so a future edit
// that removes a hide rule fails loudly (not silently). The attachments button is
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
// it's excluded from the default-visible enumeration rather than mobile-hidden.
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log'];
function attrOf(openTag: string, name: string): string {
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
return m ? m[1] : '';
}
/** Does a media query's width range include a phone-width viewport? */
function appliesToPhone(params: string): boolean {
const max = params.match(/max-width:\s*(\d+)px/);
const min = params.match(/min-width:\s*(\d+)px/);
if (max && PHONE_WIDTH > Number(max[1])) return false; // phone is wider than the cap
if (min && PHONE_WIDTH < Number(min[1])) return false; // phone is narrower than the floor
return Boolean(max || min); // must actually be a width-bounded query
}
function loadHeaderButtons(): { classes: string[]; id: string; distinguishing: string[] }[] {
const html = readFileSync(join(PUBLIC, 'index.html'), 'utf-8');
// The header controls live in <div class="header-right" id="headerRight"> … </header>.
const region = html.match(/<div class="header-right"[^>]*>([\s\S]*?)<\/header>/);
expect(region, '#headerRight region not found in index.html — update the selector in this test').toBeTruthy();
const headerHtml = region![1];
return [...headerHtml.matchAll(/<button\b([^>]*)>/g)]
.map((m) => {
const open = m[1];
const classes = attrOf(open, 'class').split(/\s+/).filter(Boolean);
return {
classes,
id: attrOf(open, 'id'),
style: attrOf(open, 'style'),
distinguishing: classes.filter(
(c) => c.startsWith('btn-') && c !== 'btn-icon-header' && c !== 'btn-sm' && !c.endsWith('--hidden')
),
};
})
.filter((b) => b.classes.includes('btn-icon-header'))
.filter((b) => !b.classes.includes('btn-sm')) // font A-/A+ controls — separate sub-group
.filter((b) => !/display:\s*none/i.test(b.style)) // JS-gated (solo-redock, retired bell)
.filter((b) => !b.classes.some((c) => c.endsWith('--hidden'))); // opt-in, hidden by default
}
function loadPhoneHiddenClasses(): Set<string> {
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
const hidden = new Set<string>();
postcss.parse(css).walkAtRules('media', (atRule) => {
if (!appliesToPhone(atRule.params)) return;
atRule.walkRules((rule) => {
let hides = false;
rule.walkDecls('display', (decl) => {
if (decl.value.replace(/!important/i, '').trim() === 'none') hides = true;
});
if (!hides) return;
for (const token of rule.selector.match(/\.btn-[a-z0-9-]+/gi) || []) {
hidden.add(token.slice(1).toLowerCase());
}
});
});
return hidden;
}
describe('Mobile header button policy (static guard)', () => {
const buttons = loadHeaderButtons();
const phoneHidden = loadPhoneHiddenClasses();
it('finds the default-visible header buttons (sanity)', () => {
// If this drops to 0 the parser/markup drifted — fix the parser, don't delete the test.
expect(buttons.length).toBeGreaterThan(0);
});
it('every default-visible header button has a mobile-visibility decision', () => {
for (const btn of buttons) {
expect(
btn.distinguishing.length,
`Header button (id=${btn.id || '?'}, class="${btn.classes.join(' ')}") has no distinguishing ` +
`btn-* class to target on mobile. Give it one so its phone visibility can be controlled.`
).toBeGreaterThan(0);
const hidden = btn.distinguishing.some((c) => phoneHidden.has(c.toLowerCase()));
const allowed = btn.distinguishing.some((c) => MOBILE_VISIBLE_ALLOWLIST.has(c));
expect(
hidden || allowed,
`Header button .${btn.distinguishing.join('.')} (id=${btn.id || '?'}) is VISIBLE BY DEFAULT but has ` +
`no mobile-visibility decision.\n` +
` → To hide it on phones: add it to the @media (max-width: 430px) "display: none" block in ` +
`src/web/public/mobile.css (next to .btn-settings / .btn-lifecycle-log).\n` +
` → To keep it visible on phones: add '${btn.distinguishing[0]}' to MOBILE_VISIBLE_ALLOWLIST in ` +
`this test, with a reason.\n` +
`This guard exists because the plan-usage chip and the attachments button both leaked onto the ` +
`mobile header unnoticed.`
).toBe(true);
}
});
it('locks the known phone-hidden header buttons', () => {
for (const cls of KNOWN_PHONE_HIDDEN) {
expect(
phoneHidden.has(cls),
`${cls} must stay hidden on phones — restore its rule in the @media (max-width: 430px) ` +
`display:none block in src/web/public/mobile.css.`
).toBe(true);
}
});
});
+58
View File
@@ -0,0 +1,58 @@
// Port 3208 - Header button visibility (real-browser E2E).
//
// Companion to the CI static guard (test/mobile-header-buttons-policy.test.ts).
// Renders the real app and asserts ACTUAL computed visibility, catching CSS/layout
// regressions the static parser can't see. Two behaviours are covered:
// 1. The minimal phone header hides the settings gear + lifecycle log.
// 2. The attachments button (COD-39) is OPT-IN — default-hidden everywhere, shown
// only when App Settings → Display → "Attachments Button" is enabled. It first
// shipped always-visible (mobile, then desktop), which is the regression here.
import { describe, it, beforeAll, afterAll } from 'vitest';
import type { WebServer } from '../../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { assertHidden, assertVisible } from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
import { PORTS, WAIT } from './helpers/constants.js';
const PORT = PORTS.HEADER_BUTTONS;
const BASE_URL = `http://localhost:${PORT}`;
describe('Header button visibility (E2E)', () => {
let server: WebServer;
beforeAll(async () => {
server = await createTestServer(PORT);
});
afterAll(async () => {
await closeAllBrowsers();
await stopTestServer(server);
});
it('hides the settings gear + lifecycle log on a standard phone (iPhone 14 Pro)', async () => {
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL);
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '.btn-icon-header.btn-settings');
await assertHidden(page, '.btn-icon-header.btn-lifecycle-log');
});
it('keeps the opt-in attachments button HIDDEN by default on a desktop-class viewport', async () => {
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '#attachmentsHistoryBtn');
});
it('shows the attachments button once the setting is enabled', async () => {
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
// Desktop-class devices use the non-mobile settings blob.
await page.evaluate(() => {
const cur = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
cur.showAttachmentsButton = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(cur));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertVisible(page, '#attachmentsHistoryBtn');
});
});
+4 -3
View File
@@ -8,6 +8,7 @@ export const PORTS = {
DEVICE_MATRIX: 3205,
VISUAL_REGRESSION: 3206,
ACCESSIBILITY: 3207,
HEADER_BUTTONS: 3208,
} as const;
// CSS Selectors
@@ -64,8 +65,8 @@ export const BREAKPOINTS = {
// Keyboard constants (match app.js KeyboardHandler)
export const KEYBOARD = {
SHOW_THRESHOLD: 150, // heightDiff > 150px triggers show
HIDE_THRESHOLD: 100, // heightDiff < 100px triggers hide
SHOW_THRESHOLD: 150, // heightDiff > 150px triggers show
HIDE_THRESHOLD: 100, // heightDiff < 100px triggers hide
TYPICAL_IOS_HEIGHT: 336,
FOCUSIN_DELAY: 400,
ANIMATION_DELAY: 150,
@@ -87,7 +88,7 @@ export const SWIPE = {
export const SUBAGENT = {
MOBILE_CARD_HEIGHT: 110,
MOBILE_CARD_GAP: 4,
MOBILE_CARD_STRIDE: 114, // height + gap
MOBILE_CARD_STRIDE: 114, // height + gap
TOOLBAR_OFFSET: 40,
DEFAULT_HEADER_HEIGHT: 36,
} as const;
+14 -4
View File
@@ -528,15 +528,24 @@ describe('Virtual Keyboard', () => {
expect(afterEnter.sentInputs).toEqual(['hello', '\r']);
});
it('shows the CJK textarea on mobile only for server override', async () => {
it('shows the CJK textarea on mobile for server override only inside an active session', async () => {
const state = await page.evaluate(() => {
app._serverCjkOverride = true;
app._updateCjkInputState();
const input = document.getElementById('cjkInput');
if (!(input instanceof HTMLElement)) return null;
// Welcome screen (no active session): even with the server override on, the
// fixed-position textarea must stay hidden so it doesn't float over the overlay.
app.activeSessionId = null;
app._serverCjkOverride = true;
app._updateCjkInputState();
const onWelcomeDisplay = getComputedStyle(input).display;
// Entering a session reveals it.
app.activeSessionId = 'cjk-server-override-test';
app._updateCjkInputState();
const cs = getComputedStyle(input);
return {
onWelcomeDisplay,
display: cs.display,
position: cs.position,
bottom: cs.bottom,
@@ -546,6 +555,7 @@ describe('Virtual Keyboard', () => {
});
expect(state).not.toBeNull();
expect(state?.onWelcomeDisplay).toBe('none');
expect(state?.display).not.toBe('none');
expect(state?.position).toBe('fixed');
expect(Number(state?.zIndex)).toBeGreaterThan(50);
+13 -41
View File
@@ -132,54 +132,26 @@ describe('Tab Navigation', () => {
expect(modalClass).toMatch(/active/);
});
it('top-left mobile menu button opens the header utility tray', async () => {
it('header has no utility toggle and the tray is reachable inline on mobile', async () => {
// The three-dot header utility toggle was removed (owner decision,
// 2026-06-10) and the collapsible position:fixed headerRight tray it
// controlled was dropped (PR #122): with the toggle gone, leaving the
// tray collapsed made every header-right utility — including the opt-in
// response-viewer eye button — permanently unreachable on phones. The
// utilities now flow INLINE and must stay reachable on small viewports;
// nothing interactive may occupy the top-left corner.
await page.evaluate(() => {
document.querySelectorAll('.modal.active').forEach((modal) => modal.classList.remove('active'));
document.getElementById('headerRight')?.classList.add('mobile-collapsed');
const toggle = document.getElementById('mobileHeaderUtilityToggle');
toggle?.classList.remove('active');
toggle?.setAttribute('aria-expanded', 'false');
});
const topLeftElements = await page.evaluate(() => {
return document.elementsFromPoint(16, 16).map((el) => ({
tag: el.tagName,
id: el.id,
className: String(el.className),
closestButtonId: el.closest('button')?.id ?? '',
}));
});
const toggleCount = await page.locator('#mobileHeaderUtilityToggle').count();
expect(toggleCount).toBe(0);
expect(topLeftElements[0]?.closestButtonId).toBe('mobileHeaderUtilityToggle');
const toggleBox = await page.locator('#mobileHeaderUtilityToggle').boundingBox();
expect(toggleBox?.width ?? 0).toBeGreaterThanOrEqual(44);
expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(44);
await page.touchscreen.tap(
(toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) / 2,
(toggleBox?.y ?? 0) + (toggleBox?.height ?? 0) / 2
);
await page.waitForTimeout(150);
const trayClass = await page.locator('#headerRight').getAttribute('class');
const expanded = await page.locator('#mobileHeaderUtilityToggle').getAttribute('aria-expanded');
const trayBox = await page.locator('#headerRight').boundingBox();
const topTrayElement = await page.evaluate(() => {
const trayVisible = await page.evaluate(() => {
const tray = document.getElementById('headerRight');
const rect = tray?.getBoundingClientRect();
if (!rect) return '';
return (
document
.elementsFromPoint(rect.left + Math.min(24, rect.width / 2), rect.top + Math.min(24, rect.height / 2))
.find((el) => el.id === 'headerRight' || el.closest?.('#headerRight'))
?.closest?.('#headerRight')?.id ?? ''
);
return tray ? getComputedStyle(tray).display !== 'none' : false;
});
expect(trayClass).not.toMatch(/mobile-collapsed/);
expect(expanded).toBe('true');
expect(trayBox?.x ?? 9999).toBeLessThanOrEqual((toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) + 8);
expect(topTrayElement).toBe('headerRight');
expect(trayVisible).toBe(true);
});
it('tabs remain visible on large phone and tablet headers', async () => {
+10
View File
@@ -222,6 +222,15 @@ export class MockSession extends EventEmitter {
};
}
/** Auto-resume on usage limit (token pause control) */
autoResumeEnabled: boolean = false;
autoResumeAt: number | null = null;
isLimitPaused: boolean = false;
setAutoResume = vi.fn((enabled: boolean) => {
this.autoResumeEnabled = enabled;
if (!enabled) this.autoResumeAt = null;
});
/** Check if session is busy */
isBusy = vi.fn(() => false);
@@ -239,6 +248,7 @@ export class MockSession extends EventEmitter {
/** Stubs for the desktop sizing claims used by resize arbitration */
claimDesktopSizing = vi.fn();
releaseDesktopSizing = vi.fn();
noteDesktopActivity = vi.fn();
/** Stub for runPrompt */
runPrompt = vi.fn(async () => {});
+36
View File
@@ -144,6 +144,42 @@ describe('RespawnController', () => {
});
});
describe('Usage-limit pause guard', () => {
it('blocks the cycle while the session is paused on a usage limit', async () => {
let cycleStarted = false;
let blockedReason: string | null = null;
controller.on('respawnCycleStarted', () => {
cycleStarted = true;
});
controller.on('respawnBlocked', (data: { reason: string }) => {
blockedReason = data.reason;
});
session.isLimitPaused = true;
controller.start();
session.simulateCompletionMessage();
await new Promise((resolve) => setTimeout(resolve, 250));
expect(cycleStarted).toBe(false);
expect(blockedReason).toBe('usage_limit');
expect(controller.state).toBe('watching');
});
it('cycles normally when the pause is not active', async () => {
let cycleStarted = false;
controller.on('respawnCycleStarted', () => {
cycleStarted = true;
});
session.isLimitPaused = false;
controller.start();
session.simulateCompletionMessage();
await new Promise((resolve) => setTimeout(resolve, 250));
expect(cycleStarted).toBe(true);
});
});
describe('Respawn Cycle', () => {
it('should start cycle when completion message detected and confirmed', async () => {
let cycleStarted = false;
@@ -0,0 +1,358 @@
/**
* @fileoverview COD-53 — attachment path-traversal / sensitive-file guard.
*
* Verifies the sensitive-path blocklist is enforced at:
* - attachment registration (POST /api/sessions/:id/attachments)
* - raw / preview / thumbnail serving (defense-in-depth against a record that
* was crafted or registered before the guard existed)
* while still allowing legitimate cross-workspace attachment (codeman-publish
* skill + the ~/.codeman review-card loop) to succeed.
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Readable } from 'node:stream';
import { homedir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
// Mock fs/promises for file operations
vi.mock('node:fs/promises', () => ({
default: {
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => 'file content'),
writeFile: vi.fn(async () => undefined),
stat: vi.fn(async () => ({ size: 100, isFile: () => true, mtimeMs: 1 })),
mkdir: vi.fn(async () => undefined),
mkdtemp: vi.fn(async () => '/tmp/codeman-preview-test'),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
// Mock realpathSync for symlink resolution (identity by default)
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
realpathSync: vi.fn((p: string) => p),
createReadStream: vi.fn(() => Readable.from([Buffer.from('file content')])),
};
});
vi.mock('../../src/file-stream-manager.js', () => ({
fileStreamManager: {
createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })),
closeStream: vi.fn(() => true),
},
}));
import fs from 'node:fs/promises';
import { createReadStream, realpathSync } from 'node:fs';
import {
attachmentRegistry,
registerExternalAttachment,
type AttachmentRecord,
} from '../../src/attachment-registry.js';
const mockedStat = vi.mocked(fs.stat);
const mockedRealpathSync = vi.mocked(realpathSync);
const mockedCreateReadStream = vi.mocked(createReadStream);
describe('file-routes attachment path guard (COD-53)', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
vi.clearAllMocks();
attachmentRegistry.clearSession('test-session-1');
mockedRealpathSync.mockImplementation((p: string) => p as never);
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, mtimeMs: 1 } as never);
mockedCreateReadStream.mockReturnValue(Readable.from([Buffer.from('file content')]) as never);
});
afterEach(async () => {
await harness.app.close();
attachmentRegistry.clearSession(harness.ctx._sessionId);
// Reset attachment-guard env knobs so one test can't leak into the next.
delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
delete process.env.CODEMAN_ATTACHMENT_CONFINE;
});
// ===== BLOCK: registration rejects a sensitive path =====
it('rejects registering a .env file that carries a supported extension', async () => {
// A dotenv-style secret file named with a supported extension still leaks
// secrets; the blocklist's /\.env\./ pattern catches `.env.<ext>`.
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/home/someone/project/.env.txt' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('rejects registering an SSH key path even with a supported extension', async () => {
const sshTxt = `${homedir()}/.ssh/id_rsa.txt`;
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: sshTxt },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('rejects registering a sensitive path that a symlink resolves to', async () => {
// The requested path looks innocent (.md) but realpath resolves it to an SSH key dir.
mockedRealpathSync.mockReturnValue(`${homedir()}/.ssh/known_hosts.md` as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/home/someone/project/innocent.md' },
});
expect(res.statusCode).toBe(403);
});
// ===== BLOCK (defense-in-depth): raw serving rejects a sensitive record =====
it('refuses to serve raw bytes for a record whose path is sensitive', async () => {
// Simulate a record that was registered before the guard existed (or crafted).
const record: AttachmentRecord = {
attachmentId: 'att_sensitive',
sessionId: harness.ctx._sessionId,
filePath: `${homedir()}/.ssh/id_rsa.txt`,
fileName: 'id_rsa.txt',
extension: 'txt',
attachmentType: 'text',
size: 100,
mtimeMs: 1,
timestamp: Date.now(),
source: 'external',
};
attachmentRegistry.register(record);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/att_sensitive/raw`,
});
expect(res.statusCode).toBe(403);
expect(mockedCreateReadStream).not.toHaveBeenCalled();
});
// ===== PRESERVE: legitimate cross-workspace attachment still works =====
it('still registers a normal cross-workspace file (codeman-publish / loop review card)', async () => {
mockedStat.mockResolvedValue({ size: 512, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: `${homedir()}/.codeman/jira-autoloop-questions.md` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('jira-autoloop-questions.md');
expect(body.data.extension).toBe('md');
});
it('still registers an arbitrary project-dir file (WSL path)', async () => {
mockedStat.mockResolvedValue({ size: 4096, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/mnt/c/decks/board-update.pdf' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('board-update.pdf');
});
it('still serves raw bytes for a legitimately registered cross-workspace file', async () => {
const content = Buffer.from('# notes');
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
const registerRes = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: `${homedir()}/.codeman/review-card.md` },
});
const attachmentId = JSON.parse(registerRes.body).data.attachmentId;
const rawRes = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/raw`,
});
expect(rawRes.statusCode).toBe(200);
expect(rawRes.headers['content-type']).toBe('text/markdown');
});
// ===== BLOCK (broadened defaults): /root and /etc trees =====
it('rejects registering a file anywhere under /root by default', async () => {
// /root is the root account home — blocked as a whole tree by default,
// even for an ordinary-looking note with a supported extension.
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/root/secret-notes.md' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('rejects registering a file anywhere under /etc by default', async () => {
// The whole /etc tree is blocked by default (not just /etc/shadow).
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/etc/codeman/config-dump.txt' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('does not block a lookalike sibling dir like /etcetera (separator-aware)', async () => {
// The /etc tree block must be path-separator-aware so an unrelated
// /etcetera/... path is NOT caught by accident.
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/etcetera/notes.md' },
});
expect(res.statusCode).toBe(200);
});
// ===== CONFIG: extend the blocked set via env =====
it('rejects a path added via the extra-blocked-paths config', async () => {
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = '/srv/secrets,/data/private';
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/srv/secrets/keys.pdf' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('still allows a normal path NOT in the configured blocked set', async () => {
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = '/srv/secrets,/data/private';
mockedStat.mockResolvedValue({ size: 20, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/srv/public/report.pdf' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('report.pdf');
});
// ===== CONFINEMENT MODE ON (opt-in) =====
it('confinement ON: rejects a file OUTSIDE the session workspace', async () => {
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
// Mock session workspace is /tmp/test-workdir; this file resolves elsewhere.
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/home/someone/elsewhere/report.pdf' },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('confinement ON: allows a file INSIDE the session workspace', async () => {
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
// Mock session workspace is /tmp/test-workdir (see MockSession).
const insidePath = '/tmp/test-workdir/docs/report.pdf';
mockedRealpathSync.mockReturnValue(insidePath as never);
mockedStat.mockResolvedValue({ size: 30, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: insidePath },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('report.pdf');
});
// ===== CONFINEMENT OFF (default) regression: legit cross-workspace attach =====
it('confinement OFF (default): legit cross-workspace attach still succeeds', async () => {
// No CODEMAN_ATTACHMENT_CONFINE set → default OFF. A ~/.codeman review-card
// file lives OUTSIDE the /tmp/test-workdir session workspace and must still
// attach (protects codeman-publish + the loop's review-card channel).
mockedStat.mockResolvedValue({ size: 64, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: `${homedir()}/.codeman/jira-autoloop-questions.md` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('jira-autoloop-questions.md');
});
// ===== Magic-link scan path: FORCED workspace confinement =====
// The terminal-output `codeman://attach` scanner registers with
// forceWorkspaceConfinement: true so a prompt-injected session printing an
// arbitrary path can't expose a host file, even though global confine is OFF.
describe('forced workspace confinement (magic-link scan path)', () => {
it('rejects an out-of-workspace path even when global confinement is OFF', async () => {
mockedRealpathSync.mockImplementation((p: string) => p as never);
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
await expect(
registerExternalAttachment('test-session-mlc', '/home/someone/secret/report.pdf', {
sessionWorkingDir: '/tmp/test-workdir',
forceWorkspaceConfinement: true,
})
).rejects.toMatchObject({ statusCode: 403 });
attachmentRegistry.clearSession('test-session-mlc');
});
it('allows an in-workspace path on the forced path', async () => {
const inside = '/tmp/test-workdir/sub/report.pdf';
mockedRealpathSync.mockReturnValue(inside as never);
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
const event = await registerExternalAttachment('test-session-mlc', inside, {
sessionWorkingDir: '/tmp/test-workdir',
forceWorkspaceConfinement: true,
});
expect(event.fileName).toBe('report.pdf');
attachmentRegistry.clearSession('test-session-mlc');
});
});
});
@@ -0,0 +1,156 @@
/**
* @fileoverview Route coverage for the document preview/thumbnail endpoints
* added in COD-38 (PR #120): the by-attachmentId routes
* (/attachments/:id/preview|thumbnail) and the workspace-path routes
* (/file-preview|/file-thumbnail). Converters are mocked, so no real
* pdftoppm/LibreOffice is needed. Uses app.inject() — no real ports.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => Buffer.from('%PDF-1.4 fake pdf bytes')),
stat: vi.fn(async () => ({ size: 100, isFile: () => true, mtimeMs: 1 })),
readdir: vi.fn(async () => []),
mkdir: vi.fn(async () => undefined),
mkdtemp: vi.fn(async () => '/tmp/codeman-preview-test'),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, realpathSync: vi.fn((p: string) => p) };
});
// Mock the converters so the routes don't shell out to real binaries.
vi.mock('../../src/document-thumbnailer.js', () => ({
generateFirstPageThumbnail: vi.fn(async () => ({ content: Buffer.from('\x89PNG fake'), contentType: 'image/png' })),
}));
vi.mock('../../src/document-preview-cache.js', () => ({
getOfficePreviewPdfPath: vi.fn(async () => '/tmp/codeman-document-preview-cache/out.pdf'),
getPreviewPdfDownloadName: vi.fn((name: string) => `${name.replace(/\.[^.]+$/, '')}.pdf`),
}));
import { generateFirstPageThumbnail } from '../../src/document-thumbnailer.js';
import { getOfficePreviewPdfPath } from '../../src/document-preview-cache.js';
import { attachmentRegistry, type AttachmentRecord } from '../../src/attachment-registry.js';
const SID = 'test-session-1';
const WORKDIR = '/tmp/test-workdir';
function makeRecord(over: Partial<AttachmentRecord>): AttachmentRecord {
return {
attachmentId: 'att_x',
sessionId: SID,
filePath: `${WORKDIR}/file`,
fileName: 'file',
extension: 'pdf',
attachmentType: 'document',
size: 100,
mtimeMs: 1,
timestamp: 1,
source: 'detected',
...over,
};
}
describe('file-routes preview/thumbnail (COD-38)', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes, { sessionId: SID });
vi.clearAllMocks();
attachmentRegistry.clearSession(SID);
});
afterEach(async () => {
await harness.app.close();
attachmentRegistry.clearSession(SID);
});
// ---- workspace-path routes ----
it('file-preview converts a workspace DOCX to an inline PDF', async () => {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=deck.docx` });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('application/pdf');
expect(res.headers['content-disposition']).toContain('inline');
expect(getOfficePreviewPdfPath).toHaveBeenCalled();
});
it('file-preview redirects a non-Office workspace file (PDF) to the raw route', async () => {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=report.pdf` });
expect(res.statusCode).toBeGreaterThanOrEqual(300);
expect(res.statusCode).toBeLessThan(400);
expect(res.headers.location).toContain('/file-raw?path=report.pdf');
expect(getOfficePreviewPdfPath).not.toHaveBeenCalled();
});
it('file-preview 400s for a missing path parameter', async () => {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview` });
expect(res.statusCode).toBe(400);
});
it('file-thumbnail returns a PNG for a supported workspace file', async () => {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=deck.pdf` });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('image/png');
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(generateFirstPageThumbnail).toHaveBeenCalled();
});
it('file-thumbnail 400s for an unsupported extension', async () => {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=notes.exe` });
expect(res.statusCode).toBe(400);
expect(generateFirstPageThumbnail).not.toHaveBeenCalled();
});
// ---- by-attachmentId routes ----
it('by-id preview converts a registered DOCX attachment', async () => {
attachmentRegistry.register(
makeRecord({
attachmentId: 'att_docx',
filePath: `${WORKDIR}/deck.docx`,
fileName: 'deck.docx',
extension: 'docx',
})
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${SID}/attachments/att_docx/preview`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('application/pdf');
});
it('by-id preview redirects a non-Office attachment (PNG) to its raw route', async () => {
attachmentRegistry.register(
makeRecord({ attachmentId: 'att_png', filePath: `${WORKDIR}/shot.png`, fileName: 'shot.png', extension: 'png' })
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${SID}/attachments/att_png/preview`,
});
expect(res.statusCode).toBeGreaterThanOrEqual(300);
expect(res.statusCode).toBeLessThan(400);
expect(res.headers.location).toContain('/attachments/att_png/raw');
});
it('by-id thumbnail returns a PNG for a registered attachment', async () => {
attachmentRegistry.register(
makeRecord({ attachmentId: 'att_pdf', filePath: `${WORKDIR}/deck.pdf`, fileName: 'deck.pdf', extension: 'pdf' })
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${SID}/attachments/att_pdf/thumbnail`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('image/png');
});
});
+90
View File
@@ -263,6 +263,63 @@ describe('session-routes', () => {
});
});
// ========== POST /api/sessions/:id/auto-resume ==========
describe('POST /api/sessions/:id/auto-resume', () => {
it('enables auto-resume on usage limit', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/auto-resume`,
payload: { enabled: true },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.autoResume.enabled).toBe(true);
const session = harness.ctx.sessions.get(harness.ctx._sessionId)!;
expect(session.setAutoResume).toHaveBeenCalledWith(true);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything());
});
it('disables auto-resume', async () => {
const session = harness.ctx.sessions.get(harness.ctx._sessionId)!;
session.autoResumeEnabled = true;
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/auto-resume`,
payload: { enabled: false },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.autoResume.enabled).toBe(false);
expect(session.setAutoResume).toHaveBeenCalledWith(false);
});
it('rejects invalid body', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/auto-resume`,
payload: { enabled: 'yes' },
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe(ApiErrorCode.INVALID_INPUT);
});
it('returns 404 for unknown session', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/nonexistent/auto-resume',
payload: { enabled: true },
});
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
});
// ========== POST /api/sessions/:id/input ==========
describe('POST /api/sessions/:id/input', () => {
@@ -384,6 +441,39 @@ describe('session-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('prepends the live tmux pane buffer (cleared) before the byte history', async () => {
harness.ctx._session.terminalBuffer = 'history-bytes';
harness.ctx.mux.captureActivePaneBuffer = vi.fn(() => 'LIVE-PANE-FRAME');
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const buf = JSON.parse(res.body).data.terminalBuffer as string;
// history, then a viewport clear, then the live pane frame
expect(buf).toContain('history-bytes');
expect(buf).toContain('\x1b[H\x1b[2J');
expect(buf).toContain('LIVE-PANE-FRAME');
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('LIVE-PANE-FRAME'));
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
});
it('falls back to the byte history when no live pane buffer is available', async () => {
harness.ctx._session.terminalBuffer = 'history-only';
// Empty string (the test-mode return) and null both mean "no live frame".
harness.ctx.mux.captureActivePaneBuffer = vi.fn(() => '');
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const buf = JSON.parse(res.body).data.terminalBuffer as string;
expect(buf).toContain('history-only');
expect(buf).not.toContain('\x1b[H\x1b[2J');
});
});
// ========== POST /api/sessions/:id/run ==========
+102
View File
@@ -0,0 +1,102 @@
/**
* Route tests for POST /api/status-telemetry — the Claude statusline exporter
* endpoint that feeds the header "Plan Usage Limits" chip.
*
* Covers: broadcast on real telemetry + footer print-through, unknown-session
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
* and 400 on a malformed body.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
const SID = 'test-session-1'; // default id created by createMockRouteContext
const REAL = {
rate_limits: {
five_hour: { used_percentage: 15, resets_at: 1781409000 },
seven_day: { used_percentage: 34, resets_at: 1781827200 },
},
context_window: { used_percentage: 56, total_input_tokens: 562411, total_output_tokens: 1188 },
cost: { total_cost_usd: 0.0415 },
model: { display_name: 'Opus 4.8 (1M context)' },
};
describe('POST /api/status-telemetry', () => {
let h: RouteTestHarness;
beforeEach(async () => {
h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID });
});
const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body });
it('broadcasts plan-usage telemetry and returns the session-status footer', async () => {
const res = await post({ sessionId: SID, data: REAL });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/plain');
expect(res.body).toBe('Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%');
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
const [event, payload] = h.ctx.broadcast.mock.calls[0];
expect(event).toBe(SessionStatusTelemetry);
expect(payload).toMatchObject({
sessionId: SID,
fiveHour: { usedPercentage: 15 },
sevenDay: { usedPercentage: 34 },
});
});
it('does not broadcast for an unknown session; returns the brand footer', async () => {
const res = await post({ sessionId: 'does-not-exist', data: REAL });
expect(res.statusCode).toBe(200);
expect(res.body).toBe('codeman');
expect(h.ctx.broadcast).not.toHaveBeenCalled();
});
it('dedups identical telemetry — rebroadcasts only once', async () => {
await post({ sessionId: SID, data: REAL });
await post({ sessionId: SID, data: REAL });
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
});
it('rebroadcasts when a displayed window percentage changes', async () => {
await post({ sessionId: SID, data: REAL });
const moved = {
...REAL,
rate_limits: { ...REAL.rate_limits, five_hour: { used_percentage: 16, resets_at: 1781409000 } },
};
await post({ sessionId: SID, data: moved });
expect(h.ctx.broadcast).toHaveBeenCalledTimes(2);
});
it('does NOT rebroadcast on context-only drift (the chip never shows context %)', async () => {
await post({ sessionId: SID, data: REAL });
await post({ sessionId: SID, data: { ...REAL, context_window: { ...REAL.context_window, used_percentage: 91 } } });
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
});
it("tolerates null in Claude's undocumented fields (no 400) and ignores them", async () => {
const res = await post({
sessionId: SID,
data: {
rate_limits: { five_hour: { used_percentage: 20, resets_at: 1781409000 }, seven_day: null },
cost: { total_cost_usd: null },
model: { display_name: null },
context_window: { used_percentage: null, total_input_tokens: null, total_output_tokens: null },
},
});
expect(res.statusCode).toBe(200);
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
const [, payload] = h.ctx.broadcast.mock.calls[0];
expect(payload).toMatchObject({ fiveHour: { usedPercentage: 20 } });
expect(payload.sevenDay).toBeUndefined();
});
it('rejects a malformed body (missing sessionId) with 400', async () => {
const res = await post({ data: REAL });
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,133 @@
/**
* @fileoverview COD-55 — tunnel password guard.
*
* Enabling the Cloudflare tunnel publishes the whole app (full terminal control =
* effectively RCE) to a public *.trycloudflare.com URL. When no CODEMAN_PASSWORD
* is set, requests through that URL are unauthenticated. These tests assert the
* PUT /api/settings tunnel-enable path REFUSES to start the tunnel unless a
* password is set OR the unauthenticated-network opt-in is acknowledged.
*
* Uses app.inject() — no real HTTP ports needed. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
// Settings are written to disk via fs/promises — stub so the guard test never
// touches the real settings.json, and so we can assert "not persisted on refusal".
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
readdirSync: vi.fn(() => []),
};
});
import fs from 'node:fs/promises';
const mockedWriteFile = vi.mocked(fs.writeFile);
/** Build a tunnelManager stub the route's ctx can use. */
function makeTunnelManager(running = false) {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => running),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running })),
};
}
describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
const savedPassword = process.env.CODEMAN_PASSWORD;
const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
vi.clearAllMocks();
mockedWriteFile.mockResolvedValue(undefined);
tunnel = makeTunnelManager(false);
// tunnelManager is null in the default mock ctx — inject our spy.
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
});
afterEach(async () => {
await harness.app.close();
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
else process.env.CODEMAN_PASSWORD = savedPassword;
if (savedOptIn === undefined) delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
else process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = savedOptIn;
});
it('REFUSES tunnel-enable with no password and no opt-in (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
// Message should tell the user how to fix it.
expect(body.error).toMatch(/CODEMAN_PASSWORD|CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK/);
// The tunnel must NOT have been started.
expect(tunnel.start).not.toHaveBeenCalled();
// And tunnelEnabled:true must NOT have been persisted.
expect(mockedWriteFile).not.toHaveBeenCalled();
});
it('ALLOWS tunnel-enable when CODEMAN_PASSWORD is set (start called, 200)', async () => {
process.env.CODEMAN_PASSWORD = 'hunter2';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
tunnel = makeTunnelManager(true);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: false },
});
expect(res.statusCode).toBe(200);
expect(tunnel.stop).toHaveBeenCalledTimes(1);
});
});
+18 -3
View File
@@ -21,11 +21,11 @@
* Port: N/A (no server start)
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, mkdtempSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { hostname as osHostname } from 'node:os';
import { hostname as osHostname, tmpdir } from 'node:os';
import { WebServer } from '../src/web/server.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -40,6 +40,21 @@ async function render(host?: string): Promise<string> {
}
describe('WebServer index.html <title> templating (#82)', () => {
// renderIndexHtml reads the ambient settings.json (for the gesture bundle and
// the header-toggle marker-class strips, e.g. showPlanUsageLimits /
// showMultiMonitorButton). Point it at an empty data dir so this test is
// deterministic regardless of the developer's real settings — otherwise an
// enabled toggle would strip a marker class and break the byte-identical
// assertion below. getDataDir() reads CODEMAN_DATA_DIR fresh per call.
const _prevDataDir = process.env.CODEMAN_DATA_DIR;
beforeAll(() => {
process.env.CODEMAN_DATA_DIR = mkdtempSync(join(tmpdir(), 'codeman-title-test-'));
});
afterAll(() => {
if (_prevDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
else process.env.CODEMAN_DATA_DIR = _prevDataDir;
});
it('substitutes the bare <title>Codeman</title> with codeman:<host>', async () => {
const html = await render('laptop');
expect(html).toContain('<title>codeman:laptop</title>');
+164
View File
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
import {
ATTACHMENT_HISTORY_LIMIT,
buildDetectedAttachmentHistoryItem,
buildExternalAttachmentHistoryItem,
upsertAttachmentHistory,
} from '../src/session-attachment-history.js';
describe('session attachment history', () => {
it('dedupes explicit external attachments by source path and moves latest to top', () => {
const first = buildExternalAttachmentHistoryItem({
sessionId: 's1',
externalPath: '/mnt/c/docs/brief.docx',
fileName: 'brief.docx',
extension: 'docx',
size: 100,
mtimeMs: 1,
timestamp: 10,
});
const second = { ...first, size: 200, mtimeMs: 2, timestamp: 20 };
const result = upsertAttachmentHistory(upsertAttachmentHistory([], first), second);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
size: 200,
mtimeMs: 2,
timestamp: 20,
externalPath: '/mnt/c/docs/brief.docx',
});
});
it('dedupes detected workspace attachments by relative path', () => {
const first = buildDetectedAttachmentHistoryItem({
sessionId: 's1',
filePath: 'report.pdf',
relativePath: 'out/report.pdf',
fileName: 'report.pdf',
extension: 'pdf',
attachmentType: 'pdf',
size: 100,
timestamp: 10,
});
const second = { ...first, size: 150, timestamp: 20 };
const result = upsertAttachmentHistory(upsertAttachmentHistory([], first), second);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ relativePath: 'out/report.pdf', size: 150, timestamp: 20 });
});
it('caps history to newest 100 items', () => {
let history: SessionAttachmentHistoryItem[] = [];
for (let i = 0; i < ATTACHMENT_HISTORY_LIMIT + 5; i++) {
history = upsertAttachmentHistory(
history,
buildDetectedAttachmentHistoryItem({
sessionId: 's1',
filePath: `${i}.png`,
relativePath: `out/${i}.png`,
fileName: `${i}.png`,
extension: 'png',
attachmentType: 'image',
size: i,
timestamp: i,
})
);
}
expect(history).toHaveLength(ATTACHMENT_HISTORY_LIMIT);
expect(history[0].fileName).toBe('104.png');
expect(history.at(-1)?.fileName).toBe('5.png');
});
it('includes attachment history in session state', () => {
const session = new Session({ workingDir: '/tmp' });
session.upsertAttachmentHistory({
id: 'detected:file.png',
sessionId: session.id,
fileName: 'file.png',
extension: 'png',
attachmentType: 'image',
size: 12,
mtimeMs: 0,
timestamp: 100,
source: 'detected',
relativePath: 'file.png',
});
expect(session.toState().attachmentHistory).toHaveLength(1);
expect(session.toState().attachmentHistory?.[0].fileName).toBe('file.png');
});
it('sanitizes external attachment paths from public session state', () => {
const session = new Session({ workingDir: '/tmp' });
session.upsertAttachmentHistory(
buildExternalAttachmentHistoryItem({
sessionId: session.id,
externalPath: '/mnt/c/private/board-update.pdf',
fileName: 'board-update.pdf',
extension: 'pdf',
size: 100,
timestamp: 100,
})
);
const publicState = session.toState();
const persistedHistory = session.getAttachmentHistoryForPersist();
expect(JSON.stringify(publicState.attachmentHistory)).not.toContain('/mnt/c/private/board-update.pdf');
expect(publicState.attachmentHistory?.[0].id).not.toContain('/mnt/c/private/board-update.pdf');
expect(persistedHistory?.[0].externalPath).toBe('/mnt/c/private/board-update.pdf');
});
it('round-trips persisted history (incl. externalPath) through constructor restore', () => {
const a = new Session({ workingDir: '/tmp' });
a.upsertAttachmentHistory(
buildExternalAttachmentHistoryItem({
sessionId: a.id,
externalPath: '/mnt/c/docs/deck.docx',
fileName: 'deck.docx',
extension: 'docx',
size: 10,
timestamp: 5,
})
);
const persisted = a.getAttachmentHistoryForPersist();
const b = new Session({ workingDir: '/tmp', attachmentHistory: persisted });
// Private copy keeps externalPath (needed to re-register/serve the file)...
expect(b.getAttachmentHistoryForPersist()?.[0].externalPath).toBe('/mnt/c/docs/deck.docx');
// ...but the public copy stays sanitized after restore.
expect(JSON.stringify(b.toState().attachmentHistory)).not.toContain('/mnt/c/docs/deck.docx');
});
it('ignores malformed saved history items during restore instead of throwing', () => {
const valid: SessionAttachmentHistoryItem = {
id: 'detected:ok.png',
sessionId: 's',
fileName: 'ok.png',
extension: 'png',
attachmentType: 'image',
size: 1,
mtimeMs: 0,
timestamp: 1,
source: 'detected',
relativePath: 'ok.png',
};
// null, a non-object, and an item missing required fields must be skipped —
// historyKey() would otherwise throw inside the constructor and abort recovery.
const malformed = [null, 'nope', { partial: true }, valid] as unknown as SessionAttachmentHistoryItem[];
let session!: Session;
expect(() => {
session = new Session({ workingDir: '/tmp', attachmentHistory: malformed });
}).not.toThrow();
const persisted = session.getAttachmentHistoryForPersist();
expect(persisted).toHaveLength(1);
expect(persisted?.[0].fileName).toBe('ok.png');
});
});
+296
View File
@@ -0,0 +1,296 @@
/**
* Tests for SessionAutoOps auto-resume on usage limit (token pause control).
*
* Uses fake timers; the SessionAutoOps callbacks are plain mocks, so no
* real session, tmux, or ports are involved. Port: N/A
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SessionAutoOps } from '../src/session-auto-ops.js';
import { Session } from '../src/session.js';
const BUFFER_MS = 2 * 60_000; // RESUME_BUFFER_MS in session-auto-ops.ts
const ESC_DELAY_MS = 600; // RESUME_ESC_DELAY_MS
function limitLine(resetInMs: number): string {
// Raw API epoch form gives exact control over the parsed reset time
const epoch = Math.floor((Date.now() + resetInMs) / 1000);
return `Claude AI usage limit reached|${epoch}`;
}
describe('SessionAutoOps auto-resume', () => {
let ops: SessionAutoOps;
let writeCommand: ReturnType<typeof vi.fn>;
let working: boolean;
let stopped: boolean;
beforeEach(() => {
vi.useFakeTimers();
// Whole-second clock: limitLine() floors to epoch seconds, and a fractional
// start time would shift the fire point by up to 999ms (flaky assertions)
vi.setSystemTime(Math.floor(Date.now() / 1000) * 1000);
writeCommand = vi.fn(async () => true);
working = false;
stopped = false;
ops = new SessionAutoOps({
writeCommand,
isWorking: () => working,
isStopped: () => stopped,
getTotalTokens: () => 0,
getSessionId: () => 'test-session',
});
});
afterEach(() => {
ops.destroy();
vi.useRealTimers();
});
it('does nothing when disabled', () => {
ops.processCleanData(limitLine(60 * 60_000));
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
});
it('arms a schedule at reset time + buffer when enabled', () => {
ops.setAutoResume(true);
const scheduled = vi.fn();
ops.on('limitPauseScheduled', scheduled);
ops.processCleanData(limitLine(60 * 60_000)); // resets in 1h
expect(ops.isLimitPaused).toBe(true);
expect(ops.autoResumeAt).not.toBeNull();
const expected = Date.now() + 60 * 60_000 + BUFFER_MS;
expect(Math.abs(ops.autoResumeAt! - expected)).toBeLessThan(2000);
expect(scheduled).toHaveBeenCalledTimes(1);
});
it('dedups repeated footer redraws of the same limit message', () => {
ops.setAutoResume(true);
const scheduled = vi.fn();
ops.on('limitPauseScheduled', scheduled);
const line = limitLine(30 * 60_000);
ops.processCleanData(line);
ops.processCleanData(line);
ops.processCleanData(line);
expect(scheduled).toHaveBeenCalledTimes(1);
});
it('reschedules when an EARLIER reset time appears', () => {
ops.setAutoResume(true);
const scheduled = vi.fn();
ops.on('limitPauseScheduled', scheduled);
ops.processCleanData(limitLine(60 * 60_000));
const firstAt = ops.autoResumeAt!;
ops.processCleanData(limitLine(10 * 60_000));
expect(scheduled).toHaveBeenCalledTimes(2);
expect(ops.autoResumeAt!).toBeLessThan(firstAt);
});
it('keeps the schedule when a LATER/stale time appears', () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
const firstAt = ops.autoResumeAt!;
ops.processCleanData(limitLine(60 * 60_000)); // later → ignored
ops.processCleanData(limitLine(-5 * 60_000)); // overdue → never preempts
expect(ops.autoResumeAt).toBe(firstAt);
});
it('fires Escape then the continue prompt at the scheduled time', async () => {
ops.setAutoResume(true);
const resumed = vi.fn();
ops.on('limitResume', resumed);
ops.processCleanData(limitLine(10 * 60_000));
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + 100);
expect(writeCommand).toHaveBeenCalledWith('\x1b');
expect(resumed).not.toHaveBeenCalled(); // continue still pending
await vi.advanceTimersByTimeAsync(ESC_DELAY_MS + 50);
expect(writeCommand).toHaveBeenCalledWith('continue\r');
expect(resumed).toHaveBeenCalledTimes(1);
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
});
it('ignores stale-footer re-detections while the resume is in flight', async () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + 100);
// Esc sent; before the continue fires, the stale footer redraws
ops.processCleanData(limitLine(-1000));
expect(ops.isLimitPaused).toBe(false); // not re-armed mid-resume
await vi.advanceTimersByTimeAsync(ESC_DELAY_MS + 50);
expect(writeCommand).toHaveBeenCalledWith('continue\r');
});
it('re-arms a retry when still limited after a resume attempt', async () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + ESC_DELAY_MS + 200);
expect(writeCommand).toHaveBeenCalledWith('continue\r');
// The submit echoes a fresh limit line with an already-past reset → retry path
const scheduled = vi.fn();
ops.on('limitPauseScheduled', scheduled);
ops.processCleanData(limitLine(-1000));
expect(scheduled).toHaveBeenCalledTimes(1);
expect(ops.isLimitPaused).toBe(true);
// Retry fires within RESUME_RETRY_MS (5 min)
expect(ops.autoResumeAt! - Date.now()).toBeLessThanOrEqual(5 * 60_000 + 1000);
});
it('skips the resume when Claude is already working at fire time', async () => {
ops.setAutoResume(true);
const cancelled = vi.fn();
ops.on('limitResumeCancelled', cancelled);
ops.processCleanData(limitLine(10 * 60_000));
working = true;
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + ESC_DELAY_MS + 200);
expect(writeCommand).not.toHaveBeenCalled();
expect(cancelled).toHaveBeenCalledWith({ reason: 'working' });
expect(ops.isLimitPaused).toBe(false);
});
it('cancels the pending schedule when Claude starts working', () => {
ops.setAutoResume(true);
const cancelled = vi.fn();
ops.on('limitResumeCancelled', cancelled);
ops.processCleanData(limitLine(10 * 60_000));
expect(ops.isLimitPaused).toBe(true);
ops.notifyWorking();
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
expect(cancelled).toHaveBeenCalledWith({ reason: 'working' });
});
it('notifyWorking is a no-op when nothing is armed', () => {
ops.setAutoResume(true);
const cancelled = vi.fn();
ops.on('limitResumeCancelled', cancelled);
ops.notifyWorking();
expect(cancelled).not.toHaveBeenCalled();
});
it('disabling cancels the pending schedule', () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
ops.setAutoResume(false);
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
// and detection stays off
ops.processCleanData(limitLine(10 * 60_000));
expect(ops.isLimitPaused).toBe(false);
});
it('destroy clears timers without emitting', () => {
ops.setAutoResume(true);
const cancelled = vi.fn();
ops.on('limitResumeCancelled', cancelled);
ops.processCleanData(limitLine(10 * 60_000));
ops.destroy();
expect(ops.isLimitPaused).toBe(false);
expect(cancelled).not.toHaveBeenCalled();
vi.advanceTimersByTime(60 * 60_000);
expect(writeCommand).not.toHaveBeenCalled();
});
it('does not fire after the session stops', async () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
stopped = true;
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + ESC_DELAY_MS + 200);
expect(writeCommand).not.toHaveBeenCalled();
});
describe('Session wiring (terminal output → detection → events)', () => {
it('detects a limit message flowing through the expensive-parser path', () => {
const session = new Session({ workingDir: '/tmp' }); // mode 'claude'
const scheduled = vi.fn();
session.on('limitPauseScheduled', scheduled);
session.setAutoResume(true);
// Same choke-point the claude-mode PTY handler uses (throttled batch)
(session as unknown as { _processExpensiveParsers(d: string): void })._processExpensiveParsers(
'5-hour limit reached ∙ resets 8pm'
);
expect(session.isLimitPaused).toBe(true);
expect(session.autoResumeAt).not.toBeNull();
expect(scheduled).toHaveBeenCalledTimes(1);
expect(session.toState().autoResumeEnabled).toBe(true);
expect(session.toState().autoResumeAt).toBe(session.autoResumeAt!);
session.setAutoResume(false); // clears the armed timer
});
it('catches an already-displayed limit message when enabling mid-pause', () => {
const session = new Session({ workingDir: '/tmp' });
// limit footer already on screen before the user finds the checkbox
(session as unknown as { _terminalBuffer: { append(d: string): void } })._terminalBuffer.append(
'\x1b[33m5-hour limit reached ∙ resets 8pm\x1b[0m'
);
session.setAutoResume(true);
expect(session.isLimitPaused).toBe(true);
session.setAutoResume(false);
});
it('ignores stale scrollback (past reset time) when enabling', () => {
const session = new Session({ workingDir: '/tmp' });
const pastEpoch = Math.floor(Date.now() / 1000) - 3600;
(session as unknown as { _terminalBuffer: { append(d: string): void } })._terminalBuffer.append(
`Claude AI usage limit reached|${pastEpoch}`
);
session.setAutoResume(true);
expect(session.isLimitPaused).toBe(false);
session.setAutoResume(false);
});
it('stays inert when the checkbox is disabled (default)', () => {
const session = new Session({ workingDir: '/tmp' });
(session as unknown as { _processExpensiveParsers(d: string): void })._processExpensiveParsers(
'5-hour limit reached ∙ resets 8pm'
);
expect(session.isLimitPaused).toBe(false);
expect(session.toState().autoResumeEnabled).toBe(false);
});
});
describe('restoreAutoResume (recovery after Codeman restart)', () => {
it('re-arms a future schedule', () => {
ops.restoreAutoResume(true, Date.now() + 30 * 60_000);
expect(ops.autoResumeEnabled).toBe(true);
expect(ops.isLimitPaused).toBe(true);
expect(ops.autoResumeAt! - Date.now()).toBeGreaterThan(29 * 60_000);
});
it('fires an overdue schedule shortly after boot', async () => {
ops.restoreAutoResume(true, Date.now() - 60_000);
expect(ops.isLimitPaused).toBe(true);
await vi.advanceTimersByTimeAsync(5_000 + ESC_DELAY_MS + 200);
expect(writeCommand).toHaveBeenCalledWith('\x1b');
expect(writeCommand).toHaveBeenCalledWith('continue\r');
});
it('enables without arming when no schedule was persisted', () => {
ops.restoreAutoResume(true);
expect(ops.autoResumeEnabled).toBe(true);
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
});
});
});
+64 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
/** Must exceed Session.DESKTOP_CLAIM_IDLE_MS (90s) */
const PAST_IDLE_MS = 91_000;
type ResizeableSessionInternals = {
ptyProcess: { resize: (cols: number, rows: number) => void };
_ptyCols: number;
@@ -109,4 +112,64 @@ describe('Session resize arbitration', () => {
expect(resize).toHaveBeenCalledWith(100, 30);
});
describe('idle-desktop override (whoever is active wins)', () => {
afterEach(() => {
vi.useRealTimers();
});
it('lets a mobile client take the pane once the desktop claim goes idle', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const resize = attachFakePty(session, 160, 48);
session.claimDesktopSizing(Symbol('desktop-conn'));
session.resize(48, 28, { viewportType: 'mobile' });
expect(resize).not.toHaveBeenCalled(); // fresh claim → ignored
vi.advanceTimersByTime(PAST_IDLE_MS);
session.resize(48, 28, { viewportType: 'mobile' });
expect(resize).toHaveBeenCalledWith(48, 28); // idle desktop → applied
});
it('keeps blocking mobile while the desktop stays active via typed input', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const resize = attachFakePty(session, 160, 48);
session.claimDesktopSizing(Symbol('desktop-conn'));
vi.advanceTimersByTime(PAST_IDLE_MS - 10_000);
session.noteDesktopActivity(); // user typed on desktop
vi.advanceTimersByTime(20_000); // idle since claim, but not since input
session.resize(48, 28, { viewportType: 'mobile' });
expect(resize).not.toHaveBeenCalled();
});
it('re-asserts the desktop layout on desktop input after a mobile override', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const resize = attachFakePty(session, 160, 48);
session.resize(208, 45, { viewportType: 'desktop' }); // desktop sizes the pane
session.claimDesktopSizing(Symbol('desktop-conn'));
vi.advanceTimersByTime(PAST_IDLE_MS);
session.resize(48, 28, { viewportType: 'mobile' }); // phone takes over
expect(resize).toHaveBeenLastCalledWith(48, 28);
session.noteDesktopActivity(); // desktop user types again
expect(resize).toHaveBeenLastCalledWith(208, 45); // layout restored
});
it('does not re-assert when no mobile override happened', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const resize = attachFakePty(session, 160, 48);
session.resize(208, 45, { viewportType: 'desktop' });
resize.mockClear();
session.noteDesktopActivity();
expect(resize).not.toHaveBeenCalled();
});
});
});
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
function loadTabOverflowHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } })
.CodemanTabOverflow;
}
describe('tab overflow layout policy', () => {
it('auto-wraps desktop tabs when their rendered width exceeds available tab space', () => {
const helper = loadTabOverflowHelper();
expect(
helper.shouldAutoWrapTabs({
deviceType: 'desktop',
manualTwoRows: false,
tabCount: 18,
scrollWidth: 1400,
clientWidth: 760,
})
).toBe(true);
});
it('does not auto-wrap when manual tall tabs are enabled or on mobile/tablet', () => {
const helper = loadTabOverflowHelper();
expect(
helper.shouldAutoWrapTabs({
deviceType: 'desktop',
manualTwoRows: true,
tabCount: 18,
scrollWidth: 1400,
clientWidth: 760,
})
).toBe(false);
expect(
helper.shouldAutoWrapTabs({
deviceType: 'mobile',
manualTwoRows: false,
tabCount: 18,
scrollWidth: 1400,
clientWidth: 320,
})
).toBe(false);
});
it('respects the boundary conditions (exact fit, +1 tolerance, and tabCount < 2)', () => {
const helper = loadTabOverflowHelper();
const base = { deviceType: 'desktop' as const, manualTwoRows: false, tabCount: 6 };
// Exact fit: no overflow, no wrap.
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 800, clientWidth: 800 })).toBe(false);
// Within the +1 sub-pixel tolerance: still no wrap.
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 801, clientWidth: 800 })).toBe(false);
// 2px over: wrap.
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 802, clientWidth: 800 })).toBe(true);
// A single overflowing tab must not wrap (need at least 2 to form a second row).
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadTerminalUiHarness(mode: string) {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
window: {},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {},
URL: {
createObjectURL: () => 'blob:yield',
revokeObjectURL: () => {},
},
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as any)();
const writes: string[] = [];
app.activeSessionId = 'session-1';
app.sessions = new Map([['session-1', { mode }]]);
app.pendingWrites = [];
app.writeFrameScheduled = false;
app._wasAtBottomBeforeWrite = false;
app._workerYield = () => {};
app._chunkedWriteGen = 0;
app.terminal = {
write: (data: string) => writes.push(data),
scrollToBottom: () => {},
scrollToLine: () => {},
};
return { app, writes };
}
describe('terminal flush budget', () => {
it('uses a smaller first-frame write budget for Codex output to reduce renderer stalls', () => {
const { app, writes } = loadTerminalUiHarness('codex');
app.pendingWrites.push('x'.repeat(96 * 1024));
app.flushPendingWrites();
expect(writes).toHaveLength(1);
expect(writes[0]).toHaveLength(32 * 1024);
expect(app.pendingWrites.join('')).toHaveLength(64 * 1024);
});
it('keeps the larger first-frame write budget for non-Codex terminal output', () => {
const { app, writes } = loadTerminalUiHarness('claude');
app.pendingWrites.push('x'.repeat(96 * 1024));
app.flushPendingWrites();
expect(writes).toHaveLength(1);
expect(writes[0]).toHaveLength(64 * 1024);
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
});
it('waits for xterm to process small buffer replays before completing buffer load', async () => {
const { app, writes } = loadTerminalUiHarness('codex');
let writeDone: (() => void) | undefined;
let resolved = false;
const finishBufferLoad = vi.fn();
app._finishBufferLoad = finishBufferLoad;
app.terminal.write = (data: string, callback?: () => void) => {
writes.push(data);
writeDone = callback;
};
const promise = app.chunkedTerminalWrite('fresh tmux pane frame').then(() => {
resolved = true;
});
await Promise.resolve();
expect(writes).toEqual(['fresh tmux pane frame']);
expect(writeDone).toBeTypeOf('function');
expect(resolved).toBe(false);
expect(finishBufferLoad).not.toHaveBeenCalled();
writeDone?.();
await promise;
expect(resolved).toBe(true);
expect(finishBufferLoad).toHaveBeenCalledOnce();
});
it('keeps stale buffer load owners from finishing a newer load', () => {
const { app } = loadTerminalUiHarness('codex');
app._beginBufferLoad('select-1');
app._beginBufferLoad('select-2');
expect(app._finishBufferLoad('select-1')).toBe(false);
expect(app._isLoadingBuffer).toBe(true);
expect(app._bufferLoadOwner).toBe('select-2');
expect(app._finishBufferLoad('select-2')).toBe(true);
expect(app._isLoadingBuffer).toBe(false);
expect(app._bufferLoadOwner).toBe(null);
});
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
const { app } = loadTerminalUiHarness('codex');
const scrollToBottom = vi.fn();
app.terminal.scrollToBottom = scrollToBottom;
app._wasAtBottomBeforeWrite = true;
app._lastUserScrollUpAt = 0;
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
expect(scrollToBottom).not.toHaveBeenCalled();
});
it('restores the user scroll position when Codex Working redraws move the viewport', () => {
const { app } = loadTerminalUiHarness('codex');
const buffer = { viewportY: 40, baseY: 100 };
app.terminal.buffer = { active: buffer };
app.terminal.write = vi.fn(() => {
buffer.viewportY = buffer.baseY;
});
app.terminal.scrollToLine = vi.fn((line: number) => {
buffer.viewportY = line;
});
app._wasAtBottomBeforeWrite = true;
app._lastUserScrollUpAt = 0;
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
expect(buffer.viewportY).toBe(40);
});
});
+24 -10
View File
@@ -164,10 +164,24 @@ describe('TmuxManager (unit)', () => {
cursorY: 1,
});
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(9)}\x1b[2;1Hnext line\x1b[2;3H`);
// Full pane width is painted (10 cols); autowrap is avoided by the
// absolute cursor positioning, not by dropping the last column.
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(10)}\x1b[2;1Hnext line\x1b[2;3H`);
expect(snapshot).not.toContain('\n');
});
it('preserves the rightmost column of each captured row', () => {
const snapshot = formatPaneSnapshot(['abcd'], {
cols: 4,
rows: 1,
cursorX: 0,
cursorY: 0,
});
// Previously truncated to cols - 1 ('abc'); the full width is now kept.
expect(snapshot).toBe('\x1b[1;1Habcd\x1b[1;1H');
});
it('preserves SGR color while stripping non-style pane controls', () => {
const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], {
cols: 40,
@@ -190,18 +204,18 @@ describe('TmuxManager (unit)', () => {
cursorY: 0,
});
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabc\x1b[0m\x1b[1;1H');
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabcd\x1b[0m\x1b[1;1H');
});
it('does not let full-width glyphs cross the paint boundary', () => {
const snapshot = formatPaneSnapshot(['abc\u754cdef'], {
cols: 5,
rows: 1,
cursorX: 0,
cursorY: 0,
});
expect(snapshot).toBe('\x1b[1;1Habc\x1b[1;1H');
// cols 5 = 'abc' (3) + full-width \u754c (2) fits exactly; with cols 4 the
// wide glyph would straddle the boundary and is dropped.
expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 5, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
'\x1b[1;1Habc\u754c\x1b[1;1H'
);
expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 4, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
'\x1b[1;1Habc\x1b[1;1H'
);
});
it('keeps combining marks attached without consuming a terminal column', () => {
+210
View File
@@ -0,0 +1,210 @@
/**
* Tests for usage-limit pause detection (auto-resume on usage limit).
*
* Message corpus mirrors real Claude Code output observed across
* 1.0.x–2.1.x (GitHub issues + official error docs). Time expectations are
* computed with the same local-Date APIs the implementation uses, so the
* tests are timezone-independent.
*/
import { describe, it, expect } from 'vitest';
import { detectUsageLimitPause } from '../src/usage-limit-patterns.js';
/** Fixed "now" for deterministic tests: a real timestamp, any value works. */
const NOW = new Date(2026, 5, 10, 14, 0, 0).getTime(); // local Jun 10 2026, 2:00pm
/** Expected epoch ms for the next local occurrence of hour:minute after NOW. */
function nextLocal(hour: number, minute = 0, from = NOW): number {
const d = new Date(from);
let ts = new Date(d.getFullYear(), d.getMonth(), d.getDate(), hour, minute).getTime();
if (ts <= from) ts += 24 * 60 * 60 * 1000;
return ts;
}
describe('detectUsageLimitPause', () => {
describe('era 2 footer forms (v1.0.109+, ∙ separator, no timezone)', () => {
it.each([
['5-hour limit reached ∙ resets 8pm', 20, 0],
['Session limit reached ∙ resets 8pm', 20, 0],
['Weekly limit reached ∙ resets 6pm', 18, 0],
['5-hour limit reached ∙ resets 10:30pm', 22, 30],
['5-hour limit reached ∙ resets 3am', 3, 0],
['5-hour limit reached ∙ resets 12am', 0, 0],
['5-hour limit reached ∙ resets 12pm', 12, 0],
])('parses "%s"', (msg, hour, minute) => {
const det = detectUsageLimitPause(msg, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(nextLocal(hour, minute));
});
it('rolls over to tomorrow when the time already passed today', () => {
// NOW is 2:00pm local; "resets 9am" must be tomorrow 9am
const det = detectUsageLimitPause('5-hour limit reached ∙ resets 9am', NOW);
expect(det!.resetAt).toBe(nextLocal(9, 0));
expect(det!.resetAt).toBeGreaterThan(NOW);
});
});
describe('era 3 forms (v2.0.55+, · separator, IANA timezone, action hints)', () => {
it('parses with timezone and /upgrade suffix', () => {
const det = detectUsageLimitPause(
'5-hour limit reached · resets 3pm (Europe/Stockholm) · /upgrade to Max 20x or turn on /extra-usage',
NOW
);
expect(det).not.toBeNull();
// 3pm in Stockholm (UTC+2 in June) = 13:00 UTC
const d = new Date(det!.resetAt);
expect(d.getUTCHours()).toBe(13);
expect(det!.resetAt).toBeGreaterThan(NOW);
});
it('parses generic "Limit reached" with minutes and timezone', () => {
const det = detectUsageLimitPause(
'Limit reached · resets 11:30am (Asia/Calcutta) · /upgrade to Max or turn on /extra-usage',
NOW
);
expect(det).not.toBeNull();
// 11:30am IST (UTC+5:30) = 06:00 UTC
const d = new Date(det!.resetAt);
expect(d.getUTCHours()).toBe(6);
expect(d.getUTCMinutes()).toBe(0);
});
it('falls back to local time for unresolvable timezone (Etc/Unknown)', () => {
const det = detectUsageLimitPause('Limit reached · resets 5pm (Etc/Unknown)', NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(nextLocal(17, 0));
});
it('parses weekly date form "Dec 2, 7pm"', () => {
const det = detectUsageLimitPause('Weekly limit reached · resets Dec 2, 7pm (Europe/Moscow) ·', NOW);
// Dec 2 is >8 days from Jun 10 → implausible horizon → rejected
expect(det).toBeNull();
});
it('parses near-future date form within the horizon', () => {
const det = detectUsageLimitPause('Weekly limit reached ∙ resets Jun 15, 1pm', NOW);
expect(det).not.toBeNull();
const expected = new Date(2026, 5, 15, 13, 0).getTime();
expect(det!.resetAt).toBe(expected);
});
it('parses malformed separator-less render', () => {
const det = detectUsageLimitPause('Limit reached resets 1:30pm (Asia/Calcutta) ·', NOW);
expect(det).not.toBeNull();
});
});
describe('era 4 forms (v2.1.x, "You\'ve hit your limit")', () => {
it.each([
["You've hit your limit · resets 1:40pm (UTC)", 13, 40],
["You've hit your session limit · resets 3:45pm", 15, 45],
["You've hit your Opus limit · resets 3:45pm", 15, 45],
])('parses "%s"', (msg) => {
const det = detectUsageLimitPause(msg, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBeGreaterThan(NOW);
});
it('parses day-of-week form "resets Mon 12:00am"', () => {
const det = detectUsageLimitPause("You've hit your weekly limit · resets Mon 12:00am", NOW);
expect(det).not.toBeNull();
const d = new Date(det!.resetAt);
expect(d.getDay()).toBe(1); // Monday
expect(d.getHours()).toBe(0);
expect(det!.resetAt).toBeGreaterThan(NOW);
expect(det!.resetAt).toBeLessThanOrEqual(NOW + 8 * 24 * 60 * 60 * 1000);
});
it('parses "May 5 at 9pm" date-with-at form (next-year rollover)', () => {
// NOW is Jun 10 2026 — "May 5" is >2 days past → next year → beyond horizon → null
const det = detectUsageLimitPause("You've hit your limit · resets May 5 at 9pm (UTC)", NOW);
expect(det).toBeNull();
});
it('parses "Jun 12 at 9pm" date-with-at form within horizon', () => {
const det = detectUsageLimitPause("You've hit your limit · resets Jun 12 at 9pm", NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(new Date(2026, 5, 12, 21, 0).getTime());
});
it('parses extra-usage form "You\'re out of extra usage"', () => {
const det = detectUsageLimitPause("You're out of extra usage · resets 1pm (UTC)", NOW);
expect(det).not.toBeNull();
const d = new Date(det!.resetAt);
expect(d.getUTCHours()).toBe(13);
});
});
describe('era 1 inline form (v1.0.x)', () => {
it('parses "Your limit will reset at 2pm (America/New_York)"', () => {
const det = detectUsageLimitPause(
'Claude usage limit reached. Your limit will reset at 2pm (America/New_York)',
NOW
);
expect(det).not.toBeNull();
// 2pm EDT (UTC-4 in June) = 18:00 UTC
expect(new Date(det!.resetAt).getUTCHours()).toBe(18);
});
});
describe('raw API epoch form', () => {
it('parses "Claude AI usage limit reached|<epoch>"', () => {
const epoch = Math.floor(NOW / 1000) + 3600; // resets in 1h
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(epoch * 1000);
});
it('returns past epoch as-is (stale message → caller retries soon)', () => {
const epoch = Math.floor(NOW / 1000) - 600;
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBeLessThan(NOW);
});
});
describe('transcript and multi-line contexts', () => {
it('detects the ⎿-prefixed transcript echo', () => {
const det = detectUsageLimitPause(
"⎿ You've hit your limit · resets 6pm (Asia/Bangkok)\n /upgrade to increase your usage limit.",
NOW
);
expect(det).not.toBeNull();
});
it('returns the LAST parseable occurrence in a chunk', () => {
const chunk = [
'5-hour limit reached ∙ resets 3pm',
'some scrollback text',
'5-hour limit reached ∙ resets 5pm',
].join('\n');
const det = detectUsageLimitPause(chunk, NOW);
expect(det!.resetAt).toBe(nextLocal(17, 0));
});
});
describe('false-positive guards', () => {
it.each([
'the rate limit reached in tests was 30/min',
'limit reached',
'Usage limit reached', // bare banner: no reset time → not actionable
'we reset at dawn',
'resets 3pm', // time without a limit phrase
'The speed limit reached 120 km/h before it resets',
'5-hour limit reached ∙ resets 25pm', // invalid hour
'limit reached ∙ resets 99:99pm',
])('ignores "%s"', (msg) => {
expect(detectUsageLimitPause(msg, NOW)).toBeNull();
});
it('ignores empty and irrelevant data', () => {
expect(detectUsageLimitPause('', NOW)).toBeNull();
expect(detectUsageLimitPause('compiling project...', NOW)).toBeNull();
});
it('requires the reset time within the window after the phrase', () => {
const farApart = 'limit reached' + ' x'.repeat(200) + ' resets 3pm';
expect(detectUsageLimitPause(farApart, NOW)).toBeNull();
});
});
});
+162
View File
@@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest';
import {
parseStatusTelemetry,
parseSessionStatus,
formatSessionStatusText,
telemetrySignature,
type RawStatuslinePayload,
} from '../src/usage-telemetry.js';
// Mirrors the real captured statusline payload (CC 2.1.177, Claude Max) — see
// docs/usage-limits-display-plan.md. resets_at is epoch SECONDS.
const REAL: RawStatuslinePayload = {
rate_limits: {
five_hour: { used_percentage: 15, resets_at: 1781409000 },
seven_day: { used_percentage: 34, resets_at: 1781827200 },
},
context_window: { used_percentage: 56, total_input_tokens: 562411, total_output_tokens: 1188 },
cost: { total_cost_usd: 0.0415495 },
model: { display_name: 'Opus 4.8 (1M context)' },
};
describe('parseStatusTelemetry', () => {
it('normalizes the real payload, converting resets_at seconds → ms', () => {
const t = parseStatusTelemetry(REAL);
expect(t).not.toBeNull();
expect(t!.fiveHour).toEqual({ usedPercentage: 15, resetAt: 1781409000 * 1000 });
expect(t!.sevenDay).toEqual({ usedPercentage: 34, resetAt: 1781827200 * 1000 });
expect(t!.contextUsedPercentage).toBe(56);
expect(t!.costUsd).toBeCloseTo(0.0415495);
expect(t!.modelDisplayName).toBe('Opus 4.8 (1M context)');
});
it('returns null when there is no rate_limits (pre-first-response / non-subscriber)', () => {
expect(parseStatusTelemetry({})).toBeNull();
expect(parseStatusTelemetry(undefined)).toBeNull();
expect(parseStatusTelemetry({ context_window: { used_percentage: 5 } })).toBeNull();
expect(parseStatusTelemetry({ rate_limits: {} })).toBeNull();
});
it('accepts a single window when only one is present', () => {
const t = parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 50, resets_at: 1781409000 } } });
expect(t!.fiveHour?.usedPercentage).toBe(50);
expect(t!.sevenDay).toBeUndefined();
});
it('drops a window with a missing or non-numeric field', () => {
const t = parseStatusTelemetry({
rate_limits: {
five_hour: { used_percentage: 20 }, // no resets_at → dropped
seven_day: { used_percentage: 40, resets_at: 1781827200 },
},
});
expect(t!.fiveHour).toBeUndefined();
expect(t!.sevenDay?.usedPercentage).toBe(40);
});
it('clamps percentages to 0–100', () => {
const t = parseStatusTelemetry({
rate_limits: {
five_hour: { used_percentage: 150, resets_at: 1781409000 },
seven_day: { used_percentage: -5, resets_at: 1781827200 },
},
});
expect(t!.fiveHour?.usedPercentage).toBe(100);
expect(t!.sevenDay?.usedPercentage).toBe(0);
});
it('ignores a zero/negative reset timestamp', () => {
expect(parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 10, resets_at: 0 } } })).toBeNull();
});
it('keeps a NaN percentage as 0 and drops a window with a non-finite reset', () => {
const t = parseStatusTelemetry({
rate_limits: {
five_hour: { used_percentage: NaN, resets_at: 1781409000 },
seven_day: { used_percentage: 40, resets_at: Infinity },
},
});
expect(t!.fiveHour).toEqual({ usedPercentage: 0, resetAt: 1781409000 * 1000 });
expect(t!.sevenDay).toBeUndefined();
});
it('rounds a fractional resets_at to whole milliseconds', () => {
const t = parseStatusTelemetry({
rate_limits: { five_hour: { used_percentage: 10, resets_at: 1781409000.7 } },
});
expect(t!.fiveHour?.resetAt).toBe(Math.round(1781409000.7 * 1000));
});
});
describe('parseSessionStatus', () => {
it('extracts model, token totals, and context % for the footer', () => {
const s = parseSessionStatus(REAL);
expect(s).toEqual({
modelDisplayName: 'Opus 4.8 (1M context)',
inputTokens: 562411,
outputTokens: 1188,
contextUsedPercentage: 56,
});
});
it('returns null when none of the footer fields are present', () => {
expect(parseSessionStatus({})).toBeNull();
expect(parseSessionStatus(undefined)).toBeNull();
// rate_limits alone is not session status
expect(parseSessionStatus({ rate_limits: { five_hour: { used_percentage: 5, resets_at: 1 } } })).toBeNull();
});
});
describe('formatSessionStatusText', () => {
it('formats the footer with comma-grouped tokens', () => {
expect(formatSessionStatusText(parseSessionStatus(REAL))).toBe(
'Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%'
);
});
it('omits groups that are missing', () => {
expect(formatSessionStatusText({ contextUsedPercentage: 12 })).toBe('ctx:12%');
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
});
it('falls back to a brand string when there is no data', () => {
expect(formatSessionStatusText(null)).toBe('codeman');
});
});
describe('telemetrySignature', () => {
it('is stable for equal telemetry and changes when a percentage moves', () => {
const a = parseStatusTelemetry(REAL)!;
const b = parseStatusTelemetry(REAL)!;
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
const moved = parseStatusTelemetry({
...REAL,
rate_limits: { ...REAL.rate_limits, five_hour: { used_percentage: 16, resets_at: 1781409000 } },
})!;
expect(telemetrySignature(moved)).not.toBe(telemetrySignature(a));
});
it('ignores contextUsedPercentage (not displayed) so it does not churn each message', () => {
const base = { rate_limits: { five_hour: { used_percentage: 15, resets_at: 1781409000 } } };
const a = parseStatusTelemetry({ ...base, context_window: { used_percentage: 56 } })!;
const b = parseStatusTelemetry({ ...base, context_window: { used_percentage: 91 } })!;
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
});
it('keys on the ROUNDED window percentage (matches the chip) — sub-integer drift is ignored', () => {
const sig = (p: number) =>
telemetrySignature(
parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: p, resets_at: 1781409000 } } })!
);
expect(sig(15.1)).toBe(sig(15.4)); // both render as 15%
expect(sig(15.1)).not.toBe(sig(15.6)); // 15% vs 16%
});
it('excludes cost/model (not shown in the chip) from the signature', () => {
const base = { rate_limits: { five_hour: { used_percentage: 15, resets_at: 1781409000 } } };
const a = parseStatusTelemetry({ ...base, cost: { total_cost_usd: 0.01 }, model: { display_name: 'A' } })!;
const b = parseStatusTelemetry({ ...base, cost: { total_cost_usd: 9.99 }, model: { display_name: 'B' } })!;
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
});
});