Compare commits

..
Author SHA1 Message Date
Codeman maintainer ac94f339ac chore: version packages (1.35.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:01:26 +02:00
Codeman maintainer 88f5a43a9f fix(cases): bounded path probe landing fixes (#516)
- hooks-config: a probe the bulk cap refused gets ONE bounded re-probe past the
  cap (probeBeforeTouching), and whatever is still unknown is skipped. The
  per-spawn hook and statusLine helpers used to fall back to an unbounded
  lstat/readFile there, which on a dead workspace never settled and could take
  the last threadpool workers (and hang the boot hook sweep). New test: cap
  engaged, stat/lstat/readFile hanging on two more paths; both helpers return.
- describeUnknownPath()/unknownPathReason(): POST /api/sessions, quick-start and
  GET /api/cases/:name now say a folder was not checked (other mounts are still
  not answering) instead of blaming a healthy folder at the stall ceiling.
  errorCodes unchanged.
- #535 x #516: Create in a custom folder probes the parent through the bounded
  probe before realpath/stat/lstat/readdir touch it; an unknown parent is 422
  OPERATION_FAILED (UNREACHABLE) within the probe timeout. New test.
- Docs: MAX_STALLED default is 2 (follows UV_THREADPOOL_SIZE), CaseInfo
  .unreachable covers a refused probe, the boot sweep skips an unanswering
  workspace, a CLAUDE.md gotcha for bounded probes, verbs.md documents the 422
  (plugin mirror synced), api-reference documents the custom-folder 422.
- Tests: the launcher case-lookup describe is no longer nested in the Grok
  block, and the cap-below-ceiling test no longer depends on an inherited
  UV_THREADPOOL_SIZE / CODEMAN_PATH_PROBE_MAX_STALLED.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 20:00:30 +02:00
Codeman maintainer aca23aa404 chore: changeset wording, the in-flight rename repaint is #526's own 2026-10-05 19:53:10 +02:00
Codeman maintainer f16f294576 chore: add #516 to the landing changeset 2026-10-05 19:53:03 +02:00
Codeman maintainer 737a2527d6 fix(tabs): report an edit dropped behind an in-flight save, respect the group cap (#525 landing)
- createEditCoordinator's finally block rebases the edits queued during a write; one that the write's 409 made inapplicable was dropped with no toast. It is now reported once, like the main loop and adoptExternal do (found by the PR bot's re-review; regression test fails without it).
- At the 32-group server cap the row and group menus no longer offer a new group, which could only fail with an untranslated 'group limit reached'. MAX_GROUPS is exported from tab-layout-browser.js.
- CLAUDE.md names the pagehide keepalive as the one deliberate exception to 'never PUT the layout outside the coordinator'.
- The Dashboard wiki page describes tab groups in the vertical rail row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer 6f88e40b77 chore: changeset for the #525, #526, #534, #535, #536, #537 landing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer 2c38e77f8a fix(git-status): landing fixes (#537)
- A cached list of repositories below a folder is re-checked against the
  Docker case workspaces as they are now, so a repository linked as a Docker
  workspace within the 30 s list cache is no longer inspected.
- A diff past runGit's 8 MB output bound is cut short from git's partial
  output instead of failing with a 500.
- The browser test waits for its slow route handler on unroute
  (unrouteAll behavior 'wait'), so a late route.continue() cannot fail the run.
- "Upstream is gone" now reads "Upstream not on remote", true for a branch
  that was never pushed as well as one deleted on the remote; docs mirrored.
- The diff route checks the repository against the workspace's own cached
  repository list (findWorkspaceRepo) and refreshes only that repository,
  instead of a fresh status of every repository in the folder.
- CLAUDE.md: a Key Patterns entry for the git read surface and its rules.
- The enclosing repository is identified with one cached rev-parse before
  any full status, so an unrelated repository above the workspace costs one
  process and its failure no longer hides the repositories below.
- Wiki: the bottom-bar indicator moves out of the header-controls table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer cf26853390 fix(doctor): Diagnostics landing fixes (#536)
- The doctor now judges candidates like the run mode's resolver: the PATH
  hit, then each search dir, each one version-checked on its own and
  skipped on a mismatch (a wrong `pi`/`grok` on the PATH no longer hides
  the real one in a search dir). A search-dir candidate must be an
  absolute path to an executable regular file, so a relative dir or a
  file without the x bit reads as missing, as it does in the Run menu.
  `isExecutableRegularFile` is exported from cli-executable-resolver.ts
  and reused rather than copied.
- Every doctor probe passes killSignal: 'SIGKILL'; a --version that
  ignores SIGTERM held the probe for its full runtime (15 s vs 5 s
  measured with a TERM-trapping script).
- README no longer claims parity with the Run menu or nvm prefixes.
- The Diagnostics panel marks a missing optional tool with ○, a missing
  required one with ✗, as the terminal doctor does.
- expandSearchDir names its twin, expandHome() in cli-resolver.ts.
- test/doctor-cli-json.test.ts is hermetic: temp HOME, a PATH of only
  `which` and `node`, and a clis.json that drops the registry's absolute
  search dirs, so it never runs the machine's installed agent CLIs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer 192a5994e0 fix(cases): custom-folder create landing fixes (#535)
- Route test hygiene: each test works in its own mkdtemp folder, every
  deletion goes through safeRmHomeTree, and the suite refuses to start
  outside test/setup.ts's temp HOME, so a raw `npx vitest` can no longer
  delete a real ~/projects or the live linked-cases registry.
- Path policy: the symlink-resolved target is also judged against the
  resolved home, data dir and system roots (home reached through a link,
  macOS /etc -> /private/etc); test expectations are realpath-safe.
- Refuse a target equal to or inside the caller's or the shared cases
  directory, pointing at plain Create New (it would list twice, and
  deleting the local copy removes files).
- The registry re-read comment no longer claims to prevent the
  lost-update race; documented as narrowing it, like /api/cases/link.
- UI: the success toast names the folder the server created, the
  "under ~/codeman-cases" blurb and name hint change while a custom
  folder is ticked, a "/" parent previews and sends /<name> instead of
  an empty path, and the new labels have zh-CN entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer 566365e127 test(tabs): static CI guard that no rail or sidebar clamp out-ranks the rename unclamp (#534 landing)
The behavioural check for the detailed-rail rename clamp (#534, #526) lives in test/inline-rename.test.ts, a browser suite the CI gate does not run. This pins the cascade from styles.css itself, from computed selector specificity and source order, so a later clamp rule cannot silently out-rank the shared unclamp again. Mutation-checked: deleting the detailed-rail twin fails exactly that case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer ff94637718 Merge pull request #516 from aakhter/pr/bounded-path-probe
fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server

# Conflicts:
#	src/web/routes/case-routes.ts
2026-10-05 19:51:43 +02:00
Codeman maintainer 2063d15c20 Merge pull request #537 from opticon454/feat/git-status-indicator
feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work

# Conflicts:
#	config/test-suites.ts
#	docs/api-reference.md
2026-10-05 19:51:43 +02:00
Codeman maintainer fed3a0897a Merge pull request #536 from opticon454/feat/doctor-in-settings
feat(settings): codeman doctor in Settings → System → Diagnostics

# Conflicts:
#	config/test-suites.ts
2026-10-05 19:51:42 +02:00
Codeman maintainer d9c760609f Merge pull request #535 from opticon454/feat/case-custom-path
feat(cases): create a new case in a custom folder
2026-10-05 19:51:41 +02:00
Codeman maintainer 74e8015783 Merge pull request #534 from opticon454/fix/rail-rename-unclamp
fix(rail): keep the inline rename editor unclamped in the detailed tab rail

# Conflicts:
#	src/web/public/styles.css
#	test/inline-rename.test.ts
2026-10-05 19:51:41 +02:00
Codeman maintainer fea5626efc Merge pull request #526 from aakhter/pr/grouped-rail-rename-fixes
fix(tabs): grouped rail interaction fixes for inline rename
2026-10-05 19:51:40 +02:00
Codeman maintainer bd109d3b16 Merge pull request #525 from aakhter/pr/grouped-rail-edit
feat(tabs): edit groups in the vertical rail
2026-10-05 19:51:40 +02:00
Aamer Akhter 9fa44109b8 fix(cases): keep deleted workspaces deleted, cap pastCap, scope stalls to network mounts
- applyWorkspaceHooks: an "unknown" probe that is not near a stalled path
  (refused by the stall cap, or an unexpected stat error) no longer reads as
  "go ahead". It checks existence with pathExistsForWrite first, so a deleted
  workspace is not recreated by the mkdir -p in ensureCodemanHooks.
- pastCap gets a hard ceiling, PATH_PROBE_STALL_CEILING = UV_THREADPOOL_SIZE
  (default 4) minus one, so explicit requests against several dead paths can
  never take the last libuv worker. The bulk cap now defaults to one below the
  ceiling (2 with the default pool), leaving a slot for an explicit request.
- A stall widens to its mount only for network and FUSE filesystem types read
  from /proc/self/mounts; on a local mount (a path typed under a local /home
  that reaches a NAS through a symlink) it narrows to the stalled path.
- GET /api/cases/:name probes CLAUDE.md with pastCap, like the folder probe.
- Comment in config/path-probe.ts describes the mount-scoped stall.
2026-10-05 09:51:27 -04:00
Aamer Akhter 3e768e1b5b fix(tabs): show the in-flight name when an unchanged rename is confirmed 2026-10-05 09:47:08 -04:00
Aamer Akhter 92f51fa619 fix(tabs): inline rename review fixes
Reopening the editor over a rename still in flight filled it from the name
the server had not replaced yet, so dismissing it (blur commits) queued the
old name behind the new one and undid the rename. The queue now records the
newest queued name per session (_inlineRenamePending, cleared with the queue
entry), and a reopened editor takes its prefix, input and "unchanged"
comparison from it. An untouched confirm sends nothing more.

A failed write only toasted while its editor was still current. The queue
reports the failure itself now, and the editor only puts its label back.

One rejected task blocked every later rename of that session until reload.
Each task now chains from a settled predecessor, the local apply after a
successful PUT is guarded, and the queue entry is cleaned up on either
outcome.

The rail and sidebar editor's 4rem floor moves from a stylesheet
`!important` into the inline min-width startInlineRename already writes per
layout (0 in the header strip, 4rem in the rail and sidebar).

Tests: the reopened-editor case now expects only "First" to be sent; new
cases cover a 500 answered after the editor is gone and a throw in
updateSubagentParentNames; the long-prefix check runs in the sidebar and
detailed sidebar too and asserts the inline floor; the header strip editor
keeps min-width 0.
2026-10-05 09:45:24 -04:00
Aamer Akhter 06aba94ef1 fix(tabs): grouped rail interaction fixes for inline rename
Two problems with renaming a tab in the vertical rail, both easier to hit now
that the grouped rail has its own inline editor beside the session one.

Writes. A committed rename PUT its name and only applied the answer if the
same editor was still open when it came back. Reopening the editor before the
PUT answered (F2 or right-click again, or starting a group rename, which
cancels the session editor) threw the confirmed name away, so the tab kept
showing the old name until an SSE frame happened to repaint it. Two quick
renames also raced as two concurrent PUTs. Inline renames now go through a
per-session queue: one PUT at a time in the order they were made, the
confirmed name applied to app.sessions whatever happened to the editor, and
the "already that name" check made when the write runs rather than when Enter
is pressed, so confirming the name still on screen over a write in flight is
a real write.

Layout. The editor (a flex row) could not shrink below the input's intrinsic
width, so a long w<n>-<case> prefix pushed the label past its row: the prefix
slid out of view in the detailed rows and the input was clipped mid-word in
the compact rail. The label now has min-width 0, the prefix gives way first
(down to 2rem, with an ellipsis), the input keeps 4rem, and in the compact
rail the row's adornments step aside while the name is edited. The detailed
rows' three-line clamp also outranked the shared unclamp rule, which is what
the existing "unclamped editor" browser test caught; it is restated there.

Header strip, sidebar and flat-rail markup are unchanged.

Tests (test/inline-rename.test.ts, browser suite): the unclamp check runs for
simple and detailed rows; a write-ordering describe covers ordering, a
reopened editor cancelled over a confirmed write, a re-sent unchanged name and
a group rename taking over; a long-prefix describe drives real rows from a
live session in simple, detailed and compact rails.
2026-10-05 09:45:24 -04:00
Aamer Akhter ea80c5f471 docs(tabs): correct the tab-layout constructor comment 2026-10-05 09:45:06 -04:00
DevvynandClaude Sonnet 5.5 0c4bb5169f fix(git-status): address #537 review (docker workspaces, gone upstream, in-flight reset, docs)
- never inspect a repository at or inside a Docker case workspace (walk-up, scan, diff route): git would run its clean filters on the host
- a branch whose upstream was deleted and pruned reports upstreamGone and falls back to commits on no remote, instead of green
- turning the setting off during a poll releases the in-flight flag
- log.showSignature=false; reword the docs: clean filters still run
- CLAUDE.md frontend load order, changeset names git-diff
- discovery reads a bounded, sorted directory listing; leading-dash paths allowed; diff 500 redacts credentials
- keyboard focus survives the poll re-render; panel stays on screen on narrow viewports; aria-expanded visible on light skins

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 11:49:38 +08:00
DevvynandClaude Sonnet 5.5 b2423c90ce test(git-status): real-git rename and conflict diffs, tree setting round-trip
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:57:30 +08:00
DevvynandClaude Sonnet 5.5 4152ee1015 test(doctor): minimal-PATH searchDirs regression and the non-admin gate
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:56:10 +08:00
DevvynandClaude Sonnet 5.5 90dfa328a8 docs(cases): README entry for creating a case in a custom folder
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:50:47 +08:00
DevvynandClaude Sonnet 5.5 294ce0a667 docs(doctor): README entry for Settings → System → Diagnostics
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:50:24 +08:00
DevvynandClaude Sonnet 5.5 58b52fceff docs(git-status): document the diff view, folder grouping and collapsed repositories
README, Working With Files (new Git changes section), Settings Reference, The Dashboard and the changeset.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:49:58 +08:00
DevvynandClaude Sonnet 5.5 4fc75d494f feat(git-status): repositories start collapsed when several are listed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:46:35 +08:00
DevvynandClaude Sonnet 5.5 948c7c54dd feat(git-status): group changed files under collapsible folders (setting, default on)
The Git window shows each group's files under their folders, collapsed until clicked, with single-child folder chains merged and open folders surviving the refresh. App Settings → Bottom bar → 'Git status: group files by folder' (per device) switches back to the flat list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:41:03 +08:00
DevvynandClaude Sonnet 5.5 cd9218c23e feat(git-status): click a file in the Git panel to see its diff
Rows open an in-panel diff (staged, not staged, untracked as additions, deleted as removals) via GET /api/sessions/:id/git-diff, with Back and Open file. The route matches repo and path against the current status, runs git diff read-only (--no-ext-diff --no-textconv), and caps output at 400 KB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:14:07 +08:00
DevvynandClaude Sonnet 5.5 db9a39405b fix(doctor): resolve CLIs via searchDirs, single-flight runs, admin-gate the group (#536 review)
- doctor probes each CLI's discovery.searchDirs when which misses and runs --version on the resolved path, so a service with a minimal PATH no longer reports installed CLIs as missing
- GET /api/doctor shares one in-flight run per category
- Diagnostics group hidden from non-admins in multi-user mode (_applyDoctorAdminGate)
- 500 uses INTERNAL_ERROR; a killed child reports 'timed out after 30 s'
- browser test blocks service workers so page.route() is reliable
- wiki: Diagnostics sentence

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:11:10 +08:00
Aamer Akhter d1bfbb4fcf fix(cases): tell an unreachable path from an absent one, scope the stall cap
The bounded path probe answered "absent" both when a path did not exist and
when it simply did not answer, so a stalled linked case 404'd and the Run
button scaffolded a stray local case over it, and two stalled paths anywhere
made every unrelated path read as absent (hooks skipped, statusLine
overridden, the clone warning lost).

- probePath()/probePathKind() are tri-state: present (or directory/file),
  absent (ENOENT/ENOTDIR only) and unknown (timeout, other errors, refusal).
  boundedPathExists() stays as the display-only boolean.
- A stalled path takes only its own mount out of probing (deepest mount
  point from /proc/self/mounts, never /; just the path itself when there is
  no mount table). Unrelated paths keep probing. The process-wide cap is a
  backstop that answers unknown, and a single-path user request can probe
  past it ({ pastCap: true }), still bounded and still recorded as stalled.
  One console.warn when a path first stalls and one when the cap engages.
- GET /api/cases/:name keeps NOT_FOUND for definite absence only. An
  unreachable linked case answers with its registered path and
  unreachable: true; a local one answers OPERATION_FAILED. runClaude and
  runShell create a case only on errorCode NOT_FOUND. The case list keeps an
  unreachable linked case, marked unreachable, instead of dropping it, and
  fix-plan reports an unreadable plan as an error, not "no plan".
- applyWorkspaceHooks and the statusLine helpers skip only a workspace that
  is absent or on the stalled mount; a capacity refusal no longer stops
  hooks being installed elsewhere, and an unreadable settings file never
  lets the exporter override a user's own statusLine.
- The clone flow's repo-settings warning is back on its synchronous check,
  and stripCaseEnvKeys uses pathExistsForWrite.
- POST /api/sessions (workingDir) and POST /api/quick-start (case folder)
  probe with the bounded probe instead of statSync/existsSync. Missing and
  non-directory keep INVALID_INPUT; unknown is OPERATION_FAILED, and
  quick-start never scaffolds over a folder that did not answer.
- PATH_PROBE_TIMEOUT_MS and MAX_STALLED_PATH_PROBES move to
  src/config/path-probe.ts, overridable via CODEMAN_PATH_PROBE_TIMEOUT_MS
  (default 1500) and CODEMAN_PATH_PROBE_MAX_STALLED (default 3), and are
  documented in the Settings Reference.
- The probe is exported from the utils barrel and imported from there.
2026-10-04 20:30:40 -04:00
Aamer Akhter bd4a1e9886 fix(tabs): grouped rail editing review fixes
- Pointer drag: a press released outside the rail no longer lingers. The
  release is heard on window while a press is pending, a move with the
  primary button up cancels it, a new press cancels any previous drag, and
  an existing Escape listener is removed before another is added, so no
  orphaned capture listener can swallow Escape before the terminal.
- Inline group rename: a commit by blur leaves focus where the user put it;
  Enter and Escape still return focus to the header.
- A failed layout read while edits are pending keeps the held layout and the
  editor and re-reads once the write settles, so a 409 is still rebased.
  Dropping unsaved work now always says so in a toast.
- "Move to <group>" quotes the group name (with a matching zh-CN pattern), so
  a group named "New group" or "ungrouped" no longer reads or translates like
  the fixed entries.
- The group menu glyph stays visible under (hover: none).
- The sessionStorage replay copy carries { owner, baseVersion, savedAt } and is
  ignored for another owner, after 60 s, or against an older layout. A move
  with no anchor carries no index, so a replay keeps the row last.
- A 400 that survives the re-read is reported as "Could not save tab groups."
- closeTabRailActionMenu() no longer removes the group menu's DOM.
- Cancelling "Delete group" returns focus to the header.
- Stale comments updated.
2026-10-04 20:21:44 -04:00
Aamer Akhter 97cb5b5799 feat(tabs): edit groups in the vertical rail
The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.

Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
  reorderGroup, moveRef) applied to the rail at once, mirroring the server
  model: a moved session takes the sessions that still follow it, and a
  hand-moved child is marked placement 'manual'. normalizeLayout now keeps
  placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
  made in the same turn share a write; edits made while one is in flight go
  out on the version it returns. A 409 replays the operations onto the
  layout the server returned and retries (bounded); an operation that no
  longer applies is dropped and reported. A 400 re-reads first; any other
  failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
  that changes nothing.

Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
  Ungrouped and Move to new group in the vertical rail. Before the first
  group exists it offers only "Move to new group", which is how a flat rail
  becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
  a hover glyph (a non-focusable aria-hidden span, so the treeitem still
  holds no interactive child): Rename, New group, Move group up/down,
  Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
  plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
  back to its row or header), a pointer outside, Tab, focus leaving it, a
  resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
  the current editor releases the render guard. Enter or blur commits,
  Escape cancels, IME composition keys are left to the IME, and the label
  becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
  row or into a group, a header drag reorders groups. Escape cancels; the
  click that ends a drag neither selects nor toggles. The flat rail and the
  header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
  once it settles; a read otherwise rebases unsaved edits. On pagehide,
  unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
  replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.

Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.

Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
2026-10-04 20:10:32 -04:00
Saqeb Akhter 00b935abe6 fix(cases): bound linked-workspace path probes so an unreachable mount cannot freeze the server
A linked case can live on a network mount. When that mount goes away, a
hard mount makes stat() wait indefinitely, and the existsSync() probes in
the case routes and the workspace hook/statusline helpers ran on the event
loop, so a single GET /api/cases (or a session create in that workspace)
froze the whole web server until the mount came back.

Add boundedPathExists() (src/utils/bounded-path-probe.ts): an async stat
that answers "absent" after 1.5 s, shares one in-flight probe per path,
remembers a timed-out path until its stat finally settles, and refuses to
start new probes while two stalled ones still hold libuv threadpool
workers. Route the read-side probes in case-routes.ts and hooks-config.ts
through it. The settings writers in hooks-config.ts use an async lstat
that treats only ENOENT as missing, so an unreachable workspace is never
mistaken for an empty one and has its settings recreated.
2026-10-04 20:08:05 -04:00
DevvynandClaude Sonnet 5.5 bfc164a262 feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work
Optional and per-device (showGitStatus, default off). GET /api/sessions/:id/git-status is
read-only and offline (no fetch, --no-optional-locks), skips remote and Docker sessions, caps its
lists, and single-flights concurrent polls. The toolbar indicator shows uncommitted files,
commits not pushed, or a check; clicking opens a draggable panel in the style of the Files window.

Which repositories: the enclosing one when there is one; otherwise every repository up to two
levels below the working directory (capped, skipping dot-folders and node_modules, never
following symlinks), each in a collapsible section, with the indicator summing them. A repository
that merely sits above the workspace and is the home folder or higher (a dotfiles repo) is
ignored. Git-supplied text is only ever written with textContent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 07:19:11 +08:00
DevvynandClaude Sonnet 5.5 1b89d7a387 docs(doctor): api-reference and changeset
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:03:55 +08:00
DevvynandClaude Sonnet 5.5 d9174a7a03 feat(settings): codeman doctor in Settings -> System -> Diagnostics
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:03:40 +08:00
DevvynandClaude Sonnet 5.5 4150707a6b feat(cases): create a new case in a custom folder
POST /api/cases takes an optional path; Add Case > Create New gets a 'Create in a
custom folder' option with Browse. The folder is created (or an empty one filled),
scaffolded like a normal case and registered as a linked case. System, home,
credential and Codeman folders are refused; a folder with files is Link Existing's
job; a failure after the first write undoes what this call created. Admin only in
multi-user mode, like Link Existing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:03:10 +08:00
DevvynandClaude Sonnet 5.5 6944f842c7 fix(rail): keep the inline rename editor unclamped in the detailed rail
The card-row rule (line-clamp: 3) out-ranked the shared unclamp-while-renaming
override. Restate it at the same weight; the test now covers both rail layouts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:02:47 +08:00
Codeman maintainer ffaa5ee80c chore: version packages (1.34.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:39:41 +02:00
Codeman maintainer 6aecc3b858 chore: changeset for the 1.34.0 landing (#499, #514, #515, #517, #519, #520, #521, #522, #523, #524, #530, #531)
One consolidated minor changeset with the Thanks block first; the four contributor changesets (#520, #521, #522, #523) are folded into it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:53:56 +02:00
Codeman maintainer 470cf79776 fix(terminal): let a composition-only overlay follow the prompt, repaint it on removeChar, document the API (#499 review)
Merge-time fixes for the three findings of the third review round of #499.

- minor: a composition on an empty prompt did not follow the prompt after
  output or a resize. The post-write re-place in flushPendingWrites and the
  resize observer both ran rerender() only when hasPending was true, and
  hasPending deliberately excludes the composition, so the first word of a
  prompt (an overlay holding only a composition) stayed on the old row over
  whatever output moved there. Both sites now call rerender() unconditionally;
  it already returns early when there is nothing to draw, so nothing changes
  without a composition. New browser case drives the real
  batchTerminalWrite/flushPendingWrites path against real xterm 6 and the
  overlay built from source, moves the prompt from row 0 to row 3 and checks
  the overlay follows (it fails on the old guard, overlay left on row 0), with
  a parity case for pending text. The structure test pins the post-write site
  through vm and the resize site, which is a closure inside initTerminal(), by
  source.
- nit: removeChar() dropped the composition but did not repaint on its false
  path, leaving a composition-only overlay on screen showing text the addon no
  longer held. It now hides the overlay there when a composition was dropped.
  Package tests cover that path and the flushed path repainting without the
  tail.
- nit: the package README did not document setComposition() or the
  composition getter and described hasPending as "any content". Added both to
  the API tables plus a short IME composition section, reworded hasPending
  (pending or flushed text, excludes the composition), and made the quick
  start re-render unconditionally instead of teaching the hasPending guard.
  The hasPending JSDoc says the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 06c4c7da16 fix(sessions): scope the launch model to claude and pin it with the advisor (#514, #515, #530 landing)
Maintainer merge-time fixes for the three PRs that landed together on the
session create / launch / persistence path.

#514 findings (bot verdict merge-with-fixes):
- minor, fixed: SessionState.model was published and persisted for every
  mode, so a codex/opencode cron session reported the app-wide Claude
  default it never ran on. toState() now emits it only where the new
  cliTakesSessionModel() holds (registry capability model.source ===
  'claude-settings-file', no CLI id branch). POST /api/sessions uses the
  same helper for its non-claude refusal, so refusal and publication cannot
  drift. Recovery then hands back undefined for other modes on its own.
- nit, fixed: the `model` schema admitted a leading dash (and '.', '[').
  The first character must now be a letter or digit; still a subset of the
  registry's model-claude pattern, so nothing accepted is refused at launch.
- nit, fixed (reject, the consistent choice): `model` with
  attachRemoteSession was silently dropped. Now a 400 INVALID_INPUT, as
  #514 does for non-claude CLIs and quick-start does for remote cases.
  advisorModel (#530) gets the same refusal there. effort and envOverrides
  keep their older silent ignore on that branch so no existing caller breaks.

#515 finding (bot verdict merge, one nit):
- nit, fixed: the types/session.ts @fileoverview described CodexConfig as
  (model, resumeSessionId); it now lists reasoningEffort, bypass,
  animations and renderMode too.

Audit of the merged combination (not reviewed before):
- The conflict resolutions in session.ts (toState), types/session.ts,
  reboot-restore-routes.ts, server.ts (restoreMuxSessions), CLAUDE.md and
  skills/codeman/reference/endpoints.md (+ plugin mirror) keep both sides
  correctly; nothing was lost or doubled.
- A claude session with both `model` and `advisorModel` launches with
  `--model <id>` and ONE merged `--settings` JSON (ultracode + advisorModel,
  or advisorModel beside `--effort <level>`), on the tmux template
  (including the resume || new variant and with the statusLine exporter)
  and on the direct-PTY fallback. Both values (and effort) survive
  restoreMuxSessions onto a dead pane, a reboot restore into a fresh pane,
  and restartCli/dead-pane respawn via _buildRespawnPaneOptions.
- quick-start and ralph-loop take no per-session `model` (matching #514's
  scope, POST /api/sessions only) and launch on the app-wide default, which
  toState now persists for claude, so recovery stays consistent.
- No defect found in the combination beyond the findings above. Noted, not
  changed: advisorModel is still published for any mode a caller sends it
  with (launch-inert there; the UI and skill send it for claude only).

Tests: test/session-model-recovery.test.ts pins the pair through both
recovery shapes for effort ultracode/high/none, the recovery constructors'
fields, the tmux-manager builder hop, and the codex/opencode/shell
non-publication; test/advisor-model.test.ts pins the launch lines and a
real direct-PTY Session's pty.spawn argv; the route test covers flag-shaped
models, attach refusals and the published fields. Docs: SessionState.model
docstring, the reboot-restore-registry header, the golden test comment and
the CLAUDE.md model/advisor bullets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 7917273188 fix(tabs): collapsed-header alerts, quieter layout reads, tree key and touch fixes (#517, #519 review)
Maintainer merge-time fixes for the grouped vertical rail (#517) and its
tree semantics (#519), from the two PR reviews.

#517 minors
- A collapsed group hid rows that need the user with no signal on its
  header. The header now takes the most urgent alert among the session
  rows its collapse hides, in the tab alert language (tab-alert-action
  red ring, tab-alert-idle yellow ring, the existing ::before rules
  extended to the header). New pure hiddenGroupAlerts() over a per-section
  `hidden` list; _syncTabGroupHeaderAlerts() patches it on BOTH render
  paths, since alerts change without a rebuild. The kept selection draws
  its own ring and is not counted.
- Every layout read rebuilt the whole tab strip, and failed reads retried
  every 5 s forever. _applyTabLayout() now rebuilds only when the
  structure key changed. The key drops the layout version (bumped on
  every session create/close and order PUT) and instead carries group
  names and the rows each collapse hides, so a version bump that moves
  nothing costs nothing and a rename still rebuilds. The load coordinator
  backs off (5, 10, 20, 40 s, capped at 60 s) and stops after 4 retries;
  the next SSE init or tab:layoutChanged tries again, a success resets.
- A malformed stored collapse value disabled collapse on that device for
  good. A parse or shape error now reads as nothing collapsed and is
  rewritten to []; ok:false stays reserved for a store that throws.
- Ctrl+Shift+{ / } still reordered across groups, where the server
  re-ranks per group, sends no session:orderChanged and leaves this
  client's sessionOrder and Alt+N targets diverged. The move is now a
  no-op unless the neighbour is in the active session's own section
  (_canSwapActiveTabWith, reading the projection's new sectionByRef, which
  also covers rows a collapse hides). Within a group the swap still works
  and the server agrees with it; the flat rail and the strip are
  unchanged.

#517 nits
- Keyboard group toggle dropping focus: already fixed by #519's
  focus-by-identity; the Enter toggle test now pins focus on the header.
- Header <button> inside role=tablist: moot, #519 made the header a
  treeitem inside role=tree.
- Byte-identity test not comparing against master: skipped in the suite
  (a test cannot read another revision's files portably). Checked by
  hand instead: the flat strip and flat rail markup of this branch before
  and after this commit are identical in all 16 cases (both orientations,
  manual and activity sort, no layout and zero groups, full and
  incremental paths).
- Doubled blank line in docs/architecture-invariants.md: removed.

#519 minors
- A tap on a tree header or unselected row dismissed the touch keyboard:
  the roving tabindex parks those at -1, so the [tabindex] arm of
  MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR missed them. The selector now
  lists [role="treeitem"].
- The tree key handler acted on keys pressed on a focused control inside
  a row (Enter on the overflow button re-selected and reloaded the active
  session instead of reopening its menu). It now returns unless the key
  landed on the treeitem itself.

#519 nits
- aria-posinset/setsize went stale when the activity-sorted grouped rail
  re-sorted rows on the incremental path. The position pass is extracted
  (_applyTabTreePositions) and re-run, with aria-selected and the header
  alerts, at the end of the incremental branch while the rail is a tree.
- An expanded group with no open rows was announced as an expanded parent
  owning an empty group. A group with no open rows is now a tree leaf: no
  aria-expanded, no aria-owns, its rows container presentation; Left and
  Right do nothing on it, and its chevron keys off the section's
  collapsed class instead of aria-expanded.

Tests: tab-layout-browser (malformed storage, backoff with a bounded
drain, structure key, hidden alerts, leaf groups, sectionByRef),
tab-layout-rail (header alerts on both paths, render-on-change, backoff
without rebuilds, malformed storage, Ctrl+Shift section gate, in-row
control keys, leaf header keys, posinset after an incremental re-sort,
the dismiss selector matching tree items), and three new Chromium tests
in tab-activation.browser (Enter on a focused overflow button, the touch
keyboard staying up on tree taps, the collapsed header's red ring). Every
new test fails on the pre-fix sources. Docs: architecture-invariants
owner-tab-layouts and keyboard-dismissal sections, one clause in
CLAUDE.md's dismissal rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer b451b3851e fix(mcp): no file text in sync errors, follow relocated config dirs, docs and Settings polish (#521 review)
Maintainer merge-time fixes for the MCP server sync (opt-in mcpSyncEnabled, synced, default OFF).

M1, parse errors echoed config text (secrets included) into the HTTP response and Settings:
smol-toml's TomlError carries a code frame of the offending lines and V8's JSON "Unexpected
token" errors quote source. Both catch sites now go through describeMcpSyncError(): a parse
failure is reported by line/column only ("not valid TOML (line 3, column 21)", "not valid
JSON"), an errno failure by Node's own message (code, syscall, path), the module's own
messages via a McpConfigError class, anything else as "unexpected error". Tests put a secret
on the broken line (TOML, both JSON message shapes, and a write refused at the re-parse that
would have quoted a copied server's env) and assert it is absent from the result and from the
route's response body; they fail against the old code.

M2, CODEX_HOME / CLAUDE_CONFIG_DIR / XDG_CONFIG_HOME were ignored, so a sync could create a
file the CLI never reads and report success: new optional registry field
capabilities.mcpConfig.relocation { envVar, path } (registry data, no id branch; schema
reuses the env-name and no-traversal path rules). Declared for claude (CLAUDE_CONFIG_DIR,
checked in the 2.1.289 binary), codex (CODEX_HOME), opencode (XDG_CONFIG_HOME) and gemini
(GEMINI_CLI_HOME, gemini-cli paths.ts); antigravity follows $HOME only (agy 1.1.12 has no
relocation var). Resolved from the server process env at call time: absolute moves the file,
empty means unset, anything else reports the target with the new status "skipped" plus the
reason and writes nothing. Dedupe is now by resolved file. When a caller overrides `home`
without passing `env`, process.env is not consulted, and the route tests clear those vars so
a CI runner's XDG_CONFIG_HOME can never aim a write outside the temp HOME.

M3, feature undocumented: CLAUDE.md Key Patterns paragraph (opt-in, admin-only, additive
only, backups, re-parse validation, 0600 for copied secrets, names-only responses with
position-only parse errors, capabilities.mcpConfig and relocation), a Settings-Reference row
in the wiki, and docs/cli-registry.md + docs/api-reference.md updated for relocation, the
"skipped" status and the error policy.

Nits:
- N1 Preview/Sync before Save: the UI remembers the saved value on open and says "Save
  settings to turn MCP sync on first" instead of calling the routes; the 403 message also
  says to turn it on and save.
- N2 non-admins in multi-user mode: _applyMcpSyncAdminGate() hides the whole MCP group, called
  from applyMcpSyncVisibility() and the codeman:me event like the CLI-management gate.
- N3 scope chip says "synced".
- N4 "(1 servers)" pluralised; the unsupported list only names installed CLIs (route test
  pins it with a per-test installed set).
- N5 McpSyncResult / McpSyncTargetResult moved to src/types/mcp-sync.ts (barrel export); only
  the route imported them, so no churn.

Verified with an isolated instance (throwaway HOME, own instance and tmux socket) and
Playwright: chip, save-first message, preview rendering and the admin gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 6d6e7da481 fix(notifications): main Save keeps webhook edits, glue test, docs and nits (#523 review)
Merge-time fixes for the webhook notification channel (ntfy, Slack, Discord, generic JSON).

Minor 1, App Settings Save silently dropped webhook edits: the modal's main Save now
persists the webhook group beside the settings PUT, the same way it already saves the
model config (saveModelConfigFromSettings), but only when the group differs from what
loadWebhook() put on screen (_webhookPending), so an untouched group never re-PUTs. A
refusal (bad URL, enabled with no URL) shows a warning toast, keeps the modal open and
scrolls to the group with the pasted URL still in the box, instead of a success toast.
Send test now saves pending edits first, so it never tests the old URL while the box
shows a new one. The row says so in one line.

Minor 2, no test for the server.ts glue: new test/webhook-push-glue.test.ts drives the
private sendPushNotifications on a real (never started) WebServer with an EMPTY push
store and webhook.json in the instance data dir, delivering through the real
egress-guarded fetch to a local receiver: a permission prompt arrives with the
host-prefixed ntfy Title and body while Web Push is never called, an immediate repeat is
deduped, "response complete" is skipped under scope attention and sent under all, and a
disabled config or a non-push event sends nothing. Verified it fails when the webhook
call is moved below the "no subscriptions" return.

Minor 3, docs: webhook.json added to CLAUDE.md State Files; a Webhooks section in
docs/wiki/Notifications-And-Approvals.md (setup, what is sent, the secret URL, public
ntfy topics, local targets allowed, dedupe, instance-wide reach in multi-user mode) plus
a table row, and a line in Settings-Reference; new section 10c in
docs/security-architecture.md for the second outbound channel through the web-tab
egress guard.

Nits:
- Orphaned JSDoc: the webhook schema moved below the push schemas, so
  PushSubscribeSchema has its comment back.
- Duplicated enums: WebhookUpdateSchema uses z.enum(WEBHOOK_KINDS/WEBHOOK_SCOPES), so
  the schema cannot accept a kind the store would coerce away.
- describeError classifies egress refusals with isEgressBlockedError (the
  CODEMAN_EGRESS_BLOCKED code anywhere in the cause chain) instead of a message regex;
  tests pin a deep cause chain and that matching words alone are not a refusal.
- Markup: the URL input uses set-input, the whitespace-only line is gone, and the switch
  row hints to pick a long random topic on public ntfy.sh.
- Remove a saved URL: a "Remove URL" button (shown only while a URL is saved, with a
  confirm) sends { url: "", enabled: false }.
- Types placement: WEBHOOK_KINDS/SCOPES and WebhookKind/Scope/Urgency/Config/Result/Status
  moved to src/types/push.ts (the IO-side WebhookMessage/Request/Fetch stay in the module).

Browser test extended: main Save persists a pending edit, a refused URL keeps the modal
open with the URL, Send test saves a newly pasted URL first, Remove URL clears it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 52267f8617 fix(terminal): drive the shipped Shift+Enter handlers and the Key tester cap in their browser tests (#520, #522 review)
- Minor: the Key tester "14 lines" test never pressed a key into the
  tester (the previous test blurred it, so the presses landed on <body>
  and the cap was never exercised). It now refocuses the field, asserts
  the focus, clears the log, checks 2 presses accumulate to 6 lines, then
  4 presses of another key cap the log at exactly 14 with the oldest 4
  lines evicted in order, and the readonly field stays empty. Verified to
  fail with the cap changed to 20.
- Nit: the split-pane invariant implied Ctrl+Enter could use the CLI's
  declared newline chord. Reworded after checking the send-key route:
  Ctrl+Enter is always a real 0x0a, Shift+Enter is the declared
  capabilities.newline chord (0x0a unless the CLI declares another), sent
  on keydown only. The same imprecision in the auto-named sessions
  paragraph is corrected too.
- Nit: docs/wiki/Settings-Reference.md now lists the Key tester row in
  the Terminal & Input table.

- Nit: test/shift-enter-keypress.browser.test.ts exercised a hand-copied
  predicate named `shipped`. It now loads the real app from a real
  WebServer and presses real keys into the handlers terminal-ui.js
  (app.terminal, recording the real _sendInputAsync send path) and
  terminal-split.js (a real SplitTerminalPane) attach, recording the
  send-key POSTs through a fetch wrapper. It asserts no \r reaches either
  send path for Shift/Ctrl+Enter, exactly one send-key per press for the
  right session, and that Enter and Alt+Enter are untouched. The old
  keydown-only gate stays as a labelled reproduction of xterm's keypress
  behaviour on a bare Terminal. Verified to fail on both panes with the
  gate narrowed back to keydown.
- Nit: the keypress trap is now written down beside the other key-gate
  rules (Command palette and shortcut registry): xterm runs the custom
  handler for keydown, keypress and keyup and drops only Ctrl/Alt/Meta
  keypresses, so a gate on a chord that can carry Shift alone must
  swallow every event type. The smart-copy keydown-only rule points at it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 36af183f97 fix(split-pane): leave the owed marker to a trailing refresh, and say what a pull's request phase holds (#524 review)
- Stale second marker above a trailing refresh's replay: xterm parses
  write() on a later tick while clear() is synchronous, so a marker stamped
  in a load's finally, just before _endBufferLoad() starts the trailing
  refresh, landed in the freshly cleared buffer above that refresh's replay.
  _stampMarkerIfOwed() now returns early while a refresh is pending; that
  refresh re-owes the marker on a closed socket and writes the one copy
  below its own replay. Pinned by marker-count assertions on the two
  existing trailing-refresh tests plus a new async-parse fake (writes
  parsed on a later tick, clear() synchronous) for back-to-back refreshes
  and a pull with a queued refresh and a close mid-pull; all four fail
  without the guard. Also checked against a real @xterm/headless 6.0.0.
- Marker withheld for up to the 45 s request budget: kept the behaviour and
  made the comment and the docs truthful. The pull's request phase holds no
  live output, but it holds the single-flight flag, so a coalesced {t:'r'}
  refresh and a close's owed marker wait for the response. Writing the
  marker at once during that phase would need a separate "awaiting
  response" state and, with a refresh pending, reopens the same
  write-vs-clear() race as above; a Codeman restart resets the in-flight
  request along with the socket, so that pull fails at once and stamps.
- Stale comments: _onSocketClosed() now says the deferral covers any load,
  _writeDisconnectedMarker() points at _stampMarkerIfOwed(), and the pull's
  finally comment describes the hand-off to a trailing refresh.
- Invariants doc: dropped "the initial load" from the loads a close can land
  in (connect() awaits it before creating the socket), reworded the
  "nested refresh stamps its own" sentence to describe the guard, and noted
  what the request phase holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:27 +02:00
Codeman maintainer c029cea620 Merge pull request #499 from aakhter/pr/mobile-ime-preview
feat(terminal): preview IME composition text on iOS Safari

# Conflicts:
#	CLAUDE.md
2026-10-04 23:26:30 +02:00
Codeman maintainer b8038a592c Merge pull request #530 from Ark0N/feat/advisor-model
feat: Claude advisor tool support (per session, App Settings default, skill workers)

# Conflicts:
#	CLAUDE.md
#	plugins/codeman/skills/codeman/reference/endpoints.md
#	skills/codeman/reference/endpoints.md
#	src/session.ts
#	src/types/session.ts
#	src/web/routes/reboot-restore-routes.ts
#	src/web/server.ts
2026-10-04 23:25:01 +02:00
Codeman maintainer ccd6df893f Merge pull request #514 from irisitymichaelgrundberg/feat/claude-session-model
feat(sessions): accept a per-session Claude model on POST /api/sessions
2026-10-04 23:24:43 +02:00
Codeman maintainer e082d8e438 Merge pull request #515 from irisitymichaelgrundberg/feat/codex-reasoning-effort
feat(codex): start a codex session at a chosen reasoning effort
2026-10-04 23:24:39 +02:00
Codeman maintainer fa53a5751e Merge pull request #519 from aakhter/pr/grouped-rail-tree
feat(tabs): full-row activation and tree semantics for the grouped rail
2026-10-04 23:24:39 +02:00
Codeman maintainer 23d145b121 Merge pull request #517 from aakhter/pr/grouped-vertical-rail
feat(tabs): grouped vertical rail from owner tab layouts
2026-10-04 23:24:39 +02:00
Codeman maintainer 5724e0c8b4 Merge pull request #523 from opticon454/feat/webhook-notifications
feat(notifications): ntfy/Slack/Discord/generic webhook for the push events

# Conflicts:
#	config/test-suites.ts
#	docs/api-reference.md
#	src/web/public/settings-ui.js
#	src/web/routes/index.ts
#	src/web/server.ts
2026-10-04 23:24:39 +02:00
Codeman maintainer 9649b5019b Merge pull request #521 from opticon454/feat/mcp-sync
feat(mcp): sync MCP servers across enabled CLIs

# Conflicts:
#	src/config/cli-registry/schema.ts
#	src/config/cli-registry/types.ts
#	src/web/public/settings-ui.js
2026-10-04 23:24:26 +02:00
Codeman maintainer ec2a036543 Merge pull request #524 from timkjr/fix/split-pane-live-queue-timing
fix(split-pane): open Pane B's live queue after the response, keep the disconnected marker last

# Conflicts:
#	docs/architecture-invariants.md
2026-10-04 23:24:08 +02:00
Codeman maintainer c197e9370b Merge pull request #522 from opticon454/fix/newline-sequence-capability
feat(terminal): newline chord as registry data, plus a Key tester in Settings

# Conflicts:
#	config/test-suites.ts
2026-10-04 23:24:00 +02:00
Codeman maintainer d887002ca8 Merge pull request #520 from opticon454/fix/shift-enter-keypress
fix(terminal): Shift+Enter no longer submits after inserting a newline
2026-10-04 23:23:55 +02:00
Codeman maintainer 574f3db58b Merge pull request #531 from Ark0N/fix/statusline-exporter-tmp-race
fix(statusline): concurrent session creates no longer fall out of tmux
2026-10-04 23:23:55 +02:00
Codeman maintainer 17a976fa2e fix(statusline): unique temp name per exporter refresh, so concurrent creates stay in tmux
ensureStatusLineExporterScript() rewrites ~/.codeman/statusline-exporter.sh
via a temp file + rename whenever the script content changes (a fresh data
dir, or a release that changes it). The temp name was pid + Date.now(), so
claude sessions created in the same millisecond (spawn_workers, a multi-tab
Run) shared one temp path: the first rename consumed it and every other
writer failed with ENOENT on chmod or rename. createSession() treats that as
a mux failure and falls back to a direct PTY, so those sessions silently ran
outside tmux (no reattach after a server restart) while quick-start still
reported success.

Measured on a fresh isolated instance, 4 concurrent claude quick-starts:
master put 2 of 4 in tmux in both rounds; with this change 4 of 4, both
rounds. The temp suffix now comes from randomBytes, like the skill writer in
the same file and user-store.ts already do. The new test freezes Date.now()
and runs eight refreshes at once; it fails on master with the same ENOENT.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 21:36:44 +02:00
DevvynandClaude Sonnet 5.5 2cf37529e9 fix(terminal): address review: Key tester isolates shortcuts, Codex stays on line feed
- app.js: the shortcut dispatcher returns early for events aimed at a data-raw-keys
  field, so Ctrl+W / Ctrl+L / Escape / Alt+1 / Ctrl+K pressed in the Key tester no
  longer kill the session, clear the terminal or close Settings
- stock.ts: drop Codex's esc-enter (a line feed works); no stock CLI declares a chord.
  The esc-enter path is tested through a clis.json override
- tests: unused port (3194), Ctrl+Enter asserts no keypress, shortcut-isolation test
  (verified to fail without the guard)
- docs/comments point at capabilities.newline; set-input class, trailing whitespace

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 11:19:42 +08:00
timkjrandClaude Sonnet 5.5 df398c5c68 fix(split-pane): open Pane B's live queue after the response, keep the disconnected marker last
Follow-ups from the #506 review.

The live-frame queue opened before the fetch, freezing Pane B for the
whole round trip. It now opens beside capturedAt; the request uses the
shared terminal fetch deadline and the body read a 10 s one.

A {t:'r'} refresh queued behind a pull ran its clear() after the
disconnected marker was written and wiped it, and a close during a
refresh load wrote the marker above the replay. The marker is now an
owed flag (_markerOwed) that each load settles in its own finally.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 10:00:11 -05:00
DevvynandClaude Sonnet 5.5 d68a173a23 docs+test(notifications): api-reference, changeset, switch click in browser test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 22:34:46 +08:00
DevvynandClaude Sonnet 5.5 0ff57ce304 feat(notifications): ntfy/Slack/Discord/generic webhook for the push events
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 22:06:29 +08:00
DevvynandClaude Sonnet 5.5 f39c66e4e8 feat(terminal): newline chord as registry data, plus a Key tester in Settings
capabilities.newline replaces choosing the Shift+Enter bytes in the send-key
route. Key tester shows the keydown/keypress/keyup a browser reports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 21:53:31 +08:00
DevvynandClaude Sonnet 5.5 4398dbfad0 feat(mcp): make sync opt-in and address review
Opt-in (mcpSyncEnabled, default OFF; routes 403 until on). Review fixes:
- codex TOML read/validated with smol-toml: CRLF, inline tables and
  command-less tables no longer yield a duplicate [mcp_servers.x]; the new
  text is re-parsed before writing
- null-prototype tables and own-key checks; unsafe names ignored at every level
- servers switched off in their own CLI (codex/opencode/antigravity) are not copied
- only CLIs that are installed or already have a config file take part
- files receiving env/headers are left 0600; symlinked configs are written through
- one apply at a time (409), unique tmp files cleaned on failure, failed status
- routes set real HTTP status codes; api-reference section; format type single-sourced

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 21:15:31 +08:00
DevvynandClaude Sonnet 5.5 c9a5fdab00 fix(terminal): swallow Shift+Enter keypress so it no longer submits
xterm runs the custom key handler for keypress too and drops Ctrl/Alt
keypresses but not Shift-only ones, so the stray \r submitted the prompt
after the newline. Swallow every event type for Shift/Ctrl+Enter and send
only on keydown, in the primary pane and Pane B. Adds a static guard and a
real xterm + Chromium browser test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 19:44:58 +08:00
DevvynandClaude Sonnet 5.5 7616de13de docs(mcp): document MCP sync in the CLI registry guide; unexport canExpress
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 19:06:37 +08:00
DevvynandClaude Sonnet 5.5 af032fc81a fix(mcp): block __proto__ server names, fix lint; add route and registry tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 18:27:07 +08:00
DevvynandClaude Sonnet 5.5 41a10b159e feat(mcp): add Antigravity, fix Gemini http/sse shape, report unsupported CLIs
Formats verified against real agy/gemini/codex mcp add output.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 18:27:07 +08:00
DevvynandClaude Sonnet 5.5 e6b258fc44 feat(mcp): sync MCP servers across enabled CLIs
Adds capabilities.mcpConfig to the CLI registry (Claude, Gemini, Codex,
OpenCode), an additive src/mcp-sync.ts, GET/POST /api/mcp-sync and a
Settings > Agents & CLIs control. Never edits or removes an existing
server; backs up each file it changes; reports conflicts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 18:27:06 +08:00
Aamer Akhter 98c6c1881d feat(tabs): full-row activation and tree semantics for the grouped rail
The grouped vertical rail is now an ARIA tree with a tree keyboard model,
and tab rows are pinned as full-row activation targets whose controls keep
their own actions and stable hit targets.

Tree semantics (grouped vertical rail only):
- #sessionTabs becomes role=tree while grouped and returns to its shipped
  role=tablist and label when grouping ends. The header strip, sidebar and
  flat rail keep role=tablist / role=tab exactly as before (the flat rail's
  markup is unchanged byte for byte).
- A named group's header is a level-1 treeitem with aria-expanded that
  aria-owns its rows' role=group (rows are level 2). Ungrouped rows and the
  row a collapsed group keeps showing are level-1 items; a collapsed header
  owns nothing, and the "Ungrouped" heading is a visual divider hidden from
  assistive tech. aria-level, aria-setsize and aria-posinset are set on every
  item, and aria-selected follows the selection without a rebuild.
- Exactly one treeitem carries tabindex=0 (roving). Controls inside rows
  leave the tab order, so Shift+F10 / ContextMenu open a row's actions
  (session action menu, web tab settings).
- Up/Down walk visible items, Home/End jump, Right expands a header or enters
  it, Left collapses a header or climbs from a row to its header, Enter/Space
  select a row or toggle a header. With the activity sort on, the walk follows
  painted order within each group; the flat list keeps its whole-list walk.
- Focus survives a full re-render by identity (a row a collapse just hid hands
  focus to its header), but a render never pulls focus into the rail.
- The group header is the treeitem itself (no nested button), still toggled by
  click through the same onclick and still the lineage proxy anchor.

Full-row activation:
- Clicking a row's status dot, mode chip, name or padding already selected it
  upstream; that is now pinned in real Chromium for the strip, the flat rail
  and the grouped rail, together with every control (gear, detach, close,
  overflow, web tab gear and close) running only its own action.
- The close control now shows a pointer like its siblings instead of the
  default arrow.
- Enter/Space on a focused web tab in the flat list opens it; it used to call
  selectSession(undefined).
- The action controls are pinned to stay under the pointer when a row is
  hovered (no reflow-on-hover moving the gear out from under a click).

New Chromium suite test/tab-activation.browser.test.ts is listed in
BROWSER_TEST_GLOBS (run with npm run test:browser).
2026-10-01 22:21:45 -04:00
Aamer Akhter 7cbce5bf6c feat(tabs): grouped vertical rail from owner tab layouts
The vertical tab rail now reads the owner's tab layout (GET /api/tab-layout)
and draws its groups as collapsible sections. This is the first frontend
consumer of the tab-layout backend and it is read-only: nothing in the
browser writes the layout yet.

- tab-layout-browser.js (new, pure, loaded before app.js): projects the
  layout onto the live sessions and open web tabs, renders the grouped
  markup, stores collapse per device, and sequences loads newest-wins with
  a bounded retry on failure.
- app.js: loads the layout on init and on tab:layoutChanged, renders the
  grouped rail from the same per-row markup the flat rail uses, falls
  through to a full render whenever the grouping structure changes, and
  withholds drag-reorder in the grouped rail.
- Grouping is opt-in by construction. With no layout, a failed read, a
  layout without groups, or a horizontal strip, the rail renders exactly
  as before (byte-identical markup).
- Grouping is a render layer only: sessionOrder, Alt+N, Ctrl+Tab and the
  palette keep reading the server-projected order, and row badges keep
  their Alt+N slot.
- A collapsed group still shows the active row; lineage arcs to a hidden
  session anchor to its group header.
- webview-tabs.js: renderWebviewTab() extracted so a single web tab can be
  placed into its group with unchanged markup.
2026-10-01 14:14:30 -04:00
Michael GrundbergandClaude Opus 5.5 3df113fc54 fix(sessions): keep a session's model through recovery, refuse it off claude
SessionState now carries the model a session launched with, and both
recovery constructors (mux recovery and reboot restore) pass it back, so a
recovered session relaunches on the same --model rather than the account
default. A top-level `model` sent with any other CLI is refused, since
those take their model in their own config object, and an empty string
means no per-session model, as it does for modelOverride.

CLAUDE.md now describes both routes for a Claude model. The tests pin
which of `model` and `modelOverride` reaches the launch and which the
case file, and that a model opening with a dash renders as --model's value.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:24:12 +02:00
Michael GrundbergandClaude Opus 5.5 0ae39cdd94 test(codex): pin reasoning effort through quick-start and the multi-user clamp
Both create schemas now refuse an unknown level, and a non-granted owner's
codexConfig keeps its reasoningEffort when the clamp forces bypass off.
docs/architecture-invariants.md lists the two --config values codex now
takes from codexConfig.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:16:18 +02:00
Michael GrundbergandClaude Opus 5.5 45db24bacf feat(codex): start a codex session at a chosen reasoning effort
codexConfig takes a `reasoningEffort`, one of the levels codex accepts,
and the session starts with `--config model_reasoning_effort=<level>`.
The registry declares one literal per level, gated on the enum, because
an argv token cannot splice a value into a literal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:59:46 +02:00
Michael GrundbergandClaude Opus 5.5 4123d229f4 feat(sessions): accept a per-session Claude model on POST /api/sessions
POST /api/sessions takes an optional `model`, and a Claude session
launches with `claude --model <id>`. It wins over the app-wide default
model and writes nothing to disk, unlike `modelOverride`, which stays as
it is and still writes the case's .claude/settings.local.json.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:59:45 +02:00
Aamer Akhter ee1a155e2c fix(terminal): draw IME preview after local-echo text
With local echo on, committed text sits in the LocalEchoOverlay and does not
reach the PTY before Enter, so the PTY cursor that places the preview span
stays at the prompt start. The span's z-index 6 only counts inside
.xterm-helpers (its own z-index 5 stacking context), and the overlay is a
z-index 7 layer whose first line is opaque from the prompt column, so every
composition after the first one in a prompt was drawn under the overlay.

- xterm-zerolag-input: add setComposition(text) and a composition getter.
  The overlay draws the composition as an underlined, aria-hidden tail after
  its pending text, through the same wrapping and grow-upward layout. It is
  never part of pendingText, hasPending or anything sent; clear() and
  removeChar() drop it, and rerender()/refreshFont() keep it.
- terminal-ui.js: while local echo shows typed text (on, and not handed back
  to PTY echo by a nav key), render and clear the preview through
  setComposition. The helper span stays for local echo off, and as the
  fallback when the overlay cannot place the text (no prompt found).
- Browser test against real xterm 6, the overlay bundled from its source
  and styles.css: a second composition after pending text is the topmost
  element after that text, and the commit lands in the overlay once. Unit
  tests for setComposition in the package and for the routing in the
  structure test.
- CLAUDE.md and architecture-invariants: state the preview's effective layer.
2026-09-27 07:56:22 -04:00
Aamer Akhter 2d96472dbe fix(terminal): address review of the iOS IME preview
- Observe keydown in the capture phase on terminal.element, an ancestor of
  the helper textarea, so the controller sees it before xterm's own capture
  listener finalizes the composition and emits the commit through onData.
  Finalize on exactly the keys CompositionHelper.keydown does (every keyCode
  except 20/229/16/17/18), ignoring isComposing and key as xterm does.
- Bound awaitingCommit with the same 2 s fallback as the committed phase, so
  a composition whose commit never reaches onData cannot turn the next
  unrelated keystroke or paste into an IME commit.
- pagehide resets the controller instead of destroying it, so a back-forward
  cache restore keeps the preview working.
- Give the preview an opaque background from the terminal theme.
- Route an IME commit through the ordinary printable/paste local echo branch
  and complete the commit afterwards; drop the send-on-throw fallback.
- Pin the event order with an xterm stand-in registered in the capture phase
  ahead of the controller, and against real xterm in a browser test.
- CLAUDE.md: note the IME commit routing and the z-index 6 preview layer.
2026-09-26 22:47:48 -04:00
Aamer Akhter e0542bb172 feat(terminal): preview IME composition text on iOS Safari
WebKit on iOS does not show text being composed by an IME inside the
terminal, so users type blind until it commits. Add mobile-ime-preview.js,
a visual-only controller that renders the composition in the xterm helper
layer and holds a committed chunk until local echo, parsed terminal output
or a 2s fallback shows it. Wire it into terminal-ui.js, the script order,
the build minify/hash lists and styles, with unit and wiring tests.
2026-09-26 17:06:11 -04:00
124 changed files with 21160 additions and 352 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.33.3",
"version": "1.35.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+50
View File
@@ -1,5 +1,55 @@
# aicodeman
## 1.35.0
### Minor Changes
- 6f88e40: ### Thanks
- @opticon454 for four PRs in one night: the Git status indicator with its panel of uncommitted and unpushed work and per-file diffs (#537), `codeman doctor` in Settings (#536), creating a case in a custom folder (#535) and the detailed-rail rename clamp fix (#534). Both review rounds came back within half an hour with every item addressed.
- @aakhter for making the grouped vertical rail editable end to end (#525), the inline rename write queue and the long-prefix editor layout (#526), and bounded path probes so an unreachable network mount can no longer freeze the server (#516). Every round came back with tests that replay the exact sequences from the review, and the review nits were already fixed before landing.
**Edit tab groups in the vertical rail (#525).** The grouped rail is now editable from the browser: create, rename, reorder and delete groups, and move tabs between groups or back to Ungrouped, from the row menu, a group menu (Shift+F10, ContextMenu, right-click, or the header glyph, which stays visible on touch screens; F2 renames inline) or a mouse/pen drag. A flat rail offers "Move to new group" to make the first one. Every edit is a named operation saved through the existing `PUT /api/tab-layout`, one write in flight at a time; a version conflict replays the pending operations onto the server's layout and retries, so a concurrent edit from another device survives, and unsaved edits survive a reload. A drag released outside the rail leaves nothing behind, and committing a group rename by clicking elsewhere leaves focus where you clicked. No server changes.
**Inline rename that keeps up (#526).** Inline renames go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied even if the editor was reopened or cancelled meanwhile, an editor reopened over a rename in flight starts from that name, and a failed rename always shows its toast. A long `w<n>-<case>` prefix no longer pushes the editor out of its row in the rail or the sidebar, and the detailed rail no longer keeps its 3-line clamp around the editor (#534 found and fixed the same clamp independently).
**Git status in the bottom bar (#537).** Turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window listing the uncommitted files (staged, not staged, untracked, conflicts; click one for its diff; grouped under collapsible folders) and the unpushed commits. A folder holding several projects gets a section per repository found up to two levels down, and an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, and git never runs on a repository a Docker case can write to. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`.
**Diagnostics in Settings (#536).** Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, with versions, paths and install hints. The probe runs in a child process, so a slow `--version` cannot freeze the server, and both the panel and the terminal `codeman doctor` now also look in each CLI's usual install directories, so a CLI installed outside a service's minimal PATH is found. Admin only in multi-user mode.
**Create a case in a custom folder (#535).** Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case and listed alongside the rest (deleting it unlinks, never removes files). `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty, and system folders, the home folder, credential folders and the cases directory itself are refused. Nothing is left behind if creation fails part-way. Admin only in multi-user mode.
**An unreachable mount no longer freezes the server (#516).** A linked case can live on a network mount, and when that mount goes away a hard mount makes `stat()` wait indefinitely; the synchronous probes in the case routes, the workspace hook and statusLine helpers and session creation used to freeze the whole server with it. Those probes now go through one bounded, tri-state probe (present, absent, or unknown when nothing answers in time): a stalled path costs one threadpool worker, paths on the same mount answer "unknown" without a new stat, and unrelated paths keep working. "Unknown" is never treated as "absent": `GET /api/cases/:name` reports an unreachable linked case with `unreachable: true` instead of NOT_FOUND, Run creates a case only on a real NOT_FOUND, and session creation answers OPERATION_FAILED for a folder that did not answer and never scaffolds over it. Tunable with `CODEMAN_PATH_PROBE_TIMEOUT_MS` (default 1500) and `CODEMAN_PATH_PROBE_MAX_STALLED`.
**Fixes applied while landing.** Tab groups: an edit made while an earlier save was still in flight, and made inapplicable by that save's conflict (its group deleted on another device), is no longer dropped silently but reported like every other dropped edit, and the menus stop offering a new group once the 32-group limit is reached instead of failing with an untranslated error. Rail: a static CI check now pins that no rail or sidebar clamp out-ranks the rename unclamp (the browser test that caught it is outside the gate). Git status: a cached repository list is re-checked against the current Docker workspaces on every poll, a diff larger than 8 MB is cut short instead of failing, a diff click refreshes only that repository, a dotfiles repository above the workspace is identified with one `rev-parse` before any full status (a failing status there no longer hides the repositories below), and "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed. Doctor: candidates are judged like the Run menu's own resolver (a wrong binary on the PATH no longer hides the right one in an install directory, a non-executable file or a relative directory reads as missing), probes are killed with SIGKILL on timeout, a missing optional tool shows ○ instead of ✗, and the contract test no longer runs the machine's installed CLIs. Custom-folder cases: the symlink-resolved target is judged against resolved roots too (home reached through a link, macOS `/private/etc`), a target inside the cases directory is refused, the success toast names the folder the server created, the new labels have zh-CN translations, and the route test can no longer delete a real `~/projects` or the live linked-cases registry when run outside `npm test`.
## 1.34.0
### Minor Changes
- 6aecc3b: ### Thanks
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
## 1.33.3
### Patch Changes
+13 -8
View File
@@ -78,7 +78,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**: 1.33.3 (must match `package.json`)
**Version**: 1.35.0 (must match `package.json`)
## Project Overview
@@ -137,8 +137,8 @@ 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_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — 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. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
- **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`
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`. Tests: `test/advisor-model.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
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never 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. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
- **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()`. This has caused real shipped bugs twice
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
@@ -146,6 +146,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -245,6 +246,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
@@ -255,7 +258,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
@@ -300,6 +303,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
@@ -320,7 +325,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
@@ -368,7 +373,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
@@ -380,7 +385,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
@@ -431,7 +436,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
+3
View File
@@ -444,8 +444,11 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
## More Features
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
+9
View File
@@ -20,6 +20,8 @@
*/
export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
@@ -31,8 +33,15 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
'test/case-custom-path.browser.test.ts',
'test/doctor-settings.browser.test.ts',
'test/git-status.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
];
/**
+77
View File
@@ -700,6 +700,44 @@ normal `caseName`/`mode`/etc. body)
jarring than a full relaunch, and folding it into the one-shot path is
separate work — see `docs/custom-model-endpoints-plan.md`).
## Creating a case in a custom folder
`POST /api/cases` takes `{ name, description?, path? }`. Without `path` it creates `<cases dir>/<name>` as always. With `path` (absolute, or starting with `~`) the case folder is created at that exact path instead, scaffolded the same way (`CLAUDE.md`, `src/`, `.claude/settings.local.json`), and registered in the linked-cases registry, so it lists, resolves and deletes like a linked case (deleting unlinks; it never removes files). Response: `{ case: { name, path } }`, where `path` is the symlink-resolved folder.
The target is judged before anything is written:
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it.
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
## Git status
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most 12 (`reposTruncated` says when there were more). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
`data` is `{ state, repos, reposTruncated, checkedAt }`:
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
## CLI management
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
@@ -713,6 +751,45 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
## MCP server sync
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
| Method | Path | Body | Notes |
| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. |
| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. |
Result (`data`):
- `applied` — `false` for the dry run.
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
- Only installed CLIs are listed: one that is not installed is left out, as a supported CLI that is not installed reads `absent`.
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
## Webhook notifications
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
| Method | Path | Body | Notes |
| ------ | -------------------- | -------------------------------------------- | ----- |
| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
## Diagnostics
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
## Voice dictation
Browser dictation transcribed through this server's Claude Code login, i.e. the
File diff suppressed because one or more lines are too long
+12
View File
@@ -118,6 +118,10 @@ sure its row is one the agent cannot write.
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
## The newline chord
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
## Arg-template safety
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
@@ -249,6 +253,14 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
## MCP server sync
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
## See also
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
+10
View File
@@ -532,6 +532,16 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
---
## 10c. Webhook notifications (outbound channel)
Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md).
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
---
## 11. Quick reference
| Env / flag | Effect |
+1 -1
View File
@@ -19,7 +19,7 @@ Three ways to get one, all under **+** next to the case picker:
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
+2
View File
@@ -144,6 +144,8 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
curl -s "$API/api/subagents" | jq # background agents
curl -s "$API/api/search?q=deploy" | jq # cross-session search
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
# with ID set to a session id:
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
+51 -3
View File
@@ -7,11 +7,12 @@ opening the session.
## The signals, cheapest first
| Surface | Reaches you | Default |
| ---------------------- | ------------------------------------------------- | ------- |
| ------------------------------ | ------------------------------------------------ | ------- |
| Tab alert | While the dashboard is open | On |
| Browser title flash | Another tab in the same browser | On |
| Desktop notification | Another window on the same machine | Opt-in |
| Push notification | Anywhere, even with no tab open | Opt-in |
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
| Approvals Inbox | One queue across every session | Opt-in |
| Phone overview | Phone home screen, NEEDS YOU section | On |
| Away Digest | Afterwards, as a summary | Opt-in |
@@ -60,6 +61,51 @@ Setup:
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
## Webhooks: ntfy, Slack, Discord
**Opt-in, off by default. One channel for the whole server.**
Push needs a browser that subscribed once. A webhook needs nothing on the client side: the
server itself posts each alert to an ntfy topic, a Slack or Discord incoming webhook, or any
URL as plain JSON. That makes it the option for a headless box nobody has opened in a browser,
and for a team channel.
It carries the same events as push: permission prompts, questions, idle sessions, session
errors, blocked respawns, a stopped crash loop and Ralph task completion. "Response complete"
is included only when **Which events** is set to **Everything**; the default, **Needs
attention**, skips it. A session that is watching its own work stays quiet here too.
Setup, in **App Settings → Notifications → Webhook**:
1. Pick the **Service**. ntfy gets a title, a priority and a tag per urgency; Slack and
Discord get a bold title line; **Generic JSON** posts `{ event, title, body, urgency,
sessionId, sessionName, host, at }`.
2. Paste the **Webhook URL** and turn on **Send alerts to a webhook**.
3. Press **Save**, either the group's own button or the main Settings Save, then **Send test**.
Send test saves anything you changed first, so it always tests what is on screen.
The status line under the group shows the last delivery: when it worked, or why it did not
(an HTTP status, a timeout, a refused connection).
Behaviour worth knowing:
- **The URL is a secret.** Anyone holding a Slack or Discord webhook URL can post as it, and
anyone who knows an ntfy topic can read it. Codeman keeps it in its own file,
`~/.codeman/webhook.json` (readable by its owner only), never in the shared settings, and
never shows it again: once saved, the box is empty and the hint shows only the scheme and
host. Paste a new URL to replace it, or press **Remove URL** to delete it from the server
(which also turns the channel off).
- **On public ntfy.sh, pick a long random topic.** Topics there are not private; the name is
the only thing keeping strangers out.
- **Local targets work.** A self-hosted ntfy on your LAN or on the same machine is fine.
Link-local and cloud-metadata addresses are refused, both when you save and when the
message is sent, and redirects are not followed.
- **Repeats are folded.** The same event for the same session within three seconds is sent
once, so a flapping prompt cannot flood a channel.
- **Multi-user mode: admins only, and it sees everything.** Only an admin can see or change
the webhook, and it receives every user's session events (session names, tool names, error
text). Point it somewhere every user would be comfortable with.
## The Approvals Inbox
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
@@ -152,7 +198,8 @@ It is the morning-after view for an overnight run. Enable its header button in
## Recommended setup for unattended runs
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
2. Push notifications subscribed, with Codeman installed to the home screen on iOS.
2. Push notifications subscribed, with Codeman installed to the home screen on iOS, or a
webhook to ntfy if no browser will ever be open.
3. Approvals Inbox on.
4. Auto-resume on usage limit on, for each session you leave running. See
[Keeping Agents Running](Keeping-Agents-Running).
@@ -162,7 +209,8 @@ from the lock screen.
## Gotchas
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. A webhook
has no such requirement, since the server sends it.
- **iOS needs the home screen install.** A Safari tab will never receive push.
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
- **Approvals need real signals.** They are built on hook events, which Claude emits and
+1 -1
View File
@@ -42,7 +42,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
| Tab | Use it when |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. Tick **Create in a custom folder** to put it somewhere else instead. |
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
+18 -4
View File
@@ -50,6 +50,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
| Key tester | n/a | A diagnostic that stores nothing. Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup, for when a chord such as Shift+Enter behaves differently on one device. Keys pressed there reach no session and trigger no shortcut. |
### Header & Panels
@@ -60,6 +61,13 @@ Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle L
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
by folder** (per device, on by default) shows changed files under collapsed folders in that
window; off lists every file by its full path.
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
New header controls never appear on phones. Split is desktop-only regardless of this
setting — the button and the feature both stay off below a ~1180px viewport, where two
@@ -121,11 +129,13 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Nice priority / value | Runs agent processes at a lower CPU priority. |
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
### Notifications
Master toggle, browser notifications, push subscription, audio alerts, and the idle
threshold that decides when a quiet session counts as needing you. See
Master toggle, browser notifications, push subscription, audio alerts, the idle
threshold that decides when a quiet session counts as needing you, and the server-wide
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
[Notifications And Approvals](Notifications-And-Approvals).
### Voice
@@ -142,8 +152,10 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
### System
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
Cloudflare tunnel controls including the tunnel and upload URLs. In multi-user mode, the
**Users** administration entry is injected here.
Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
tools with their versions and install hints (admin only in multi-user mode). In multi-user
mode, the **Users** administration entry is injected here.
## Session Options
@@ -178,6 +190,8 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
## Gotchas
+8 -1
View File
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. Desktop and tablet only. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
@@ -124,6 +124,13 @@ The right side of the header. Almost all of these are off until you enable them
New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide).
## Bottom bar
**Git status** sits at the right of the bottom bar and shows the active session's uncommitted
and unpushed work. It is off by default and per device: turn it on in **App Settings → Header &
Panels → Bottom bar**. Click it for the Git window. See
[Working With Files](Working-With-Files#git-changes).
## Connection state
The dot in the header is the quick read. Two louder surfaces exist because a cached page
+35
View File
@@ -163,6 +163,41 @@ HEIC images from an iPhone are converted to JPEG on the way in.
When an agent produces a file the UI can show (a chart, a diagram, a document), it can
surface as an artifact attachment rather than a path you have to go and find.
## Git changes
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
conflicts, `✓` when everything is committed and pushed.
Click it for a draggable window, in the style of the File Viewer:
- **Uncommitted changes**, grouped as staged, not staged, untracked and conflicted, each with a
status letter (`M` modified, `A` added, `D` deleted, `R` renamed, `?` new, `U` conflict).
- **Not pushed**: the commits no remote has. A branch with no upstream says so, and so does one whose
upstream does not exist on the remote, because it was never pushed or was deleted there ("Upstream
not on remote"), which counts every commit on no remote rather than showing a green tick.
- Files are grouped under their folders, collapsed until you click a folder (a chain of single-child
folders is one row, and the folders you opened stay open when the list refreshes). Turn off
**App Settings → Header & Panels → Bottom bar → Git status: group files by folder** for a flat
list of full paths instead.
- **Click a file** to see what changed in it, as a unified diff with added and removed lines
coloured. Staged files show index versus last commit, not-staged files show working tree
versus index, untracked files show as all additions and deleted files as all removals.
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
note instead, and a diff over 400 KB is cut short.
- A session folder that holds several projects gets one collapsible section per repository
found up to two levels down. They all start collapsed (each summary line shows its branch and
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
in your home folder) is ignored.
It is read-only and offline: Codeman never fetches, commits or changes the repository, so
"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case
workspace is skipped even from a local session (a container can write there, and git would run
that repository's own configuration on the host). The
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
## Gotchas
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
+16 -3
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.33.3",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.33.3",
"version": "1.35.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -32,6 +32,7 @@
"jpeg-js": "^0.4.4",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"smol-toml": "^1.9.0",
"undici": "^6.28.0",
"uuid": "^14.0.0",
"web-push": "^3.6.7",
@@ -10114,6 +10115,18 @@
"npm": ">= 3.0.0"
}
},
"node_modules/smol-toml": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
@@ -12372,7 +12385,7 @@
}
},
"packages/xterm-zerolag-input": {
"version": "0.3.1",
"version": "0.4.0",
"license": "MIT",
"devDependencies": {
"@xterm/headless": "^6.0.0",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.33.3",
"version": "1.35.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -105,6 +105,7 @@
"jpeg-js": "^0.4.4",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"smol-toml": "^1.9.0",
"undici": "^6.28.0",
"uuid": "^14.0.0",
"web-push": "^3.6.7",
+28
View File
@@ -1,5 +1,33 @@
# xterm-zerolag-input
## 0.4.0
### Minor Changes
- 6aecc3b: ### Thanks
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
## 0.3.1
### Patch Changes
+24 -9
View File
@@ -106,10 +106,10 @@ terminal.onData((data) => {
}
});
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink)
terminal.onWriteParsed(() => {
if (zerolag.hasPending) zerolag.rerender();
});
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink).
// Unconditional: rerender() is a no-op when there is nothing to draw, and
// hasPending would miss an overlay that shows only an IME composition.
terminal.onWriteParsed(() => zerolag.rerender());
```
That is the whole integration. Everything below is for tuning it.
@@ -186,7 +186,7 @@ If one terminal hosts several CLIs with different prompts, swap the strategy in
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
```
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
`setPrompt()` clears the cached prompt position and re-renders if the overlay has anything to draw, so a mode switch cannot leave the overlay pinned to the old column.
---
@@ -202,8 +202,9 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|--------|---------|-------------|
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
| `appendText(text)` | `void` | Append multiple characters (paste). |
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
### Backspace handling
@@ -217,6 +218,19 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
### IME composition
While an input method (Japanese kana, Chinese pinyin, Korean) is still composing, the text is not committed yet, so it is not in `pendingText` either. `setComposition(text)` draws it as an underlined, `aria-hidden` tail right after the pending and flushed text, using the same wrapping and on-screen layout as the rest of the overlay.
```typescript
const textarea = terminal.textarea!;
textarea.addEventListener('compositionupdate', (e) => zerolag.setComposition(e.data));
textarea.addEventListener('compositionend', () => zerolag.setComposition(''));
// xterm then emits the committed text through onData: add it with addChar()/appendText() as usual.
```
The composition is visual only: it is never part of `pendingText`, `hasPending` or `state`, so it can never be sent. Control characters and line breaks are stripped from it. `clear()` and `removeChar()` drop it. Because `hasPending` excludes it, re-place the overlay after output or a resize with an unconditional `rerender()`, not one gated on `hasPending`.
### Flushed text
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
@@ -242,7 +256,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
| Method | Description |
|--------|-------------|
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. A no-op when there is nothing to draw, so it needs no guard. |
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
### Prompt
@@ -258,7 +272,8 @@ Finds text that exists after the prompt but was never typed through the overlay.
| Property | Type | Description |
|----------|------|-------------|
| `pendingText` | `string` | Unacknowledged text (read-only) |
| `hasPending` | `boolean` | `true` if the overlay has any content |
| `hasPending` | `boolean` | `true` if there is pending or flushed text. Excludes the IME composition, so it can be `false` while the overlay still shows one |
| `composition` | `string` | The text set by `setComposition()`, `''` when none (read-only) |
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
### Options
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-zerolag-input",
"version": "0.3.1",
"version": "0.4.0",
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
"type": "module",
"main": "dist/index.cjs",
@@ -58,6 +58,7 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
const {
lines,
compositionStart,
startCol,
totalCols,
cellW,
@@ -90,12 +91,24 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
// `startCol` indents only the line that begins at the prompt marker, so it is
// dropped along with that line when the tail is all that fits.
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
// Code-point offset of each line in the whole text, so the composition
// styling survives the tail slice below.
const lineOffsets: number[] = [];
{
let offset = 0;
for (const line of lines) {
lineOffsets.push(offset);
offset += [...line].length;
}
}
let visibleLines = lines;
let firstVisible = 0;
let keepsPromptLine = true;
let topRow = promptRow;
if (rows && rows > 0) {
if (lines.length > rows) {
visibleLines = lines.slice(lines.length - rows);
firstVisible = lines.length - rows;
visibleLines = lines.slice(firstVisible);
keepsPromptLine = false;
topRow = 0;
} else if (promptRow + lines.length > rows) {
@@ -116,7 +129,21 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
const leftPx = indents ? startCol * cellW : 0;
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH;
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
const lineCompositionFrom =
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
const lineEl = makeLine(
visibleLines[i],
leftPx,
topPx,
widthPx,
cellH,
cellW,
charTop,
charHeight,
font,
terminal,
lineCompositionFrom
);
container.appendChild(lineEl);
}
@@ -144,7 +171,10 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
* Create a styled line `<div>` with per-character grid positioning.
*
* Each character gets its own `<span>` positioned by visual column offset.
* CJK wide characters occupy 2 cell widths.
* CJK wide characters occupy 2 cell widths. Characters at or after
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
* composition text: underlined, like xterm's own composition view, and marked
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
*/
function makeLine(
text: string,
@@ -156,7 +186,8 @@ function makeLine(
_charTop: number,
_charHeight: number,
font: FontStyle,
terminal?: XtermTerminal | null
terminal?: XtermTerminal | null,
compositionFrom?: number
): HTMLDivElement {
const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none';
@@ -172,6 +203,7 @@ function makeLine(
// CJK wide chars occupy 2 cells — position by visual column offset
let colOffset = 0;
let index = 0;
for (const ch of text) {
const cw = charCellWidth(terminal, ch);
const span = document.createElement('span');
@@ -189,9 +221,15 @@ function makeLine(
span.style.fontWeight = font.fontWeight;
span.style.color = font.color;
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
if (compositionFrom !== undefined && index >= compositionFrom) {
span.style.textDecoration = 'underline';
span.setAttribute('data-zerolag-composition', '');
span.setAttribute('aria-hidden', 'true');
}
span.textContent = ch;
el.appendChild(span);
colOffset += cw;
index++;
}
return el;
@@ -163,6 +163,12 @@ export interface CellDimensions {
/** Parameters for the overlay renderer. */
export interface RenderParams {
lines: string[];
/**
* Index (in code points, across all `lines`) where IME composition text
* begins. Characters from there on are drawn underlined and marked
* `data-zerolag-composition`. Omit when nothing is being composed.
*/
compositionStart?: number;
startCol: number;
totalCols: number;
cellW: number;
@@ -67,6 +67,8 @@ export class ZerolagInputAddon implements XtermAddon {
private _flushedOffset = 0;
private _flushedText = '';
private _bufferDetectDone = false;
// IME text still being composed: drawn after the pending text, never sent.
private _composition = '';
// Render cache
private _lastRenderKey = '';
@@ -130,7 +132,7 @@ export class ZerolagInputAddon implements XtermAddon {
clearTimeout(this._scrollTimer);
this._scrollTimer = null;
}
} else if (this._pendingText || this._flushedOffset > 0) {
} else if (this._hasContent()) {
if (this._scrollTimer) clearTimeout(this._scrollTimer);
this._scrollTimer = setTimeout(() => {
this._scrollTimer = null;
@@ -206,8 +208,14 @@ export class ZerolagInputAddon implements XtermAddon {
* - `'flushed'`: A character was removed from text already sent to the PTY.
* The consumer SHOULD send backspace to the PTY.
* - `false`: Nothing to remove. The consumer should NOT send backspace.
*
* Any IME composition is dropped in every case, and the overlay is repainted
* without it (hidden when nothing else is left).
*/
removeChar(): 'pending' | 'flushed' | false {
// A backspace that reaches the overlay means no composition is open.
const droppedComposition = this._composition.length > 0;
this._composition = '';
if (this._pendingText.length > 0) {
this._pendingText = this._pendingText.slice(0, -1);
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
@@ -243,6 +251,9 @@ export class ZerolagInputAddon implements XtermAddon {
return 'flushed';
}
// Nothing to remove, but a composition-only overlay is still on screen
// drawing the text dropped above.
if (droppedComposition) this._hide();
return false;
}
@@ -252,6 +263,7 @@ export class ZerolagInputAddon implements XtermAddon {
*/
clear(): void {
this._pendingText = '';
this._composition = '';
this._flushedOffset = 0;
this._flushedText = '';
this._bufferDetectDone = false;
@@ -297,7 +309,7 @@ export class ZerolagInputAddon implements XtermAddon {
clearFlushed(): void {
this._flushedOffset = 0;
this._flushedText = '';
if (this._pendingText) {
if (this._pendingText || this._composition) {
this._render();
} else {
this._hide();
@@ -312,7 +324,7 @@ export class ZerolagInputAddon implements XtermAddon {
* that move the prompt.
*/
rerender(): void {
if (this._pendingText || this._flushedOffset > 0) {
if (this._hasContent()) {
this._lastRenderKey = '';
this._render();
}
@@ -325,7 +337,7 @@ export class ZerolagInputAddon implements XtermAddon {
refreshFont(): void {
this._cacheFont();
this._lastRenderKey = '';
if (this._pendingText || this._flushedOffset > 0) this._render();
if (this._hasContent()) this._render();
}
// ─── Buffer detection ─────────────────────────────────────────────
@@ -391,7 +403,37 @@ export class ZerolagInputAddon implements XtermAddon {
this._options.prompt = finder;
this._lastPromptPos = null;
this._lastRenderKey = '';
if (this._pendingText || this._flushedOffset > 0) this._render();
if (this._hasContent()) this._render();
}
// ─── IME composition ──────────────────────────────────────────────
/**
* Show text an IME is still composing as an underlined tail after the
* pending text, wrapped and kept on screen like the rest of the overlay.
* Pass `''` to remove it.
*
* Visual only: the composition is never part of `pendingText`, `hasPending`
* or anything a consumer sends. When the IME commits, the consumer adds the
* committed text the usual way (`addChar`/`appendText`) and clears the
* composition. `clear()` and `removeChar()` drop it too.
*/
setComposition(text: string): void {
// One visual line of provisional text: control characters and line breaks
// would break the cell grid.
const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
if (next === this._composition) return;
this._composition = next;
if (this._hasContent()) {
this._render();
} else {
this._hide();
}
}
/** Text an IME is still composing, drawn after `pendingText` (never sent). */
get composition(): string {
return this._composition;
}
// ─── Prompt utilities ─────────────────────────────────────────────
@@ -425,7 +467,13 @@ export class ZerolagInputAddon implements XtermAddon {
return this._pendingText;
}
/** Whether there is any overlay content (pending or flushed). */
/**
* Whether there is pending or flushed text. Excludes the IME composition,
* which is never sent, so an overlay showing only a composition reports
* `false` while still on screen. To re-place the overlay after output or a
* resize, call `rerender()` unconditionally: it is a no-op when there is
* nothing to draw.
*/
get hasPending(): boolean {
return this._pendingText.length > 0 || this._flushedOffset > 0;
}
@@ -443,6 +491,10 @@ export class ZerolagInputAddon implements XtermAddon {
// ─── Private methods ──────────────────────────────────────────────
private _hasContent(): boolean {
return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
}
private _getPromptOffset(): number {
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
return prompt.offset ?? 2;
@@ -505,7 +557,7 @@ export class ZerolagInputAddon implements XtermAddon {
private _render(): void {
if (!this._terminal || !this._overlay) return;
if (!this._pendingText && !(this._flushedOffset > 0)) {
if (!this._hasContent()) {
this._overlay.style.display = 'none';
return;
}
@@ -563,12 +615,16 @@ export class ZerolagInputAddon implements XtermAddon {
}
}
// The composition is a styled tail after everything the user has typed.
const compositionStart = [...displayText].length;
displayText += this._composition;
// Skip redundant re-renders — include text content to detect
// same-length changes (e.g., setFlushed with different text)
// `rows` is part of the key: the layout is clamped to the visible rows
// (see renderOverlay), so a keyboard opening — which changes rows without
// changing the text — must not be skipped as a redundant render.
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
this._lastRenderKey = renderKey;
@@ -608,6 +664,7 @@ export class ZerolagInputAddon implements XtermAddon {
renderOverlay(this._overlay, {
lines,
compositionStart: this._composition ? compositionStart : undefined,
startCol,
totalCols,
cellW,
@@ -0,0 +1,235 @@
import { describe, it, expect, afterEach } from 'vitest';
import { createMockTerminal } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
// setComposition(): IME text still being composed, drawn as an underlined tail
// after the pending text. Visual only, never part of what a consumer sends.
const CELL_W = 10;
let cleanups: (() => void)[] = [];
afterEach(() => {
for (const fn of cleanups) fn();
cleanups = [];
});
function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
const mock = createMockTerminal({
buffer: { lines: opts.lines ?? ['$ '] },
cols: opts.cols,
rows: opts.rows,
cellWidth: CELL_W,
cellHeight: 20,
});
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
return { addon, mock, overlay };
}
/** Line divs of the overlay (the block cursor is a bare span, not a div). */
function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
}
function lineText(line: HTMLDivElement): string {
return Array.from(line.children)
.map((s) => s.textContent)
.join('');
}
function compositionText(overlay: HTMLDivElement): string {
return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
.map((s) => s.textContent)
.join('');
}
describe('setComposition', () => {
it('renders the composition after pendingText, underlined and aria-hidden', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
const [line] = lineDivs(overlay);
expect(lineText(line)).toBe('abcxy');
const spans = Array.from(line.children) as HTMLSpanElement[];
for (const span of spans.slice(0, 3)) {
expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
expect(span.style.textDecoration).toBe('');
}
for (const span of spans.slice(3)) {
expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
expect(span.getAttribute('aria-hidden')).toBe('true');
expect(span.style.textDecoration).toBe('underline');
}
// Grid positions continue straight on from the pending text.
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
expect(overlay.style.display).toBe('');
});
it('places a wide composition by cell width after wide pending text', () => {
const { addon, overlay } = setup();
addon.appendText('今日は');
addon.setComposition('天気');
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
});
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
const { addon } = setup();
addon.appendText('abc');
addon.setFlushed(2, 'zz');
addon.setComposition('xy');
expect(addon.pendingText).toBe('abc');
expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
expect(addon.composition).toBe('xy');
expect(addon.state.pendingText).toBe('abc');
expect(addon.state.flushedText).toBe('zz');
});
it('shows on an empty prompt without making anything pending', () => {
const { addon, overlay } = setup();
addon.setComposition('かな');
expect(addon.pendingText).toBe('');
expect(addon.hasPending).toBe(false);
expect(addon.state.visible).toBe(true);
expect(compositionText(overlay)).toBe('かな');
});
it('wraps with the pending text: the tail continues onto the next line', () => {
// 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
const { addon, overlay } = setup({ cols: 12 });
addon.appendText('abcdefgh');
addon.setComposition('WXYZ');
const lines = lineDivs(overlay);
expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
expect(compositionText(overlay)).toBe('WXYZ');
const second = Array.from(lines[1].children) as HTMLSpanElement[];
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
expect(second[0].style.left).toBe('0px');
});
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
// 2 visible rows, 3 lines of text: the first line is dropped.
const { addon, overlay } = setup({ cols: 6, rows: 2 });
addon.appendText('abcdefghij');
addon.setComposition('XYZ');
const lines = lineDivs(overlay);
expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
expect(compositionText(overlay)).toBe('XYZ');
const first = Array.from(lines[0].children);
expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
});
it("setComposition('') removes the tail and keeps the pending text", () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
addon.setComposition('');
expect(lineText(lineDivs(overlay)[0])).toBe('abc');
expect(compositionText(overlay)).toBe('');
expect(addon.pendingText).toBe('abc');
});
it("setComposition('') on an otherwise empty overlay hides it", () => {
const { addon, overlay } = setup();
addon.setComposition('xy');
addon.setComposition('');
expect(overlay.style.display).toBe('none');
expect(overlay.innerHTML).toBe('');
});
it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
addon.clear();
expect(addon.composition).toBe('');
expect(overlay.style.display).toBe('none');
addon.addChar('q');
expect(lineText(lineDivs(overlay)[0])).toBe('q');
});
it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
expect(addon.removeChar()).toBe('pending');
expect(addon.pendingText).toBe('ab');
expect(addon.composition).toBe('');
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
});
it('removeChar() with nothing to remove still takes a composition-only overlay off screen', () => {
const { addon, overlay } = setup();
addon.setComposition('ka');
expect(compositionText(overlay)).toBe('ka');
expect(addon.removeChar()).toBe(false);
expect(addon.composition).toBe('');
expect(compositionText(overlay)).toBe('');
expect(overlay.style.display).toBe('none');
expect(addon.state.visible).toBe(false);
});
it('removeChar() repaints flushed text without the dropped composition', () => {
const { addon, overlay } = setup();
addon.setFlushed(3, 'abc');
addon.setComposition('xy');
expect(addon.removeChar()).toBe('flushed');
expect(compositionText(overlay)).toBe('');
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
});
it('text appended while composing lands before the tail', () => {
const { addon, overlay } = setup();
addon.appendText('ab');
addon.setComposition('xy');
addon.addChar('c');
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
expect(compositionText(overlay)).toBe('xy');
});
it('rerender() and refreshFont() keep the composition', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
addon.rerender();
expect(compositionText(overlay)).toBe('xy');
addon.refreshFont();
expect(compositionText(overlay)).toBe('xy');
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
});
it('re-renders when only the composition changes', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('x');
addon.setComposition('xy');
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
});
it('strips control characters and line breaks from the composition', () => {
const { addon, overlay } = setup();
addon.setComposition('a\nb\u0007c
');
expect(addon.composition).toBe('abc');
expect(compositionText(overlay)).toBe('abc');
});
it('draws the block cursor after the composition', () => {
const { addon, overlay } = setup();
addon.appendText('ab');
addon.setComposition('xy');
const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
// prompt col 0 + offset 2 + 4 cells
expect(cursor.style.left).toBe(6 * CELL_W + 'px');
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.33.3",
"version": "1.35.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
@@ -391,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`advisorModel`, `envOverrides`). Three differences that break copied code:
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
`wait?until=exit` answers `exit` immediately. Follow it with
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
(`session-routes.ts:648`).
(`sessionCapacityMessage()` in `route-helpers.ts`).
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
@@ -95,6 +95,9 @@ Differences from `quick-start` worth knowing before you debug one:
- the id is at `.data.session.id`, not `.data.sessionId`;
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
one that does not answer or cannot be read (an unreachable network mount, a
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
create a replacement for it;
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
`SESSION_BUSY` for the identical condition.
+4
View File
@@ -83,9 +83,11 @@ 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 mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.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 tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.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 tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.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');
@@ -111,8 +113,10 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
'mobile-ime-preview.js',
'terminal-keycode229-recovery.js',
'sanitize-html.js',
'tab-layout-browser.js',
'app.js',
'tab-rail-resize.js',
'terminal-ui.js',
+4 -3
View File
@@ -391,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`advisorModel`, `envOverrides`). Three differences that break copied code:
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
`wait?until=exit` answers `exit` immediately. Follow it with
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
(`session-routes.ts:648`).
(`sessionCapacityMessage()` in `route-helpers.ts`).
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
+3
View File
@@ -95,6 +95,9 @@ Differences from `quick-start` worth knowing before you debug one:
- the id is at `.data.session.id`, not `.data.sessionId`;
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
one that does not answer or cannot be read (an unreachable network mount, a
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
create a replacement for it;
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
`SESSION_BUSY` for the identical condition.
+29
View File
@@ -15,6 +15,7 @@
import { z } from 'zod';
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat } from './types.js';
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
@@ -27,6 +28,14 @@ const envName = z
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
.max(64);
/** A relative file path with no traversal or odd characters (MCP sync writes to it). */
const mcpRelativePath = z
.string()
.min(1)
.max(100)
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
.refine((v) => !v.split('/').includes('..'), 'must not contain ..');
/**
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
@@ -377,6 +386,26 @@ const capabilitiesSchema = z
privilegedEnvKeys: z.array(envName).max(8),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
newline: z.enum(['line-feed', 'esc-enter']).optional(),
mcpConfig: z
.object({
// Home-relative, no traversal: sync writes to this path.
path: mcpRelativePath,
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
// keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([
'claude-json',
'gemini-json',
'codex-toml',
'opencode-json',
'antigravity-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
relocation: z.object({ envVar: envName, path: mcpRelativePath }).strict().optional(),
})
.strict()
.optional(),
customModelInjection: z.discriminatedUnion('kind', [
z
.object({
+35
View File
@@ -11,6 +11,7 @@
*/
import type { CliEntry } from './types.js';
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
const HOME_DIRS = {
local: '~/.local/bin',
@@ -306,6 +307,12 @@ const CLAUDE: CliEntry = {
'CLAUDE_CONFIG_DIR',
],
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
mcpConfig: {
path: '.claude.json',
format: 'claude-json',
relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
},
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
// llama.cpp server. Claude reads these at process start only, so switching requires a
// respawn, never a live hot-swap.
@@ -486,6 +493,12 @@ const OPENCODE: CliEntry = {
...agentDefaults(),
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
format: 'opencode-json',
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
},
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
@@ -532,6 +545,7 @@ const CODEX: CliEntry = {
bypassApprovals: { type: 'bool' },
animations: { type: 'bool' },
model: { type: 'token', pattern: 'model' },
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
resumeId: { type: 'token', pattern: 'id' },
},
variants: [
@@ -543,6 +557,14 @@ const CODEX: CliEntry = {
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
// One literal per level: an argv token cannot splice a value into a literal, and
// `model_reasoning_effort=<level>` is a single `--config` value. The enum above is
// what admits a level, so an unknown one emits nothing.
...CODEX_REASONING_EFFORTS.map((level) => ({
flag: '--config',
value: `model_reasoning_effort=${level}`,
when: { param: 'reasoningEffort', is: level },
})),
{ lit: 'resume', when: { param: 'resumeId', state: 'set' } },
{ valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
],
@@ -620,6 +642,11 @@ const CODEX: CliEntry = {
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
relocation: { envVar: 'CODEX_HOME', path: 'config.toml' },
},
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
// so the user's real ~/.codex/config.toml is never touched.
customModelInjection: {
@@ -721,6 +748,12 @@ const GEMINI: CliEntry = {
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
// sends no geminiConfig at all would still get yolo for free.
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
// gemini-cli's `homedir()` returns `GEMINI_CLI_HOME` when set (packages/core/src/utils/paths.ts).
mcpConfig: {
path: '.gemini/settings.json',
format: 'gemini-json',
relocation: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
},
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
// start). Confirm the exact model-override env var name against the installed
// gemini-cli version before shipping.
@@ -800,6 +833,8 @@ const ANTIGRAVITY: CliEntry = {
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
// SENT config needs the flag forced off — nothing is materialized.
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
// No relocation var: `agy` 1.1.12 resolves `~/.gemini/config` from $HOME only.
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
// custom-endpoint setting and explicitly say it "cannot currently" become the core
// reasoning model. Toolbar entry stays disabled for this mode.
+28
View File
@@ -90,6 +90,12 @@ export interface CliVariant {
args: ArgSpec[];
}
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
export type NewlineSequence = 'line-feed' | 'esc-enter';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
export interface CliLaunch {
params: Record<string, ParamSpec>;
/**
@@ -511,6 +517,28 @@ export interface CliCapabilities {
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
maxFrameBytes?: number;
/**
* The bytes the web UI types into this CLI's pane for Shift+Enter (the `send-key` route).
* `line-feed` (`0x0a`, also what Ctrl+Enter sends) is what Claude Code's Ink input and most TUIs
* read as "insert a newline"; `esc-enter` (`ESC` `CR`, the same chord as Option/Alt+Enter and
* the mobile ⌥Enter key) is for a TUI that ignores a bare line feed. Absent = `line-feed`.
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
*/
newline?: NewlineSequence;
/**
* Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`).
* `path` is relative to the home directory. `format` names the file dialect the sync
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
* skipped by sync rather than guessed at.
*
* `relocation` names the env var the CLI itself reads to move that file (codex's
* `CODEX_HOME`, claude's `CLAUDE_CONFIG_DIR`, opencode's `XDG_CONFIG_HOME`). When the SERVER
* process env (what the CLIs Codeman spawns inherit) sets it to an absolute directory, the
* file is `<that dir>/<relocation.path>` instead; set to anything else, the target is
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
* only follows `$HOME`.
*/
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
+21
View File
@@ -7,6 +7,8 @@
* @module config/dependency-registry
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
import { enabledClis } from './cli-registry/registry.js';
import { compileVersionRegex } from './cli-registry/patterns.js';
@@ -30,6 +32,24 @@ export interface PathResolver {
* there and a false "installed" contradicts the run mode's own resolver.
*/
requireVersionMatch?: boolean;
/**
* Absolute directories to probe (`<dir>/<bin>`) when `which` misses. A service (systemd,
* launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm
* prefix is invisible to `which` while the run mode, which falls back to the registry's
* `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees.
*/
searchDirs?: string[];
}
/**
* Expand a leading `~` (the only form registry `searchDirs` use). Twin of `expandHome()` in
* src/utils/cli-resolver.ts, copied rather than imported because importing it from config/
* would pull in the whole resolver chain; keep the two in step.
*/
function expandSearchDir(dir: string): string {
if (dir === '~') return homedir();
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
return dir;
}
/** Resolve a Windows-installed app reachable from win32 or WSL. */
@@ -131,6 +151,7 @@ function cliDependencyEntries(): ToolDependency[] {
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
// program, so a version mismatch means MISSING rather than unknown-version.
requireVersionMatch: version?.requireVersionMatch,
searchDirs: cli.discovery.searchDirs.map(expandSearchDir),
},
},
],
+48
View File
@@ -0,0 +1,48 @@
/**
* @fileoverview Limits for the bounded path probe (`src/utils/bounded-path-probe.ts`).
*
* A linked case can live on a network mount, and a hard mount that went away makes
* `stat()` wait until the mount comes back. The probe gives up on such a path after
* `PATH_PROBE_TIMEOUT_MS` and answers "unknown", and it stops starting new probes
* once `MAX_STALLED_PATH_PROBES` timed-out stats are still holding libuv threadpool
* workers (the pool is shared by every `fs`, `dns.lookup` and `crypto` call in the
* process, and holds 4 workers unless `UV_THREADPOOL_SIZE` says otherwise).
*
* Both are env-overridable, in the same style as the other config modules. A slow
* but healthy mount (an sshfs that needs a couple of seconds on first touch) may want
* a longer timeout. The stall limits follow `UV_THREADPOOL_SIZE` on their own, so a
* server started with a larger pool gets a higher ceiling without further setup.
*
* @module config/path-probe
*/
function envInt(name: string, fallback: number, min: number, max: number): number {
const raw = parseInt(process.env[name] || '', 10);
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.max(min, Math.min(max, raw));
}
/** How long a caller waits for one path probe before the answer is "unknown". */
export const PATH_PROBE_TIMEOUT_MS = envInt('CODEMAN_PATH_PROBE_TIMEOUT_MS', 1_500, 100, 60_000);
/**
* Hard ceiling on timed-out probes left pending, for every caller, `pastCap` ones
* included: the threadpool size minus one, so a dead mount can never take the last
* worker. libuv sizes the pool from `UV_THREADPOOL_SIZE` (4 when unset). A pool of
* one cannot keep a worker free at all, so the ceiling never drops below one.
*/
export const PATH_PROBE_STALL_CEILING = Math.max(1, (Number(process.env.UV_THREADPOOL_SIZE) || 4) - 1);
/**
* Timed-out probes allowed to stay pending before new BULK probes are refused
* (answered "unknown" without a stat). This is a backstop, not the main defence: a
* stalled path on a network or FUSE mount already takes the rest of that mount out
* of probing (a stall anywhere else takes out only the stalled path), so the cap
* only engages once that many UNRELATED places have stopped answering. It defaults
* to one below {@link PATH_PROBE_STALL_CEILING} (2 with the default pool), leaving a
* slot a `pastCap` probe may still use, and is never allowed above the ceiling.
*/
export const MAX_STALLED_PATH_PROBES = Math.min(
PATH_PROBE_STALL_CEILING,
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
);
+761
View File
@@ -0,0 +1,761 @@
/**
* @fileoverview "What has this session's workspace not committed or pushed?": a read-only git
* snapshot of a session's working directory, for the bottom-bar Git indicator and its panel
* (`GET /api/sessions/:id/git-status`). Agents leave work uncommitted and unpushed; this makes that
* visible without leaving Codeman.
*
* Split so the parts that matter test without a repo:
* - pure: `parsePorcelainV2` (status output → branch, upstream, ahead/behind, per-file entries),
* `parseCommitLog`
* - IO: `getGitWorkspaceStatus` (a handful of async, bounded, read-only `git` calls), with a short
* single-flight cache so several tabs polling one repo cost one set of git processes
*
* WHICH repositories. `getGitWorkspaceOverview` answers for the session's working directory:
* - inside a repository (or at its root): that one repository. git finds it by walking UP, so a
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
* to the outer one, and is not scanned;
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
* DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following
* symlinks), each reported separately;
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
*
* Rules the code keeps and the tests pin:
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
* refreshing the index, so polling cannot contend with the agent's own git commands.
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
* shell: the working directory is the process `cwd`, and the only operand-like input is a fixed
* revision range.
* - Output is capped: the counts are exact, the lists are not (`filesTruncated`).
* - git can run helpers a repository configures: a clean filter (`filter.<name>.clean`) still runs
* during `git status` and `git diff`, as it does for any `git status`. A LOCAL session already
* runs as this same OS user, so polling adds no privilege there. What is turned off: the
* filesystem monitor (`core.fsmonitor`), external diff and textconv drivers, and the signature
* program (`log.showSignature`). A repository a container can write to is NOT inspected: a
* Docker session answers `unsupported`, and any repository whose root is, or is inside, a Docker
* case workspace is dropped from the walk-up, the scan below a folder, and the diff route, because
* the container could have planted that config and git here would run it on the host.
* - Remote URLs and git's stderr can embed `user:token@host`; anything that reaches a client goes
* through `redactGitCredentials`.
*
* @module git-workspace-status
*/
import { execFile } from 'node:child_process';
import { promises as fs } from 'node:fs';
import { homedir } from 'node:os';
import { basename, join, relative, sep } from 'node:path';
import { promisify } from 'node:util';
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT_MS = 10_000;
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
/** Max file rows returned. The counts stay exact. */
export const MAX_FILES = 300;
/** Max unpushed commits listed. The count stays exact. */
export const MAX_COMMITS = 50;
/** A fresh-enough result is reused, so N tabs on one repo cost one set of git calls. */
const CACHE_TTL_MS = 4000;
const CACHE_MAX_ENTRIES = 64;
export type GitFileKind = 'staged' | 'unstaged' | 'untracked' | 'conflicted';
export interface GitFileEntry {
/** Path relative to the repository root, as git reports it. */
path: string;
/** Rename/copy source, when the entry is one. */
origPath?: string;
/** Status letter in the index (`M`, `A`, `D`, `R`, `C`, `T`, `.`). */
index: string;
/** Status letter in the working tree (`M`, `D`, `T`, `.`, ...). `?` for untracked. */
worktree: string;
kind: GitFileKind;
}
export interface GitCommitEntry {
hash: string;
author: string;
/** Seconds since the epoch. */
time: number;
subject: string;
}
export interface GitWorkspaceStatus {
/**
* `ok`: a repository, the rest of the fields are meaningful. `not-a-repo`: nothing to show.
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
*/
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
error?: string;
repoRoot?: string;
/** Null when HEAD is detached. */
branch: string | null;
detached: boolean;
upstream: string | null;
/**
* The configured upstream does not exist on the remote (deleted and pruned, or never pushed, as after
* cloning an empty repository and committing): nothing is tracked.
*/
upstreamGone: boolean;
ahead: number;
/** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */
behind: number;
/** Whether the repository has any remote at all. */
hasRemote: boolean;
counts: {
staged: number;
unstaged: number;
untracked: number;
conflicted: number;
/** Distinct paths that are not committed. */
uncommitted: number;
stashes: number;
};
files: GitFileEntry[];
filesTruncated: boolean;
/** Commits on this branch that no remote has: exact. */
unpushedCount: number;
unpushed: GitCommitEntry[];
checkedAt: number;
}
const EMPTY: Omit<GitWorkspaceStatus, 'state' | 'checkedAt'> = {
branch: null,
detached: false,
upstream: null,
upstreamGone: false,
ahead: 0,
behind: 0,
hasRemote: false,
counts: { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 },
files: [],
filesTruncated: false,
unpushedCount: 0,
unpushed: [],
};
export const emptyStatus = (
state: GitWorkspaceStatus['state'],
extra: Partial<GitWorkspaceStatus> = {}
): GitWorkspaceStatus => ({ ...EMPTY, counts: { ...EMPTY.counts }, state, checkedAt: Date.now(), ...extra });
// ---------------------------------------------------------------------------
// Pure parsing
// ---------------------------------------------------------------------------
export interface ParsedStatus {
branch: string | null;
detached: boolean;
upstream: string | null;
/** `# branch.upstream` was printed but `# branch.ab` was not: no such remote branch (deleted and pruned, or never pushed). */
upstreamGone: boolean;
ahead: number;
behind: number;
files: GitFileEntry[];
}
/**
* Parse `git status --porcelain=v2 --branch -z`. Entries are NUL-separated and paths are NOT quoted,
* so a name with spaces, quotes or a newline arrives intact. A rename/copy (`2 ...`) is followed by
* one more NUL-terminated token holding the original path.
*/
export function parsePorcelainV2(text: string): ParsedStatus {
const out: ParsedStatus = {
branch: null,
detached: false,
upstream: null,
upstreamGone: false,
ahead: 0,
behind: 0,
files: [],
};
let sawAb = false;
const tokens = text.split('\0');
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (!t) continue;
if (t.startsWith('# ')) {
const [key, ...rest] = t.slice(2).split(' ');
const value = rest.join(' ');
if (key === 'branch.head') {
out.detached = value === '(detached)';
out.branch = out.detached ? null : value;
} else if (key === 'branch.upstream') {
out.upstream = value;
} else if (key === 'branch.ab') {
sawAb = true;
const m = /^\+(\d+) -(\d+)$/.exec(value);
if (m) {
out.ahead = Number(m[1]);
out.behind = Number(m[2]);
}
}
continue;
}
const type = t[0];
if (type === '1') {
// 1 XY sub mH mI mW hH hI path
const f = t.split(' ');
const xy = f[1] ?? '..';
out.files.push(...entriesFor(xy, f.slice(8).join(' ')));
} else if (type === '2') {
// 2 XY sub mH mI mW hH hI Xscore path <NUL> origPath
const f = t.split(' ');
const xy = f[1] ?? '..';
const path = f.slice(9).join(' ');
const origPath = tokens[++i] ?? '';
out.files.push(...entriesFor(xy, path, origPath));
} else if (type === 'u') {
// u XY sub m1 m2 m3 mW h1 h2 h3 path
const f = t.split(' ');
out.files.push({
path: f.slice(10).join(' '),
index: f[1]?.[0] ?? 'U',
worktree: f[1]?.[1] ?? 'U',
kind: 'conflicted',
});
} else if (type === '?') {
out.files.push({ path: t.slice(2), index: '?', worktree: '?', kind: 'untracked' });
}
// '!' (ignored) is not requested; anything unknown is skipped rather than guessed at.
}
out.upstreamGone = out.upstream !== null && !sawAb;
return out;
}
/** One porcelain entry can be both staged AND modified in the tree: that is two rows, one per kind. */
function entriesFor(xy: string, path: string, origPath?: string): GitFileEntry[] {
const index = xy[0] ?? '.';
const worktree = xy[1] ?? '.';
const rows: GitFileEntry[] = [];
const base = origPath ? { path, origPath } : { path };
if (index !== '.') rows.push({ ...base, index, worktree, kind: 'staged' });
if (worktree !== '.') rows.push({ ...base, index, worktree, kind: 'unstaged' });
return rows;
}
/** Parse `git log --format=%h%x1f%an%x1f%ct%x1f%s%x1e`. */
export function parseCommitLog(text: string): GitCommitEntry[] {
const out: GitCommitEntry[] = [];
for (const record of text.split('\x1e')) {
const r = record.replace(/^\n+/, '');
if (!r) continue;
const [hash, author, time, ...subject] = r.split('\x1f');
if (!hash) continue;
out.push({ hash, author: author ?? '', time: Number(time) || 0, subject: subject.join('\x1f') });
}
return out;
}
// ---------------------------------------------------------------------------
// IO
// ---------------------------------------------------------------------------
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
export type GitRunner = (cwd: string, args: string[]) => Promise<string>;
export const runGit: GitRunner = async (cwd, args) => {
const { stdout } = await execFileAsync(
'git',
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
// consult a filesystem monitor on behalf of a poll. log.showSignature=false: `git log` must not run
// a configured gpg.program to verify signatures.
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
{
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: MAX_OUTPUT_BYTES,
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
}
);
return stdout;
};
function describeFailure(err: unknown): { notARepo: boolean; message: string } {
const e = err as { code?: unknown; stderr?: unknown; message?: string };
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
if (/not a git repository/i.test(stderr)) return { notARepo: true, message: '' };
if (e.code === 'ENOENT') return { notARepo: false, message: 'git is not installed (or the folder no longer exists)' };
if (e.code === 'ETIMEDOUT' || (err as { killed?: boolean }).killed)
return { notARepo: false, message: 'git timed out' };
const text = (stderr || e.message || 'git failed').trim().split('\n')[0];
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
}
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
let statusText: string;
try {
statusText = await git(cwd, [
'status',
'--porcelain=v2',
'--branch',
'-z',
'--untracked-files=normal',
'--ignore-submodules=dirty',
]);
} catch (err) {
const f = describeFailure(err);
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
}
const parsed = parsePorcelainV2(statusText);
const safe = async (args: string[]): Promise<string> => {
try {
return await git(cwd, args);
} catch {
return '';
}
};
// A configured upstream whose remote branch is gone has no `branch.ab`, and `@{upstream}` no longer
// resolves: treat it as no usable upstream rather than letting the failed rev-list read as 0.
const hasUpstream = parsed.upstream !== null && !parsed.upstreamGone;
// With an upstream: what is ahead of it. Without one (a branch never pushed, a detached HEAD, or an
// upstream that is gone): what is on HEAD but on no remote-tracking ref at all.
const range = hasUpstream ? ['@{upstream}..HEAD'] : ['HEAD', '--not', '--remotes'];
const [root, remotes, stash, countText, logText] = await Promise.all([
safe(['rev-parse', '--show-toplevel']),
safe(['remote']),
safe(['stash', 'list', '--format=%gd']),
safe(['rev-list', '--count', ...range]),
safe(['log', `--max-count=${MAX_COMMITS}`, '--format=%h%x1f%an%x1f%ct%x1f%s%x1e', ...range]),
]);
const hasRemote = remotes.trim().length > 0;
// A repository with no remote has nothing to push to, so "unpushed" would be every commit it has.
const unpushedCount = hasUpstream || hasRemote ? Number(countText.trim()) || 0 : 0;
const unpushed = unpushedCount > 0 ? parseCommitLog(logText) : [];
const counts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
const distinct = new Set<string>();
for (const f of parsed.files) {
counts[f.kind]++;
distinct.add(f.path);
}
counts.uncommitted = distinct.size;
counts.stashes = stash.split('\n').filter(Boolean).length;
return {
state: 'ok',
repoRoot: root.trim() || undefined,
branch: parsed.branch,
detached: parsed.detached,
upstream: parsed.upstream,
upstreamGone: parsed.upstreamGone,
ahead: parsed.ahead,
behind: parsed.behind,
hasRemote,
counts,
files: parsed.files.slice(0, MAX_FILES),
filesTruncated: parsed.files.length > MAX_FILES,
unpushedCount,
unpushed,
checkedAt: Date.now(),
};
}
interface CacheEntry<T> {
at: number;
value?: T;
inflight?: Promise<T>;
}
const cache = new Map<string, CacheEntry<GitWorkspaceStatus>>();
/** For tests. */
export function clearGitStatusCache(): void {
cache.clear();
toplevelCache.clear();
discoveryCache.clear();
}
/**
* `compute()` for `key`, single-flight and briefly cached: concurrent callers share the computation in
* flight, and a result younger than `CACHE_TTL_MS` is reused. `fresh` skips the reuse (a person pressed
* Refresh and expects the truth) but still joins a computation that is already running, which is as
* current as a new one would be.
*/
async function singleFlight<T>(
map: Map<string, CacheEntry<T>>,
key: string,
opts: { now: () => number; fresh?: boolean },
compute: () => Promise<T>
): Promise<T> {
const hit = map.get(key);
if (hit?.inflight) return hit.inflight;
if (!opts.fresh && hit?.value !== undefined && opts.now() - hit.at < CACHE_TTL_MS) return hit.value;
const inflight = compute();
map.set(key, { at: opts.now(), inflight });
try {
const value = await inflight;
map.set(key, { at: opts.now(), value });
if (map.size > CACHE_MAX_ENTRIES) {
for (const [k, v] of map) {
if (map.size <= CACHE_MAX_ENTRIES) break;
if (k !== key && !v.inflight) map.delete(k);
}
}
return value;
} catch (err) {
map.delete(key);
throw err;
}
}
/**
* The git snapshot of `cwd`. Concurrent callers share one in-flight computation, and a result younger
* than a few seconds is reused, so several tabs polling one repo cost one set of git processes.
* `fresh` skips the reuse but still joins a computation already running (see `singleFlight`).
*/
export async function getGitWorkspaceStatus(
cwd: string,
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
): Promise<GitWorkspaceStatus> {
const git = opts.git ?? runGit;
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
}
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
const toplevelCache = new Map<string, CacheEntry<RepoToplevel>>();
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
function enclosingRepoRoot(
cwd: string,
opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
): Promise<RepoToplevel> {
const git = opts.git ?? runGit;
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
try {
const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim();
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
} catch (err) {
const f = describeFailure(err);
return f.notARepo ? { state: 'not-a-repo' } : { state: 'error', error: f.message };
}
});
}
// ---------------------------------------------------------------------------
// Which repositories: the overview
// ---------------------------------------------------------------------------
/** How far below the working directory to look for repositories (`cwd/a/b` is found, `cwd/a/b/c` is not). */
const DISCOVERY_MAX_DEPTH = 2;
/** Directory entries inspected per folder (after sorting), so a folder with thousands of children stays cheap. */
const DISCOVERY_MAX_ENTRIES = 300;
/** Repositories reported for one workspace. */
export const MAX_REPOS = 12;
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
const DISCOVERY_TTL_MS = 30_000;
/** Folders that are never worth descending into when looking for projects. */
const DISCOVERY_SKIP = new Set(['node_modules', 'dist', 'build', 'target', '__pycache__', 'venv', 'vendor']);
/** Status calls in flight at once for one overview: each is several git processes. */
const STATUS_CONCURRENCY = 4;
export interface GitRepoEntry {
/** Folder name of the repository (its root's basename). */
name: string;
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
path: string;
status: GitWorkspaceStatus;
}
export interface GitWorkspaceOverview {
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
error?: string;
repos: GitRepoEntry[];
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
reposTruncated: boolean;
checkedAt: number;
}
export const emptyOverview = (
state: GitWorkspaceOverview['state'],
extra: Partial<GitWorkspaceOverview> = {}
): GitWorkspaceOverview => ({ state, repos: [], reposTruncated: false, checkedAt: Date.now(), ...extra });
const realOr = async (p: string): Promise<string> => {
try {
return await fs.realpath(p);
} catch {
return p;
}
};
/** Real paths of `dirs` (a Docker case workspace may be reached through a symlink). */
const realAll = (dirs: string[]): Promise<string[]> => Promise.all(dirs.map(realOr));
const isWithin = (child: string, root: string): boolean => child === root || child.startsWith(root + sep);
/**
* True when `path` is, or is inside, any of the (already real) `roots`. Used for Docker case
* workspaces: a container can write there, so git must not run on its behalf on the host.
*/
export async function isInsideAny(path: string, realRoots: string[]): Promise<boolean> {
if (!realRoots.length) return false;
const real = await realOr(path);
return realRoots.some((r) => isWithin(real, r));
}
/**
* True when `repoRoot` is a repository that merely contains the workspace and is the home folder or
* above it (`$HOME` managed as a dotfiles repo, `/`, `/home`): its changes are not the session's work.
* A workspace that IS the repository root is never "unrelated", even when that root is the home folder.
*/
export async function isUnrelatedAncestor(repoRoot: string, cwd: string, home: string): Promise<boolean> {
const [root, here, h] = await Promise.all([realOr(repoRoot), realOr(cwd), realOr(home)]);
if (root === here) return false;
return root === sep || h === root || h.startsWith(root + sep);
}
async function hasDotGit(dir: string): Promise<boolean> {
try {
await fs.lstat(join(dir, '.git')); // a directory, or a file (worktrees and submodules)
return true;
} catch {
return false;
}
}
/** Most directory entries READ from one folder before sorting and slicing, so the scan of a huge folder is bounded. */
const DISCOVERY_MAX_SCAN = 5000;
/** Up to `DISCOVERY_MAX_SCAN` entries of `dir` (null when unreadable). */
async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] | null> {
let handle;
try {
handle = await fs.opendir(dir);
} catch {
return null;
}
const out: import('node:fs').Dirent[] = [];
try {
for await (const e of handle) {
out.push(e);
if (out.length >= DISCOVERY_MAX_SCAN) break;
}
} catch {
/* a folder that fails mid-read: use what was read */
} finally {
await handle.close().catch(() => {});
}
return out;
}
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
export async function discoverChildRepos(
cwd: string,
excludeRealRoots: string[] = []
): Promise<{ dirs: string[]; truncated: boolean }> {
const found: string[] = [];
let level = [cwd];
for (let depth = 1; depth <= DISCOVERY_MAX_DEPTH && level.length > 0; depth++) {
const next: string[] = [];
for (const dir of level) {
const entries = await readDirBounded(dir);
if (!entries) continue;
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
entries.length = Math.min(entries.length, DISCOVERY_MAX_ENTRIES);
for (const e of entries) {
// isDirectory() is false for a symlink, which is how a link to elsewhere is never followed.
if (!e.isDirectory() || e.name.startsWith('.') || DISCOVERY_SKIP.has(e.name)) continue;
const child = join(dir, e.name);
// A Docker case workspace (or anything inside one) is never inspected, nor descended into.
if (await isInsideAny(child, excludeRealRoots)) continue;
if (await hasDotGit(child)) found.push(child);
else next.push(child);
}
}
level = next;
}
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
}
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
export interface GitOverviewOptions {
git?: GitRunner;
now?: () => number;
fresh?: boolean;
home?: string;
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
dockerWorkspaces?: string[];
}
type WorkspaceRepos =
| { kind: 'docker' }
| { kind: 'error'; error: string }
| { kind: 'enclosing'; root: string }
| { kind: 'children'; dirs: string[]; truncated: boolean };
/**
* WHICH repositories belong to the workspace (the module header has the rules), without a full
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
* below the folder. The overview and the diff route both go through here, so they cannot disagree.
*/
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
const now = opts.now ?? Date.now;
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
// could carry config (a clean filter) that runs on the host.
if (await isInsideAny(cwd, dockerRoots)) return { kind: 'docker' };
// The enclosing repository is identified before its full status runs, so an unrelated one above the
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
// repositories below.
const top = await enclosingRepoRoot(cwd, opts);
if (top.state === 'error') return { kind: 'error', error: top.error };
if (top.state === 'ok') {
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
if (!(await isUnrelatedAncestor(top.root, cwd, opts.home ?? homedir())))
return { kind: 'enclosing', root: top.root };
}
// Not inside a repository of this workspace: look below for projects.
const hit = discoveryCache.get(cwd);
let found: { dirs: string[]; truncated: boolean };
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
else {
found = await discoverChildRepos(cwd, dockerRoots);
discoveryCache.set(cwd, { at: now(), value: found });
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
}
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
const dirs: string[] = [];
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
return { kind: 'children', dirs, truncated: found.truncated };
}
/**
* Everything git knows about the session's workspace: the enclosing repository when there is one,
* otherwise each repository found below the working directory. See the module header for the rules.
*/
export async function getGitWorkspaceOverview(
cwd: string,
opts: GitOverviewOptions = {}
): Promise<GitWorkspaceOverview> {
const where = await resolveWorkspaceRepos(cwd, opts);
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
if (where.kind === 'enclosing') {
const primary = await getGitWorkspaceStatus(cwd, opts);
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
const root = primary.repoRoot ?? where.root;
return {
state: 'ok',
repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
reposTruncated: false,
checkedAt: primary.checkedAt,
};
}
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
const repos: GitRepoEntry[] = [];
where.dirs.forEach((dir, i) => {
const status = statuses[i];
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
});
if (!repos.length) return emptyOverview('not-a-repo');
return { state: 'ok', repos, reposTruncated: where.truncated, checkedAt: Date.now() };
}
/**
* `repo` when it is the root of one of the repositories the overview reports for `cwd` (the same rules
* and caches, and the Docker roots as they are now), else null. The diff route checks a requested
* repository with this rather than recomputing every repository's status.
*/
export async function findWorkspaceRepo(
cwd: string,
repo: string,
opts: GitOverviewOptions = {}
): Promise<string | null> {
const where = await resolveWorkspaceRepos(cwd, opts);
const roots = where.kind === 'enclosing' ? [where.root] : where.kind === 'children' ? where.dirs : [];
// git reports a repository root with symlinks resolved; a discovered folder may be reached through one.
for (const root of roots) if (root === repo || (await realOr(root)) === repo) return repo;
return null;
}
// ── Per-file diff ──────────────────────────────────────────────────────────
/** Longest diff handed to the browser; beyond this it is cut at a line boundary and flagged. */
export const MAX_DIFF_BYTES = 400 * 1024;
export interface GitFileDiff {
/** Unified diff text (empty when git reports no textual change, e.g. a mode-only edit shows its header). */
diff: string;
truncated: boolean;
binary: boolean;
}
/** A repo-relative path git reported, minus anything that could escape the repo. (A leading `-` is fine: every operand follows `--`.) */
export function isSafeRepoRelativePath(p: string): boolean {
if (!p || p.length > 4096 || p.includes('\0') || p.startsWith('/')) return false;
return !p.split('/').includes('..');
}
/**
* The diff of one changed file, as the panel's rows describe it: `staged` is index vs HEAD,
* `unstaged`/`conflicted` is working tree vs index (a conflict shows git's combined diff), and
* `untracked` is the whole file as additions. Read-only. `--no-ext-diff --no-textconv` stop the external
* diff and textconv drivers a repository configures; a clean filter still runs, as it does for any
* `git diff`, which is why a container-writable repository never reaches this function.
*/
export async function getGitFileDiff(
repoRoot: string,
file: { path: string; origPath?: string; kind: GitFileKind },
opts: { git?: GitRunner } = {}
): Promise<GitFileDiff> {
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
throw new Error('Invalid path');
}
const git = opts.git ?? runGit;
const base = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '-U3'];
let args: string[];
if (file.kind === 'untracked') args = [...base, '--no-index', '--', '/dev/null', file.path];
else {
const paths = file.origPath ? [file.origPath, file.path] : [file.path];
args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
}
let out: string;
let cutShort = false;
try {
out = await git(repoRoot, args);
} catch (err) {
const e = err as { code?: unknown; stdout?: unknown };
// `--no-index` exits 1 when the files differ, which is the normal case for it.
if (file.kind === 'untracked' && e.code === 1 && typeof e.stdout === 'string') out = e.stdout;
// A diff past runGit's output bound: git was stopped, and what it printed so far is cut below like
// any oversized diff.
else if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && typeof e.stdout === 'string') {
out = e.stdout;
cutShort = true;
} else throw err;
}
const binary = /^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out);
if (out.length <= MAX_DIFF_BYTES) return { diff: out, truncated: cutShort, binary };
const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
}
+85 -14
View File
@@ -30,7 +30,6 @@
*/
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
@@ -40,6 +39,52 @@ import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
import { isNearStalledPath, probePath } from './utils/index.js';
/**
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
* which gives up after a timeout and answers "unknown", this waits for the real
* answer: only ENOENT reads as absent, anything else throws, so a
* stalled or unreadable workspace can never be mistaken for an empty one and
* have its settings recreated over the top. It is async, so a dead mount ties
* up a threadpool worker rather than the event loop.
*/
async function pathExistsForWrite(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
}
/**
* Probe a path a per-spawn helper is about to touch. An "unknown" that is NOT near a
* stalled probe (the bulk cap refused it, or the stat failed with something other
* than ENOENT) gets ONE more bounded probe past the bulk cap, so a healthy path still
* answers while unrelated mounts are dead. Whatever is still "unknown" after that
* must be skipped by the caller, never touched with an unbounded `lstat`/`readFile`:
* on a dead mount those never settle, and each would hold a threadpool worker the
* probe's ceiling does not count.
*/
async function probeBeforeTouching(path: string) {
const state = await probePath(path);
if (state !== 'unknown' || isNearStalledPath(path)) return state;
return probePath(path, { pastCap: true });
}
/**
* Whether a READ-side helper should leave `path` alone: it is definitely absent, or
* it did not answer (a mount that is not responding, a refused probe, an unreadable
* path). See `probeBeforeTouching` for why "unknown" is a skip.
*/
async function absentOrUnreachable(path: string): Promise<'absent' | 'unreachable' | false> {
const state = await probeBeforeTouching(path);
if (state === 'absent') return 'absent';
if (state === 'unknown') return 'unreachable';
return false;
}
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
@@ -558,7 +603,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
if (keysToRemove.length === 0) return;
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
if (!existsSync(settingsPath)) return;
if (!(await pathExistsForWrite(settingsPath))) return;
let existing: Record<string, unknown>;
try {
@@ -590,7 +635,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
*/
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}
@@ -621,7 +666,7 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
*/
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}
@@ -650,7 +695,7 @@ export async function updateCaseModel(casePath: string, model: string | null): P
*/
export async function writeHooksConfig(casePath: string): Promise<void> {
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}
@@ -698,7 +743,7 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
*/
export async function ensureCodemanHooks(casePath: string): Promise<void> {
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}
@@ -738,7 +783,7 @@ export async function ensureCodemanHooks(casePath: string): Promise<void> {
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
*/
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
if (!existsSync(join(casePath, '.claude', 'settings.local.json'))) return;
if (await absentOrUnreachable(join(casePath, '.claude', 'settings.local.json'))) return;
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
let existing: Record<string, unknown>;
try {
@@ -820,7 +865,17 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
*/
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
try {
if (!existsSync(workspace)) return;
// "absent" stays absent: the install below would mkdir -p a deleted repo back
// into being. "unknown" is skipped too, never asked again with an unbounded
// lstat (see probeBeforeTouching).
const state = await probeBeforeTouching(workspace);
if (state === 'absent') return;
if (state === 'unknown') {
console.warn(
`[hooks] ${workspace} is not responding or not readable (unreachable mount?); Codeman hooks not checked or installed`
);
return;
}
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
} catch {
@@ -883,7 +938,7 @@ export function generateStatusLineCommand(): string {
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
if (await pathExistsForWrite(settingsPath)) {
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
@@ -898,7 +953,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
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 });
if (!(await pathExistsForWrite(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)
@@ -957,7 +1012,7 @@ function statusLineExporterScriptContent(): string {
}
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
if (!existsSync(settingsPath)) return undefined;
if (await absentOrUnreachable(settingsPath)) return undefined;
try {
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
const current = parsed.statusLine as { command?: unknown } | undefined;
@@ -1031,7 +1086,11 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
// windows in which Claude Code could run an empty or non-executable file.
// rename() swaps the complete, already-executable file in atomically.
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
// name let the first rename consume the others' temp file, and their ENOENT
// dropped those sessions from tmux to the direct-PTY fallback.
const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmpPath, desired);
await chmod(tmpPath, 0o755);
await rename(tmpPath, scriptPath);
@@ -1099,9 +1158,21 @@ export async function resolveStatusLineCliCommand(
): Promise<string | undefined> {
const settingsPath = join(casePath, '.claude', 'settings.local.json');
let userHasOwnStatusLine = false;
if (existsSync(settingsPath)) {
const skip = await absentOrUnreachable(settingsPath);
// Unreachable: whether the user configured their own statusLine there cannot be
// told, and this must never override a real one, so inject nothing.
if (skip === 'unreachable') return undefined;
if (!skip) {
let raw: string;
try {
const existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
raw = await readFile(settingsPath, 'utf-8');
} catch (err) {
// Gone since the probe: nothing to respect. Unreadable: same reason as above.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return undefined;
raw = '';
}
try {
const existing = raw ? JSON.parse(raw) : {};
const current = existing.statusLine as { command?: unknown } | undefined;
if (current && typeof current.command === 'string') {
if (current.command.includes(STATUSLINE_MARKER)) {
+679
View File
@@ -0,0 +1,679 @@
/**
* @fileoverview MCP server sync between the enabled agent CLIs.
*
* Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig`
* names the file and the dialect). This module reads every participating CLI's list into one
* neutral shape, and adds any server a CLI is missing from the others. The whole feature is
* opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools'
* own user config.
*
* Deliberately conservative:
* - ADDITIVE only. A server already present under a name (in ANY shape, even one this module
* does not understand) is never rewritten and nothing is ever removed. Same name with a
* different definition is reported as a conflict and left alone.
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
* switch it on in every other CLI.
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
* duplicate table) is never written, and a write is only made after the NEW text has been
* parsed again and every added server comes back as intended.
* - Only the MCP table is touched; every other key in the file is preserved. Files are
* re-read immediately before the write and replaced via tmp+rename next to the REAL target
* (a symlinked dotfile stays a symlink), with the old file kept as `<file>.codeman-bak`
* (overwritten by each sync).
* - Copied servers can carry secrets in `env`/`headers`: a file that receives any is left
* readable by its owner only.
* - Servers a dialect cannot express (SSE for codex) are skipped and reported.
* - Only one apply runs at a time.
* - A CLI whose file was moved by its own env var (`mcpConfig.relocation`: `CODEX_HOME`,
* `CLAUDE_CONFIG_DIR`, ...) is followed there, as the SERVER env sets it; a relative value
* cannot be located safely, so that target is reported `skipped` and never written.
*
* The result types (src/types/mcp-sync.ts) never carry env values or headers: those commonly
* hold secrets and the result is returned over HTTP. For the same reason a parse failure is
* reported by position only (`describeMcpSyncError`): parsers quote the offending source.
*
* @module mcp-sync
*/
import { promises as fs } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import { parse as parseToml, TomlError } from 'smol-toml';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
export type McpFormat = McpConfigFormat;
export interface McpServer {
transport: 'stdio' | 'http' | 'sse';
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
/** Switched off in the CLI that defines it. Never propagated. */
disabled?: boolean;
}
export type McpServerMap = Record<string, McpServer>;
export interface McpSyncTarget {
id: string;
label: string;
/** Home-relative default location of the config file. */
path: string;
format: McpFormat;
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
relocation?: { envVar: string; path: string };
/** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */
installed: boolean;
}
/** A second apply was requested while one was running. */
export class McpSyncBusyError extends Error {
constructor() {
super('An MCP sync is already running');
this.name = 'McpSyncBusyError';
}
}
/**
* An error whose message this module wrote itself. It names keys Codeman chose and server names
* (which the result reports anyway), never a value from the file, so it may be shown as is.
*/
class McpConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'McpConfigError';
}
}
/**
* What a target's `error` may say. A parser's own message can quote the file: smol-toml's
* `TomlError` carries a code frame of the offending line and the one before it, and V8's JSON
* "Unexpected token" errors quote about ten characters of source. These files hold env values
* and headers and the result goes over HTTP, so a parse failure is reported by position only,
* an errno failure by Node's own message (code, syscall and path: no file content), and anything
* else by a fixed category.
*/
function describeMcpSyncError(err: unknown): string {
if (err instanceof McpConfigError) return err.message;
if (err instanceof TomlError) return `not valid TOML (line ${err.line}, column ${err.column})`;
if (err instanceof SyntaxError) {
const lc = /\(line (\d+) column (\d+)\)/.exec(err.message);
if (lc) return `not valid JSON (line ${lc[1]}, column ${lc[2]})`;
const pos = /at position (\d+)/.exec(err.message);
return pos ? `not valid JSON (position ${pos[1]})` : 'not valid JSON';
}
const code = (err as NodeJS.ErrnoException | null)?.code;
if (err instanceof Error && typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return err.message;
return 'unexpected error';
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
/** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */
const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
/** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */
function dict<T>(): Record<string, T> {
return Object.create(null) as Record<string, T>;
}
/** Own, safe keys of an untrusted table. */
function safeKeys(table: Record<string, unknown>): string[] {
return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k));
}
function strMap(v: unknown): Record<string, string> | undefined {
if (!isRecord(v)) return undefined;
const out = dict<string>();
for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string;
return Object.keys(out).length ? out : undefined;
}
function strArr(v: unknown): string[] | undefined {
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : undefined;
}
/** Drop undefined/empty fields so equal servers compare equal. */
function clean(s: McpServer): McpServer {
const out: McpServer = { transport: s.transport };
if (s.command) out.command = s.command;
if (s.args?.length) out.args = s.args;
if (s.env && Object.keys(s.env).length) out.env = s.env;
if (s.cwd) out.cwd = s.cwd;
if (s.url) out.url = s.url;
if (s.headers && Object.keys(s.headers).length) out.headers = s.headers;
if (s.disabled) out.disabled = true;
return out;
}
const sortedEntries = (m: Record<string, string> | undefined): [string, string][] =>
Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
/** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */
function fingerprint(s: McpServer): string {
const t = s.transport === 'stdio' ? 'stdio' : 'url';
return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]);
}
/** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */
function fullIdentity(s: McpServer): string {
return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]);
}
const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers);
// ---------------------------------------------------------------------------
// JSON dialects
// ---------------------------------------------------------------------------
function fromClaude(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const type = raw.type;
if ((type === 'http' || type === 'sse') && typeof raw.url === 'string') {
return clean({ transport: type, url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
}
return null;
}
function toClaude(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') return { type: 'stdio', command: s.command, args: s.args ?? [], env: s.env ?? {} };
return { type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
}
function fromGemini(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
// today, and a bare `url` with no type is the legacy SSE form.
if (typeof raw.httpUrl === 'string')
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
if (typeof raw.url === 'string') {
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({
transport: 'stdio',
command: raw.command,
args: strArr(raw.args),
env: strMap(raw.env),
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
});
}
return null;
}
function toGemini(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return {
command: s.command,
args: s.args ?? [],
...(s.env ? { env: s.env } : {}),
...(s.cwd ? { cwd: s.cwd } : {}),
};
}
return { url: s.url, type: s.transport, ...(s.headers ? { headers: s.headers } : {}) };
}
/** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */
function fromAntigravity(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.disabled === true;
if (typeof raw.serverUrl === 'string')
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled });
if (typeof raw.command === 'string') {
return clean({
transport: 'stdio',
command: raw.command,
args: strArr(raw.args),
env: strMap(raw.env),
disabled,
});
}
return null;
}
function toAntigravity(s: McpServer): Record<string, unknown> | null {
if (s.transport === 'sse') return null;
if (s.transport === 'stdio') {
return { command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}), disabled: false };
}
return { serverUrl: s.url, ...(s.headers ? { headers: s.headers } : {}), disabled: false };
}
function fromOpencode(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.enabled === false;
if (raw.type === 'remote' && typeof raw.url === 'string') {
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
}
if (raw.type === 'local') {
const cmd = strArr(raw.command);
if (!cmd?.length) return null;
return clean({
transport: 'stdio',
command: cmd[0],
args: cmd.slice(1),
env: strMap(raw.environment),
disabled,
});
}
return null;
}
function toOpencode(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return {
type: 'local',
command: [s.command, ...(s.args ?? [])],
...(s.env ? { environment: s.env } : {}),
enabled: true,
};
}
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
}
interface JsonDialect {
/** Key holding the server table. */
key: string;
from(raw: unknown): McpServer | null;
to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record<string, unknown>;
}
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
'opencode-json': {
key: 'mcp',
from: fromOpencode,
to: toOpencode,
seed: { $schema: 'https://opencode.ai/config.json' },
},
};
// ---------------------------------------------------------------------------
// Codex TOML (the `[mcp_servers.*]` tables only)
// ---------------------------------------------------------------------------
function fromCodex(t: Record<string, unknown>): McpServer | null {
const disabled = t.enabled === false;
if (typeof t.url === 'string') {
return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled });
}
if (typeof t.command === 'string') {
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled });
}
return null;
}
const tomlStr = (v: string): string => JSON.stringify(v);
const tomlKey = (k: string): string => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlStr(k));
function toCodexToml(name: string, s: McpServer): string {
const head = `[mcp_servers.${tomlKey(name)}]`;
const lines = [head];
if (s.transport === 'stdio') {
lines.push(`command = ${tomlStr(s.command ?? '')}`);
lines.push(`args = [${(s.args ?? []).map(tomlStr).join(', ')}]`);
if (s.env) {
lines.push('', `[mcp_servers.${tomlKey(name)}.env]`);
for (const [k, v] of Object.entries(s.env)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
}
} else {
lines.push(`url = ${tomlStr(s.url ?? '')}`);
if (s.headers) {
lines.push('', `[mcp_servers.${tomlKey(name)}.http_headers]`);
for (const [k, v] of Object.entries(s.headers)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
}
}
return lines.join('\n') + '\n';
}
// ---------------------------------------------------------------------------
// Dialect entry points
// ---------------------------------------------------------------------------
export interface ParsedConfig {
/** Servers this module understands. */
servers: McpServerMap;
/** Every name defined under the MCP table, in any shape: these are never appended over. */
names: Set<string>;
}
/** The MCP table of a config file's text (null = file absent). Throws if it cannot be read safely. */
function mcpTable(format: McpFormat, text: string | null): Record<string, unknown> {
if (text === null || !text.trim()) return dict<unknown>();
if (format === 'codex-toml') {
const doc = parseToml(text);
const table = doc.mcp_servers;
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError('"mcp_servers" is not a table');
return table;
}
const dialect = JSON_DIALECTS[format];
const doc: unknown = JSON.parse(text);
if (!isRecord(doc)) throw new McpConfigError('top level is not a JSON object');
const table = doc[dialect.key];
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
return table;
}
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
export function parseConfig(format: McpFormat, text: string | null): ParsedConfig {
const table = mcpTable(format, text);
const servers = dict<McpServer>();
const names = new Set<string>();
for (const name of safeKeys(table)) {
names.add(name);
const raw = table[name];
const s =
format === 'codex-toml'
? isRecord(raw)
? fromCodex(raw)
: null
: JSON_DIALECTS[format as Exclude<McpFormat, 'codex-toml'>].from(raw);
if (s) servers[name] = s;
}
return { servers, names };
}
/** The servers of a config file's text. */
export function parseServers(format: McpFormat, text: string | null): McpServerMap {
return parseConfig(format, text).servers;
}
/** Whether this dialect can express the server. */
function canExpress(format: McpFormat, s: McpServer): boolean {
if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse';
return true;
}
/**
* Add servers to a config file's text and return the new text. A name already defined under the
* MCP table (in any shape) is skipped; the new text is parsed again and every added server must
* come back as intended, otherwise this throws and nothing should be written.
*/
export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string {
const before = parseConfig(format, text);
const todo = dict<McpServer>();
for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n];
const names = Object.keys(todo);
if (names.length === 0) return text ?? '';
let out: string;
if (format === 'codex-toml') {
const base = text ?? '';
const eol = base.includes('\r\n') ? '\r\n' : '\n';
const sep =
base.length === 0
? ''
: base.endsWith('\n\n') || base.endsWith('\r\n\r\n')
? ''
: base.endsWith('\n')
? eol
: eol + eol;
const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol));
out = base + sep + blocks.join(eol);
} else {
const dialect = JSON_DIALECTS[format];
const doc: Record<string, unknown> =
text && text.trim() ? (JSON.parse(text) as Record<string, unknown>) : { ...dialect.seed };
const existing = doc[dialect.key];
const table: Record<string, unknown> = isRecord(existing) ? existing : {};
for (const n of names) {
const entry = dialect.to(todo[n]);
if (entry) table[n] = entry;
}
doc[dialect.key] = table;
out = JSON.stringify(doc, null, 2) + '\n';
}
// Re-read what we are about to write.
const after = parseConfig(format, out);
for (const n of before.names) {
if (!after.names.has(n)) throw new McpConfigError(`refusing to write: "${n}" would be lost`);
}
for (const n of names) {
const got = after.servers[n];
if (!got || fullIdentity(got) !== fullIdentity(todo[n])) {
throw new McpConfigError(`refusing to write: "${n}" does not read back as written`);
}
}
return out;
}
// ---------------------------------------------------------------------------
// Orchestration
// ---------------------------------------------------------------------------
async function readText(file: string): Promise<string | null> {
try {
return await fs.readFile(file, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
/**
* Write `text` over `file`, keeping the old content as `<file>.codeman-bak`. Follows a symlink
* to the real file so a symlinked dotfile stays a symlink. When `secret` is set the result is
* readable by its owner only.
*/
async function writeAtomic(file: string, text: string, secret: boolean): Promise<void> {
let target = file;
try {
if ((await fs.lstat(file)).isSymbolicLink()) target = await fs.realpath(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart.
try {
await fs.lstat(file);
throw new McpConfigError('config path is a dangling symlink');
} catch (inner) {
if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner;
}
}
let mode = 0o600;
try {
mode = (await fs.stat(target)).mode & 0o777;
await fs.copyFile(target, `${target}.codeman-bak`);
await fs.chmod(`${target}.codeman-bak`, 0o600);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
if (secret) mode &= ~0o077;
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
try {
await fs.writeFile(tmp, text, { mode });
// writeFile's mode is masked by the umask; the mode we computed is the one we mean.
await fs.chmod(tmp, mode);
await fs.rename(tmp, target);
} catch (err) {
await fs.unlink(tmp).catch(() => undefined);
throw err;
}
}
export interface McpSyncOptions {
/** false = report what would change without writing. */
apply: boolean;
home?: string;
/**
* Where relocation env vars (`McpSyncTarget.relocation`) are read from: the env the CLIs
* Codeman spawns would inherit. Defaults to `process.env`, except when `home` is overridden
* (tests, throwaway homes): then it defaults to none, so a relocation var in the caller's own
* env can never aim a write outside that home.
*/
env?: Record<string, string | undefined>;
}
/**
* The config file a target means, honouring its relocation env var. `skip` is set when the var
* holds something that cannot be located safely (a relative path resolves against the CLI's
* working directory, which differs per session), so the target is neither read nor written.
*/
function resolveFile(
t: McpSyncTarget,
home: string,
env: Record<string, string | undefined>
): { file: string; skip?: string } {
const rel = t.relocation;
const dir = rel ? env[rel.envVar] : undefined;
// Every CLI declared today treats an empty value as unset (`||` / a non-empty filter).
if (!rel || dir === undefined || dir === '') return { file: join(home, t.path) };
if (!isAbsolute(dir)) {
return {
file: `$${rel.envVar}/${rel.path}`,
skip: `${rel.envVar} is set to a relative path, so the file ${t.label} reads cannot be located safely`,
};
}
return { file: join(dir, rel.path) };
}
let applying = false;
/**
* Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority
* order: when two CLIs define a name differently, the first one's definition is the one copied).
* Throws `McpSyncBusyError` if another apply is running.
*/
export async function syncMcpServers(
targets: McpSyncTarget[],
opts: McpSyncOptions,
unsupported: string[] = []
): Promise<McpSyncResult> {
if (opts.apply) {
if (applying) throw new McpSyncBusyError();
applying = true;
}
try {
return await run(targets, opts, unsupported);
} finally {
if (opts.apply) applying = false;
}
}
async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise<McpSyncResult> {
const home = opts.home ?? homedir();
const env = opts.env ?? (opts.home === undefined ? process.env : {});
const seen = new Set<string>();
const live = targets
.map((t) => ({ t, ...resolveFile(t, home, env) }))
.filter(({ file }) => (seen.has(file) ? false : (seen.add(file), true)));
const state = live.map(({ t, file, skip }) => {
const res: McpSyncTargetResult = {
id: t.id,
label: t.label,
file,
status: skip ? 'skipped' : 'ok',
...(skip ? { error: skip } : {}),
servers: [],
added: [],
skipped: [],
};
return { t, file, res, servers: dict<McpServer>(), names: new Set<string>() };
});
for (const s of state) {
if (s.res.status !== 'ok') continue;
try {
if (!s.t.installed && !(await exists(s.file))) {
s.res.status = 'absent';
continue;
}
const parsed = parseConfig(s.t.format, await readText(s.file));
s.servers = parsed.servers;
s.names = parsed.names;
s.res.servers = [...parsed.names];
} catch (err) {
s.res.status = 'unreadable';
s.res.error = describeMcpSyncError(err);
}
}
// Union, first enabled definition wins; a later, different definition of the same name is a conflict.
const union = dict<McpServer>();
const conflicts = new Set<string>();
const switchedOff = new Set<string>();
for (const s of state) {
if (s.res.status !== 'ok') continue;
for (const name of Object.keys(s.servers)) {
const def = s.servers[name];
if (def.disabled) {
switchedOff.add(name);
continue;
}
if (!(name in union)) union[name] = def;
else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name);
}
}
const disabled = [...switchedOff].filter((n) => !(n in union)).sort();
for (const s of state) {
if (s.res.status !== 'ok') continue;
const add = dict<McpServer>();
for (const name of Object.keys(union)) {
if (s.names.has(name)) continue;
if (canExpress(s.t.format, union[name])) add[name] = union[name];
else s.res.skipped.push(name);
}
s.res.added = Object.keys(add);
if (!opts.apply || s.res.added.length === 0) continue;
try {
// Re-read right before writing: claude rewrites ~/.claude.json constantly.
const fresh = await readText(s.file);
const out = addServers(s.t.format, fresh, add);
const current = parseConfig(s.t.format, fresh);
const written = Object.keys(add).filter((n) => !current.names.has(n));
if (written.length === 0) {
s.res.added = [];
continue;
}
const subset = dict<McpServer>();
for (const n of written) subset[n] = add[n];
await writeAtomic(s.file, out, carriesSecrets(subset));
s.res.added = written;
} catch (err) {
s.res.status = 'failed';
s.res.error = describeMcpSyncError(err);
s.res.added = [];
}
}
return {
applied: opts.apply,
targets: state.map((s) => s.res),
conflicts: [...conflicts].sort(),
disabled,
unsupported,
};
}
+18
View File
@@ -213,6 +213,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
return getCli(mode)?.capabilities.external ?? true;
}
/**
* Does this CLI take the top-level session `model` (claude's per-session `--model`)?
*
* Read off the registry's model-source capability: only a `claude-settings-file` CLI
* (claude) launches on that field. Every other CLI takes its model in its own config object
* (`codexConfig.model` and so on), so for them the field is inert, and cron hands the
* app-wide default (always a Claude id) to any CLI that has a model at all. `toState()`
* publishes and persists the field only where this holds, so a codex cron session never
* reports a Claude model it did not run on, and `POST /api/sessions` refuses a `model` for
* any CLI where it does not.
*/
export function cliTakesSessionModel(mode: SessionMode): boolean {
return getCli(mode)?.capabilities.model.source === 'claude-settings-file';
}
/** Display name for a run mode. Falls back to the raw id for an unregistered one. */
function getModeLabel(mode: SessionMode): string {
return getCli(mode)?.label ?? mode;
@@ -1838,6 +1853,9 @@ export class Session extends EventEmitter {
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// Claude only: for any other CLI `_model` is inert (its model lives in its own config
// object) and may be the app-wide Claude default cron handed it.
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
advisorModel: this._advisorModel,
customModel: this.customModel,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
+7
View File
@@ -157,6 +157,13 @@ export interface CaseInfo {
location?: 'local' | 'linked-local' | 'remote' | 'docker';
/** Whether this is a linked local folder */
linked?: boolean;
/**
* The case folder did not answer (an unreachable network mount, or an error other
* than "no such file"), or its probe was refused because folders on other unreachable
* mounts are still not answering, so whether it still exists is unknown. A refused
* probe can set this on a healthy linked case. Absent = it answered.
*/
unreachable?: boolean;
/**
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
* (the packaged skill's workers, or any spawn naming a parent session), read back
+3 -1
View File
@@ -24,9 +24,10 @@
* | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` |
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
*
* ## Cross-domain relationship map
*
@@ -72,3 +73,4 @@ export * from './search.js';
export * from './user.js';
export * from './webview.js';
export * from './intent.js';
export * from './mcp-sync.js';
+41
View File
@@ -0,0 +1,41 @@
/**
* @fileoverview Response types for MCP server sync (`GET`/`POST /api/mcp-sync`, src/mcp-sync.ts).
*
* These are returned over HTTP, so they carry server NAMES only: never env values or headers,
* and never file content (a parse failure is reported by position, see `describeMcpSyncError`).
*/
/** One participating CLI in a sync result. */
export interface McpSyncTargetResult {
id: string;
label: string;
/** The config file read (and written). For a `skipped` target, the unresolved location. */
file: string;
/**
* `absent`: not installed and no config file, so neither read nor created.
* `skipped`: the CLI's config location could not be resolved safely (e.g. its relocation env
* var is a relative path), so it is neither read nor written; `error` says why.
* `unreadable`: the file exists but cannot be parsed safely, so it is not written.
* `failed`: a read or write error (the file may be unchanged).
*/
status: 'ok' | 'absent' | 'skipped' | 'unreadable' | 'failed';
/** Why the target is not `ok`. Position or category only, never file content. */
error?: string;
servers: string[];
/** Servers added (apply) or that would be added (plan). */
added: string[];
/** Missing servers this dialect cannot express. */
skipped: string[];
}
/** The `data` of `GET`/`POST /api/mcp-sync`. */
export interface McpSyncResult {
applied: boolean;
targets: McpSyncTargetResult[];
/** Names defined differently by different CLIs; existing definitions are left untouched. */
conflicts: string[];
/** Names left out because the only definitions are switched off in their own CLI. */
disabled: string[];
/** Installed, enabled agent CLIs with no known MCP config file, so sync cannot touch them. */
unsupported: string[];
}
+43 -2
View File
@@ -6,13 +6,17 @@
* Key exports:
* - PushSubscriptionRecord — a registered push endpoint with per-event preferences
* - VapidKeys — VAPID key pair (public + private) for Web Push authentication
* - WebhookConfig, WebhookStatus, WebhookResult (+ the kind/scope lists): the webhook channel
* (ntfy, Slack, Discord, generic JSON) that carries the same events as Web Push
*
* Persistence:
* - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use)
* - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404)
* - Webhook: `~/.codeman/webhook.json` (mode 0600; the URL is a bearer secret)
*
* Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`,
* `POST /api/push/subscribe`. No dependencies on other domain modules.
* Push is managed by PushStore (`src/push-store.ts`), served at `GET /api/push/vapid-key`,
* `POST /api/push/subscribe`. The webhook is managed by `src/webhook-notify.ts`, served at
* `GET`/`PUT /api/webhook` and `POST /api/webhook/test`. No dependencies on other domain modules.
*/
/** A registered push subscription */
@@ -32,3 +36,40 @@ export interface VapidKeys {
privateKey: string;
generatedAt: number;
}
/** Services the webhook channel can format a message for. */
export const WEBHOOK_KINDS = ['ntfy', 'slack', 'discord', 'generic'] as const;
export type WebhookKind = (typeof WEBHOOK_KINDS)[number];
/** `attention`: only events that need a human (critical / warning). `all`: also "response complete". */
export const WEBHOOK_SCOPES = ['attention', 'all'] as const;
export type WebhookScope = (typeof WEBHOOK_SCOPES)[number];
export type WebhookUrgency = 'critical' | 'warning' | 'info';
/** The stored webhook config (`~/.codeman/webhook.json`). `url` is a secret and is never returned. */
export interface WebhookConfig {
enabled: boolean;
kind: WebhookKind;
url: string;
scope: WebhookScope;
}
/** One delivery attempt. `error` never contains the URL. */
export interface WebhookResult {
ok: boolean;
status?: number;
error?: string;
at: number;
}
/** `GET /api/webhook`: the config without its URL, plus the last delivery result. */
export interface WebhookStatus {
enabled: boolean;
kind: WebhookKind;
scope: WebhookScope;
hasUrl: boolean;
/** Scheme + host only; the path and query are the secret. */
urlMasked: string;
lastResult: WebhookResult | null;
}
+22 -1
View File
@@ -12,7 +12,7 @@
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, reasoningEffort, resumeSessionId, bypass, animations, renderMode)
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
@@ -377,6 +377,16 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel {
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
}
/**
* Reasoning effort levels codex accepts as `model_reasoning_effort` (codex-cli 0.154.0).
* Which of them a given model honours is codex's business; Codeman only keeps the value
* to a known word, since it lands in the launch argv.
*/
export const CODEX_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const;
/** Codex reasoning effort for a session, passed as `--config model_reasoning_effort=<level>` */
export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number];
/**
* Model aliases Claude Code accepts for its advisor tool (a stronger model the session's
* main model consults at decision points; code.claude.com/docs/en/advisor). Haiku is left
@@ -418,6 +428,8 @@ export type CodexRenderMode = 'hybrid';
export interface CodexConfig {
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
model?: string;
/** Reasoning effort for this session. Passed via --config model_reasoning_effort=<level>. */
reasoningEffort?: CodexReasoningEffort;
/** Resume a previous codex conversation by session id (passed via --resume) */
resumeSessionId?: string;
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
@@ -817,6 +829,15 @@ export interface SessionState {
effort?: EffortLevel;
/** Claude advisor model (`advisorModel` in the launch `--settings`, switchable in-session via /advisor) */
advisorModel?: string;
/**
* The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or
* the app-wide default when there was none. Persisted so a recovered session relaunches on
* the same model rather than whatever the default is by then. Not `cliModel`, which is what
* the CLI's banner reports. Claude sessions only (`cliTakesSessionModel()`): every other CLI
* keeps its model in its own config object (`codexConfig.model` and so on), and this is
* absent for them.
*/
model?: string;
/**
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
+247
View File
@@ -0,0 +1,247 @@
/**
* @fileoverview Bounded existence probe for user-chosen paths.
*
* A linked case can live on a network mount (NFS, SMB, sshfs). When that mount
* goes unreachable, a hard mount makes `stat()` wait forever. A synchronous
* probe (`existsSync`) on such a path blocks the event loop and freezes the
* whole web server; even an async `stat()` never settles and permanently holds
* one of libuv's few threadpool workers, which every other `fs`, `dns.lookup`
* and `crypto` call in the process shares.
*
* The probe therefore answers one of THREE things, never two:
* - `'present'` / `'absent'`: the filesystem answered (ENOENT and ENOTDIR are
* the only errors that mean absent);
* - `'unknown'`: it did not answer in `PATH_PROBE_TIMEOUT_MS`, it answered with
* some other error (EIO from a soft mount that gave up, EACCES), or the probe
* was refused (below). "Unknown" is NOT "absent": a caller that would create,
* scaffold or 404 on absence must not do so on unknown.
*
* And it keeps a dead mount from draining the threadpool:
* - one in-flight probe per path, shared by concurrent callers;
* - a path whose probe timed out is "stalled" until that stat finally settles.
* Paths NEAR a stalled one are answered "unknown" without a new stat, so one
* dead mount costs one worker, not one per case and file on it. "Near" means on
* the same mount when that mount is a network or FUSE filesystem (NFS, SMB,
* sshfs and the like): under the deepest mount point holding the stalled path,
* with its type, read from `/proc/self/mounts` (procfs, which never waits on the
* dead filesystem). Otherwise it narrows to the stalled path and everything under
* it: when the deepest mount is local (a path typed under a local `/home` can
* reach a NAS through a symlink, and must not take the rest of `/home` with it),
* is `/`, or the table is unavailable (not Linux). Unrelated paths are probed
* normally;
* - once `MAX_STALLED_PATH_PROBES` stalled stats are pending, new probes are
* refused process-wide (answered "unknown"), since each would risk another
* worker. Probes merely in flight do not count, so concurrent healthy probes
* never get refused. A caller acting on ONE path at a user's explicit request
* (opening a case, starting a session in it) may pass `{ pastCap: true }`: its
* probe is still bounded and still recorded as stalled if it hangs (so a dead
* path costs at most one worker however often it is retried), but it is not
* refused just because unrelated mounts are dead. Bulk scans (the case list)
* keep the cap; the per-spawn hook and statusLine helpers retry one refused
* probe past it and then skip a path that still answers "unknown", rather than
* touch it with an unbounded call. `pastCap` still stops at
* `PATH_PROBE_STALL_CEILING` (the threadpool size minus one), so explicit
* requests against several dead paths can never take the last worker.
*
* Both events are logged once (`console.warn`): a path's first stall, and the
* cap engaging, so "my case vanished" and "hooks stopped firing" leave a trace.
*
* Writers should not use this at all: a writer that must tell "missing" apart
* from "unreachable" wants an ENOENT-aware async `lstat` (see
* `pathExistsForWrite` in hooks-config.ts).
*
* @module utils/bounded-path-probe
*/
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING, PATH_PROBE_TIMEOUT_MS } from '../config/path-probe.js';
/** What a probe could establish about a path. */
export type PathProbeState = 'present' | 'absent' | 'unknown';
/** Like {@link PathProbeState}, with "present" split by whether it is a directory. */
export type PathProbeKind = 'directory' | 'file' | 'absent' | 'unknown';
const inFlight = new Map<string, Promise<PathProbeKind>>();
/** Stalled path -> the directory whose subtree is answered "unknown" while it stays stalled. */
const stalled = new Map<string, string>();
let capWarned = false;
async function statKind(path: string): Promise<PathProbeKind> {
try {
return (await fs.stat(path)).isDirectory() ? 'directory' : 'file';
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'unknown';
}
}
function isWithin(path: string, root: string): boolean {
if (path === root) return true;
return path.startsWith(root.endsWith(sep) ? root : root + sep);
}
/** Filesystem types whose stall means the whole mount is gone (network and FUSE). */
const REMOTE_FS_TYPES = new Set([
'nfs',
'nfs4',
'cifs',
'smb3',
'smbfs',
'9p',
'ceph',
'glusterfs',
'afs',
'lustre',
'davfs',
]);
function isRemoteFsType(fsType: string): boolean {
return REMOTE_FS_TYPES.has(fsType) || fsType.startsWith('fuse.');
}
/** Deepest mount holding `abs`, from the kernel's mount table; undefined when unreadable. */
function mountOf(abs: string): { mountPoint: string; fsType: string } | undefined {
let table: string;
try {
table = readFileSync('/proc/self/mounts', 'utf-8');
} catch {
return undefined;
}
let best: { mountPoint: string; fsType: string } | undefined;
for (const line of table.split('\n')) {
const [, field, fsType] = line.split(' ');
if (!field || !fsType) continue;
// The table octal-escapes space, tab, newline and backslash in mount points.
const mountPoint = field.replace(/\\([0-7]{3})/g, (_m, oct: string) => String.fromCharCode(parseInt(oct, 8)));
if (isWithin(abs, mountPoint) && (!best || mountPoint.length > best.mountPoint.length)) {
best = { mountPoint, fsType };
}
}
return best;
}
/**
* The subtree a stalled path takes down with it (see the module comment): its
* mount when that is a network or FUSE filesystem, else just the path itself.
*/
function stallScope(abs: string): string {
const mount = mountOf(abs);
return mount && mount.mountPoint !== '/' && isRemoteFsType(mount.fsType) ? mount.mountPoint : abs;
}
/**
* Whether `path` is near a path whose probe is still stalled (see the module
* comment), i.e. whether the probe would answer "unknown" for it without a stat.
* Lets a caller tell "this workspace sits on the dead mount" apart from "the
* probe was refused for capacity".
*/
export function isNearStalledPath(path: string): boolean {
const abs = resolve(path);
for (const scope of stalled.values()) {
if (isWithin(abs, scope)) return true;
}
return false;
}
/** Options for {@link probePathKind} / {@link probePath}. */
export interface PathProbeOptions {
/** Probe even while the stall cap is engaged (see the module comment). */
pastCap?: boolean;
}
/**
* Probe `path` without letting an unresponsive filesystem block the caller for
* longer than `PATH_PROBE_TIMEOUT_MS`. Follows symlinks, like `stat()`.
*/
export async function probePathKind(path: string, options: PathProbeOptions = {}): Promise<PathProbeKind> {
const abs = resolve(path);
if (isNearStalledPath(abs)) return 'unknown';
let probe = inFlight.get(abs);
if (!probe) {
// pastCap lifts the bulk cap, never the ceiling that keeps one worker free.
if (stalled.size >= (options.pastCap ? PATH_PROBE_STALL_CEILING : MAX_STALLED_PATH_PROBES)) {
if (!capWarned) {
capWarned = true;
console.warn(
`[path-probe] ${stalled.size} path probes are stalled on unresponsive filesystems; ` +
'not starting new ones until one answers (paths read as unknown meanwhile)'
);
}
return 'unknown';
}
probe = statKind(abs);
const started = probe;
inFlight.set(abs, started);
void started.finally(() => {
inFlight.delete(abs);
stalled.delete(abs);
if (stalled.size < MAX_STALLED_PATH_PROBES) capWarned = false;
});
}
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
probe,
new Promise<PathProbeKind>((resolveTimeout) => {
timer = setTimeout(() => {
if (inFlight.get(abs) === probe && !stalled.has(abs)) {
stalled.set(abs, stallScope(abs));
console.warn(
`[path-probe] ${abs} did not answer within ${PATH_PROBE_TIMEOUT_MS} ms ` +
'(unreachable mount?); treating it and its neighbours as unknown until it does'
);
}
resolveTimeout('unknown');
}, PATH_PROBE_TIMEOUT_MS);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Why a probe of `path` answers "unknown" right now: its mount is not answering
* (`'stalled'`, it is near a stalled probe), new probes are refused because enough
* UNRELATED paths are stalled (`'refused'`; `pastCap` picks which limit applies), or
* neither, so the filesystem answered with an error such as EACCES or EIO
* (`'unreadable'`). For messages only: it reads the state now, not at probe time.
*/
export function unknownPathReason(path: string, options: PathProbeOptions = {}): 'stalled' | 'refused' | 'unreadable' {
if (isNearStalledPath(path)) return 'stalled';
if (stalled.size >= (options.pastCap ? PATH_PROBE_STALL_CEILING : MAX_STALLED_PATH_PROBES)) return 'refused';
return 'unreadable';
}
/**
* User-facing sentence for an "unknown" probe of `path` (`label` names it, e.g.
* "workingDir"). A refused probe says so, rather than blaming a folder that was never
* checked: at the ceiling every new folder reads "unknown" until a dead mount answers.
*/
export function describeUnknownPath(label: string, path: string, options: PathProbeOptions = {}): string {
return unknownPathReason(path, options) === 'refused'
? `${label} was not checked: folders on other unreachable mounts are still not answering, ` +
`so Codeman is not checking new folders until one does (see the server log): ${path}`
: `${label} is not responding or not readable: ${path}`;
}
/** Tri-state probe of `path`; see the module comment for what "unknown" means. */
export async function probePath(path: string, options: PathProbeOptions = {}): Promise<PathProbeState> {
const kind = await probePathKind(path, options);
return kind === 'directory' || kind === 'file' ? 'present' : kind;
}
/**
* `true` only when `path` is known to exist. For DISPLAY decisions only (does a
* case have a CLAUDE.md): it folds "unknown" into `false`, so never use it to
* decide that something is absent and may be created, scaffolded or reported
* missing; use {@link probePath} for that.
*/
export async function boundedPathExists(path: string): Promise<boolean> {
return (await probePath(path)) === 'present';
}
+2 -1
View File
@@ -122,7 +122,8 @@ export interface ProductionCliResolverHostOptions {
allowRealIoUnderVitest?: boolean;
}
function isExecutableRegularFile(path: string): boolean {
/** An executable regular file. Exported for `codeman doctor`, which must judge a candidate the same way. */
export function isExecutableRegularFile(path: string): boolean {
try {
if (!statSync(path).isFile()) return false;
accessSync(path, constants.X_OK);
+36 -9
View File
@@ -8,7 +8,9 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { isExecutableRegularFile } from './cli-executable-resolver.js';
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
export interface EnvDetectionInputs {
@@ -62,6 +64,8 @@ export interface ProbeHost {
environment: ProbeEnvironment;
which(bin: string): string | null;
fileExists(path: string): boolean;
/** An executable regular file, the run mode's own test for a `searchDirs` candidate. */
isExecutableFile(path: string): boolean;
runVersion(bin: string, args: string[]): string | null;
windowsProgramRoots(): string[];
windowsFileVersion(winPath: string): string | null;
@@ -94,18 +98,33 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
if (spec.resolver.kind === 'path') {
const { bins, versionArg, versionRegex, requireVersionMatch } = spec.resolver;
const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver;
for (const bin of bins) {
const resolved = host.which(bin);
if (resolved) {
const out = host.runVersion(bin, [versionArg ?? '--version']);
// The same candidate order and the same per-candidate test as the run mode's resolver
// (createCliExecutableResolver): the `which` hit (the PATH), then each search dir. Under
// a service the PATH is minimal and the run mode finds the CLI through those dirs, so
// the doctor must too. A search-dir candidate counts only as an absolute path to an
// executable regular file, so a relative dir from a custom clis.json or a file without
// the x bit reads as missing here exactly as it does in the Run menu.
const candidates: string[] = [];
const onPath = host.which(bin);
if (onPath && isAbsolute(onPath)) candidates.push(onPath);
for (const dir of searchDirs ?? []) {
const candidate = join(dir, bin);
if (candidates.includes(candidate)) continue; // a search dir that is also on the PATH
if (isAbsolute(candidate) && host.isExecutableFile(candidate)) candidates.push(candidate);
}
for (const candidate of candidates) {
// Run the RESOLVED path: a bare name would miss the same binary `which` just missed.
const out = host.runVersion(candidate, [versionArg ?? '--version']);
const version = out ? extractVersion(out, versionRegex) : undefined;
// A generic binary name that prints the wrong thing is some OTHER program (see
// PathResolver.requireVersionMatch). Keep looking, then report MISSING; the
// alternative is claiming a tool is installed that the feature's own resolver
// rejects, which reads as "the mode is broken" rather than "install it".
// PathResolver.requireVersionMatch). Try the next candidate, then report MISSING;
// the alternative is claiming a tool is installed that the feature's own resolver
// rejects, or missing one it accepts (an npm squatter on the PATH in front of the
// real grok in ~/.grok/bin), which reads as "the mode is broken".
if (requireVersionMatch && !version) continue;
return finalize(base, tool, resolved, version);
return finalize(base, tool, candidate, version);
}
}
return { ...base, status: 'missing', installHint };
@@ -132,11 +151,16 @@ export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResul
return registry.map((tool) => checkTool(tool, host));
}
// SIGKILL on every probe below: execFileSync's `timeout` only SENDS the kill signal and then
// keeps waiting for the child, so a `--version` that ignores the default SIGTERM would hold
// the doctor (now a Settings button) until GET /api/doctor's own timeout, then be orphaned.
// Same reasoning as the resolver host in cli-executable-resolver.ts.
function safeWhich(bin: string): string | null {
try {
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
killSignal: 'SIGKILL',
}).trim();
const first = out.split(/\r?\n/)[0]?.trim();
return first && existsSync(first) ? first : null;
@@ -151,6 +175,7 @@ function safeRunVersion(bin: string, args: string[]): string | null {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
killSignal: 'SIGKILL',
});
} catch (err: unknown) {
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
@@ -187,11 +212,12 @@ function readWindowsFileVersion(winPath: string): string | null {
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
killSignal: 'SIGKILL',
}).trim();
const out = execFileSync(
'powershell.exe',
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, killSignal: 'SIGKILL' }
).trim();
return out || null;
} catch {
@@ -209,6 +235,7 @@ export function createRealHost(): ProbeHost {
environment,
which: safeWhich,
fileExists: existsSync,
isExecutableFile: isExecutableRegularFile,
runVersion: safeRunVersion,
windowsProgramRoots: listWindowsProgramRoots,
windowsFileVersion: readWindowsFileVersion,
+9
View File
@@ -68,3 +68,12 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
export {
boundedPathExists,
describeUnknownPath,
probePath,
probePathKind,
isNearStalledPath,
unknownPathReason,
} from './bounded-path-probe.js';
export type { PathProbeState, PathProbeKind, PathProbeOptions } from './bounded-path-probe.js';
+197
View File
@@ -0,0 +1,197 @@
/**
* @fileoverview Validation for "create a new case in a custom folder" (`POST /api/cases` with a
* `path`). Creating a case writes a scaffold (`CLAUDE.md`, `src/`, `.claude/settings.local.json`)
* and registers the folder in the shared, ownerless linked-cases registry, so the target has to be
* judged before anything is created:
*
* - it must be an absolute path (a leading `~` is expanded) with no traversal and none of the shell
* metacharacters a session's working directory is later rejected for (`isValidWorkingDir`), so
* a case this accepts is one a session can actually start in;
* - it must not be a system directory, the home directory itself, Codeman's own data directory, or
* a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed AND on
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
* around it;
* - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same
* folder listed both as a local case and as a linked one would make deleting it remove files;
* - its parent must already exist (one folder is created, never a whole chain), and the folder
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
* job, and silently scaffolding into someone's project is the one thing this must never do);
* - it must not be a symlink.
*
* Pure except for the filesystem reads in `prepareNewCasePath`; the policy lives in `blockedReason`
* so it can be tested without a disk.
*
* @module web/case-path
*/
import { promises as fs } from 'node:fs';
import { basename, dirname, join, resolve, sep } from 'node:path';
import { isValidWorkingDir } from './schemas.js';
import { describeUnknownPath, probePath } from '../utils/index.js';
/** System trees nobody creates a project in; creating one here is a mistake or an attack. */
const BLOCKED_SYSTEM_ROOTS = [
'/bin',
'/boot',
'/dev',
'/etc',
'/lib',
'/lib32',
'/lib64',
'/proc',
'/run',
'/sbin',
'/sys',
'/usr',
];
/** Home-relative trees that hold credentials or other tools' own configuration. */
const BLOCKED_HOME_DIRS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker', '.claude', '.codex', '.gemini'];
export interface NewCasePathContext {
home: string;
/** Codeman's own state directory (`getDataDir()`), which must never become a case. */
dataDir: string;
/**
* The cases directories (the caller's own and the shared one). A folder in one of them is already
* listed as a local case, so it must not be registered as a linked one too.
*/
casesDirs?: readonly string[];
}
export type NewCasePathResult =
| { ok: true; path: string; existedEmpty: boolean }
| { ok: false; code: 'INVALID' | 'BLOCKED' | 'NOT_FOUND' | 'EXISTS' | 'UNREACHABLE'; reason: string };
const isWithin = (child: string, root: string): boolean =>
child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);
/** `~` and `~/x` to the home directory; anything else is returned unchanged. */
export function expandHome(raw: string, home: string): string {
if (raw === '~') return home;
if (raw.startsWith('~/')) return join(home, raw.slice(2));
return raw;
}
/**
* Why a case may not live at this (already absolute and normalised) path, or null. `systemRoots`
* defaults to the system trees as spelled; pass their symlink-resolved forms to judge a resolved path.
*/
export function blockedReason(
absPath: string,
ctx: NewCasePathContext,
systemRoots: readonly string[] = BLOCKED_SYSTEM_ROOTS
): string | null {
if (absPath === sep) return 'The filesystem root cannot be a case';
for (const root of systemRoots) {
if (isWithin(absPath, root)) return `${root} is a system directory`;
}
if (absPath === ctx.home) return 'The home folder itself cannot be a case; pick a folder inside it';
for (const dir of BLOCKED_HOME_DIRS) {
if (isWithin(absPath, join(ctx.home, dir))) return `~/${dir} holds credentials or another tool's configuration`;
}
if (isWithin(absPath, ctx.dataDir)) return "Codeman's own data folder cannot be a case";
// Any Codeman instance's data dir under the home folder (~/.codeman, ~/.codeman-beta, ...), not only
// the one this process uses.
if (absPath.startsWith(ctx.home + sep)) {
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
}
for (const dir of ctx.casesDirs ?? []) {
if (isWithin(absPath, dir)) {
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
}
}
return null;
}
/** `p` with its symlinks resolved, or `p` itself when it does not exist (or cannot be read). */
async function realpathOr(p: string): Promise<string> {
try {
return await fs.realpath(p);
} catch {
return p;
}
}
/** The context and system roots with their symlinks resolved, for judging a resolved path. */
async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathContext, string[]]> {
const [home, dataDir, casesDirs, systemRoots] = await Promise.all([
realpathOr(ctx.home),
realpathOr(ctx.dataDir),
Promise.all((ctx.casesDirs ?? []).map(realpathOr)),
Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)),
]);
return [{ home, dataDir, casesDirs }, systemRoots];
}
/**
* Judge `raw` as the folder for a new case and, if it is acceptable, say what to create.
* Never creates anything.
*/
export async function prepareNewCasePath(raw: string, ctx: NewCasePathContext): Promise<NewCasePathResult> {
const typed = raw.trim();
if (!typed) return { ok: false, code: 'INVALID', reason: 'Enter a folder path' };
const expanded = expandHome(typed, ctx.home);
if (!isValidWorkingDir(expanded)) {
return {
ok: false,
code: 'INVALID',
reason: 'Use an absolute path with letters, numbers, spaces, - _ . only (no .., no shell characters)',
};
}
const target = resolve(expanded);
const typedBlock = blockedReason(target, ctx);
if (typedBlock) return { ok: false, code: 'BLOCKED', reason: typedBlock };
// Bounded first: the parent can sit on a network mount that stopped answering, where the
// realpath/stat/lstat/readdir below would each hold a threadpool worker until it returns.
// It is one folder the user named, so the probe may pass the bulk cap (never the ceiling).
const parentState = await probePath(dirname(target), { pastCap: true });
if (parentState === 'absent') {
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
}
if (parentState === 'unknown') {
return {
ok: false,
code: 'UNREACHABLE',
reason: describeUnknownPath('The parent folder', dirname(target), { pastCap: true }),
};
}
// Resolve the parent's symlinks, then judge again: a link into a blocked tree must not pass.
let realParent: string;
try {
realParent = await fs.realpath(dirname(target));
if (!(await fs.stat(realParent)).isDirectory()) {
return { ok: false, code: 'INVALID', reason: `${dirname(target)} is not a folder` };
}
} catch {
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
}
const real = join(realParent, basename(target));
// The resolved path against the roots as given AND as resolved: with home reached through a link, a
// link to <real home>/.ssh is only caught by the resolved home; on macOS /etc is /private/etc.
const [resolvedCtx, resolvedSystemRoots] = await resolvedPolicy(ctx);
const realBlock = blockedReason(real, ctx) ?? blockedReason(real, resolvedCtx, resolvedSystemRoots);
if (realBlock) return { ok: false, code: 'BLOCKED', reason: realBlock };
try {
const st = await fs.lstat(real);
if (st.isSymbolicLink()) return { ok: false, code: 'INVALID', reason: `${target} is a symbolic link` };
if (!st.isDirectory()) return { ok: false, code: 'INVALID', reason: `${target} exists and is not a folder` };
if ((await fs.readdir(real)).length > 0) {
return {
ok: false,
code: 'EXISTS',
reason: `${target} already has files in it. Use "Link Existing" for a project that already exists`,
};
}
return { ok: true, path: real, existedEmpty: true };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ok: true, path: real, existedEmpty: false };
return { ok: false, code: 'INVALID', reason: `Cannot read ${target}: ${(err as Error).message}` };
}
}
+1184 -7
View File
File diff suppressed because it is too large Load Diff
+673
View File
@@ -0,0 +1,673 @@
/**
* @fileoverview Git status indicator in the bottom bar, and the panel it opens.
*
* Agents leave work uncommitted and unpushed. This puts a small indicator at the right of the bottom
* toolbar for the ACTIVE session's repository, or repositories when the session's folder holds several (`●3` uncommitted files, `↑2` commits not pushed,
* `✓` when everything is committed and pushed) and, on click, a draggable panel in the style of the
* Files window listing exactly which files are uncommitted and which commits are not pushed.
*
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
* session changes or the window regains focus). The route is read-only and offline: it never fetches
* or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel
* footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator.
*
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
* only ever written with `textContent`, never `innerHTML`.
*
* The bottom toolbar's right group is hidden on phones (mobile.css), so this surface is desktop and
* tablet only by construction.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.activeSessionId, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
* @dependency panels-ui.js (openFilePreview)
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
*/
/** How often the active session's repository is re-read while the indicator is on. */
const GIT_STATUS_POLL_MS = 15000;
/** The timer only decides whether a poll is due; it is cheap and runs while the indicator is on. */
const GIT_STATUS_TICK_MS = 2000;
/** A focus or visibility change refreshes at once unless the last read is younger than this. */
const GIT_STATUS_MIN_REFRESH_MS = 3000;
const GIT_STATUS_BADGE_TITLE = {
M: 'Modified',
A: 'Added',
D: 'Deleted',
R: 'Renamed',
C: 'Copied',
T: 'Type changed',
U: 'Unmerged',
'?': 'Untracked',
};
Object.assign(CodemanApp.prototype, {
/** Per-device setting, default OFF. */
isGitStatusEnabled() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
return (settings.showGitStatus ?? defaults.showGitStatus ?? false) === true;
},
/**
* Starts or stops the poll to match the setting. Called from applyHeaderVisibilitySettings(), which
* runs on boot and after every settings save, so a live toggle needs no reload.
*/
applyGitStatusVisibility() {
const on = this.isGitStatusEnabled();
if (on && !this._gitStatusTimer) {
this._gitStatusTimer = setInterval(() => this._gitStatusTick(), GIT_STATUS_TICK_MS);
this._gitStatusOnVisible = () => {
if (!document.hidden) this.refreshGitStatus({ minAgeMs: GIT_STATUS_MIN_REFRESH_MS });
};
document.addEventListener('visibilitychange', this._gitStatusOnVisible);
window.addEventListener('focus', this._gitStatusOnVisible);
this.refreshGitStatus();
} else if (!on && this._gitStatusTimer) {
clearInterval(this._gitStatusTimer);
this._gitStatusTimer = null;
document.removeEventListener('visibilitychange', this._gitStatusOnVisible);
window.removeEventListener('focus', this._gitStatusOnVisible);
this._gitStatusOnVisible = null;
}
if (!on) {
this._gitStatus = null;
this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1; // an in-flight read must not repaint
// That read's `finally` no longer owns the flag (its epoch is stale), so release it here: left set,
// turning the setting back on would skip every refresh for this session until a reload.
this._gitStatusInFlight = false;
this.closeGitStatusPanel();
}
this._renderGitStatusButton();
},
_gitStatusTick() {
if (document.hidden) return;
const sid = this.activeSessionId || null;
if (sid !== this._gitStatusSessionId) {
// The active session changed (or the first one opened): show nothing stale, read now.
this._gitStatus = null;
this._renderGitStatusButton();
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel(); // not the previous repo's files
this.refreshGitStatus();
return;
}
if (sid && Date.now() - (this._gitStatusFetchedAt || 0) >= GIT_STATUS_POLL_MS) this.refreshGitStatus();
},
/** Reads the active session's git status and repaints. Stale answers (another session, setting off) are dropped. */
async refreshGitStatus({ minAgeMs = 0, fresh = false } = {}) {
if (!this.isGitStatusEnabled()) return;
const sid = this.activeSessionId || null;
this._gitStatusSessionId = sid;
if (!sid) {
this._gitStatus = null;
this._renderGitStatusButton();
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
return;
}
if (minAgeMs && Date.now() - (this._gitStatusFetchedAt || 0) < minAgeMs) return;
// A read for THIS session is already running: let it finish. One for another session is not worth
// waiting for (its answer is dropped below), so a session switch is never left blank.
if (this._gitStatusInFlight && this._gitStatusInFlightSid === sid) return;
this._gitStatusInFlight = true;
this._gitStatusInFlightSid = sid;
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
this._gitStatusFetchedAt = Date.now();
try {
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
this._gitStatus = data ? { sessionId: sid, data } : null;
} catch {
if (epoch === this._gitStatusEpoch) this._gitStatus = null;
} finally {
// Only the newest request owns the flag: an older one finishing late must not clear it.
if (epoch === this._gitStatusEpoch) this._gitStatusInFlight = false;
}
if (epoch !== this._gitStatusEpoch) return;
this._renderGitStatusButton();
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
},
/** Whether the Git window groups changed files under collapsible folders (default on). */
isGitStatusTree() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
return (settings.gitStatusTree ?? defaults.gitStatusTree ?? true) === true;
},
/** The data for the session on screen, or null (not enabled, no session, not a repo, remote/docker, error). */
_currentGitStatus() {
const s = this._gitStatus;
return s && s.sessionId === this.activeSessionId && s.data ? s.data : null;
},
/** `{ uncommitted, unpushed, conflicted, repos, tone }` summed over every repository, or null when there is nothing to show. */
_gitStatusSummary(overview) {
if (!overview || overview.state !== 'ok' || !overview.repos?.length) return null;
let uncommitted = 0;
let unpushed = 0;
let conflicted = 0;
for (const r of overview.repos) {
uncommitted += r.status.counts.uncommitted;
unpushed += r.status.unpushedCount;
conflicted += r.status.counts.conflicted;
}
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
},
/** One sentence for the tooltip and the screen-reader label. */
_gitStatusSentence(overview) {
const sum = this._gitStatusSummary(overview);
if (!sum) return '';
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
const bits = [];
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
if (!bits.length) bits.push('everything is committed and pushed');
let where;
if (sum.repos > 1) where = `${sum.repos} repositories`;
else {
const d = overview.repos[0].status;
where = d.detached ? 'detached HEAD' : d.branch || 'no branch';
}
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
},
_renderGitStatusButton() {
const btn = this.$('gitStatusBtn');
if (!btn) return;
const data = this.isGitStatusEnabled() ? this._currentGitStatus() : null;
const sum = this._gitStatusSummary(data);
btn.hidden = !sum;
btn.classList.toggle('git-status--clean', sum?.tone === 'clean');
btn.classList.toggle('git-status--dirty', sum?.tone === 'dirty');
btn.classList.toggle('git-status--conflict', sum?.tone === 'conflict');
const label = btn.querySelector('.git-status-label');
if (!sum) {
if (label) label.textContent = '';
return;
}
const parts = [];
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
if (!parts.length) parts.push('✓');
if (label) label.textContent = parts.join(' ');
const sentence = this._gitStatusSentence(data);
btn.title = sentence;
btn.setAttribute('aria-label', sentence);
},
// ── Panel ───────────────────────────────────────────────────────────────
_isGitStatusPanelOpen() {
return !!this.$('gitStatusPanel')?.classList.contains('visible');
},
toggleGitStatusPanel() {
if (this._isGitStatusPanelOpen()) {
this.closeGitStatusPanel();
return;
}
const panel = this.$('gitStatusPanel');
if (!panel) return;
panel.classList.add('visible');
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'true');
this._ensureGitStatusPanelDrag();
this._renderGitStatusPanel();
this.refreshGitStatus({ fresh: true }); // the click should show what is true now, not what was true 14s ago
},
closeGitStatusPanel() {
this._gitDiffView = null;
const panel = this.$('gitStatusPanel');
if (panel) {
panel.classList.remove('visible');
// Reset a dragged position so it reopens at the default spot.
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
}
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'false');
},
refreshGitStatusNow() {
this._gitStatusFetchedAt = 0;
this._gitStatusInFlight = false;
return this.refreshGitStatus({ fresh: true });
},
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
_ensureGitStatusPanelDrag() {
const panel = this.$('gitStatusPanel');
const handle = panel?.querySelector('.git-status-header');
if (!panel || !handle || handle._dragReady) return;
handle._dragReady = true;
let drag = null;
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
const rect = panel.getBoundingClientRect();
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
panel.style.left = `${rect.left}px`;
panel.style.top = `${rect.top}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
handle.setPointerCapture?.(e.pointerId);
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!drag) return;
const maxX = window.innerWidth - panel.offsetWidth - 4;
const maxY = window.innerHeight - panel.offsetHeight - 4;
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
});
const end = (e) => {
drag = null;
handle.releasePointerCapture?.(e.pointerId);
};
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
},
_gitEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
},
_renderGitStatusPanel() {
const body = this.$('gitStatusBody');
const head = this.$('gitStatusBranch');
const foot = this.$('gitStatusFooter');
if (!body) return;
const overview = this._currentGitStatus();
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
const focusKey = body.contains(document.activeElement)
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
: null;
const view = this._gitDiffView;
if (view && view.sessionId === this.activeSessionId) {
// A file's diff is on screen: the 15 s poll re-renders the panel, and must not throw it away.
this._renderGitDiffView(body, view);
if (head) head.textContent = '';
if (foot) foot.textContent = '';
return;
}
this._gitDiffView = null;
body.replaceChildren();
const clearChrome = () => {
if (head) head.textContent = '';
if (foot) foot.textContent = '';
};
if (!this.activeSessionId) {
body.append(el('div', 'git-status-empty', 'Open a session to see its repository.'));
clearChrome();
return;
}
if (!overview) {
body.append(
el('div', 'git-status-empty', this._gitStatus === null ? 'Reading the repository…' : 'No status available.')
);
return;
}
if (overview.state !== 'ok') {
const why =
overview.state === 'not-a-repo'
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
: overview.state === 'unsupported'
? overview.reason === 'docker'
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
: 'Git status is not available for remote (SSH) sessions.'
: `Could not read the repository: ${overview.error || 'git failed'}`;
body.append(el('div', 'git-status-empty', why));
clearChrome();
return;
}
const repos = overview.repos;
if (repos.length === 1) {
// One repository: the panel is that repository, as it always was.
const d = repos[0].status;
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
this._renderGitRepoInto(body, d);
} else {
if (head) head.textContent = `${repos.length} repositories`;
for (const r of repos) body.append(this._gitRepoSection(r));
if (overview.reposTruncated) {
body.append(
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
);
}
}
if (foot) {
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
}
if (focusKey) {
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
again?.focus({ preventScroll: true });
}
},
/**
* One repository of several: a collapsible section, collapsed by default (the summary line already
* shows what is outstanding). Which ones the user opened stay open across the 15 s re-render.
*/
_gitRepoSection(r) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const d = r.status;
const section = el('details', 'git-status-repo');
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
const repoKey = `repo|${d.repoRoot || r.path}`;
section.open = openRepos.has(repoKey);
section.addEventListener('toggle', () => (section.open ? openRepos.add(repoKey) : openRepos.delete(repoKey)));
const summary = el('summary', 'git-status-repo-summary');
summary.append(el('span', 'git-status-repo-name', r.name));
if (r.path !== r.name) summary.append(el('span', 'git-status-repo-path', r.path));
summary.append(el('span', 'git-status-repo-branch', d.detached ? 'detached HEAD' : d.branch || ''));
const bits = [];
if (d.counts.conflicted) bits.push(`⚠ ${d.counts.conflicted}`);
if (d.counts.uncommitted) bits.push(`● ${d.counts.uncommitted}`);
if (d.unpushedCount) bits.push(`↑ ${d.unpushedCount}`);
const state = el(
'span',
`git-status-repo-state${outstanding ? ' git-status-repo-state--dirty' : ''}`,
bits.join(' ') || '✓'
);
summary.append(state);
section.append(summary);
const inner = el('div', 'git-status-repo-body');
this._renderGitRepoInto(inner, d);
section.append(inner);
return section;
},
/** The branch line, uncommitted files and unpushed commits of ONE repository into `body`. */
_renderGitRepoInto(body, data) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
// Branch / upstream line.
const line = el('div', 'git-status-branchline');
if (data.upstream && data.upstreamGone) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
const gone = el('span', 'git-status-chip git-status-chip--warn', 'Upstream not on remote');
gone.title =
'The upstream branch does not exist on the remote (never pushed, or deleted and pruned), so the commits below are on no remote.';
line.append(gone);
} else if (data.upstream) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
if (data.behind) {
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
behind.title = 'As of the last git fetch: Codeman never fetches.';
line.append(behind);
}
} else if (data.hasRemote) {
line.append(el('span', 'git-status-chip git-status-chip--warn', 'No upstream branch'));
} else {
line.append(el('span', 'git-status-chip', 'No remote configured'));
}
if (data.counts.stashes) {
line.append(
el('span', 'git-status-chip', `${data.counts.stashes} stash${data.counts.stashes === 1 ? '' : 'es'}`)
);
}
body.append(line);
// Uncommitted changes.
const filesSection = el('section', 'git-status-section');
filesSection.append(el('h4', 'git-status-section-title', `Uncommitted changes (${data.counts.uncommitted})`));
if (!data.files.length) {
filesSection.append(el('div', 'git-status-ok', 'Nothing uncommitted.'));
} else {
const groups = [
['conflicted', 'Merge conflicts'],
['staged', 'Staged'],
['unstaged', 'Not staged'],
['untracked', 'Untracked'],
];
for (const [kind, label] of groups) {
const rows = data.files.filter((f) => f.kind === kind);
if (!rows.length) continue;
const group = el('div', `git-status-group git-status-group--${kind}`);
group.append(el('div', 'git-status-group-title', `${label} (${data.counts[kind]})`));
if (this.isGitStatusTree()) group.append(...this._gitFileTree(rows, data, kind));
else for (const f of rows) group.append(this._gitFileRow(f, data));
filesSection.append(group);
}
if (data.filesTruncated) {
filesSection.append(
el(
'div',
'git-status-more',
`Showing the first ${data.files.length} entries; the counts above include every file.`
)
);
}
}
body.append(filesSection);
// Commits not pushed.
const pushSection = el('section', 'git-status-section');
pushSection.append(el('h4', 'git-status-section-title', `Not pushed (${data.unpushedCount})`));
if (!data.unpushedCount) {
pushSection.append(
el(
'div',
'git-status-ok',
data.hasRemote ? 'Every commit on this branch is on a remote.' : 'There is no remote to push to.'
)
);
} else {
if (!data.upstream || data.upstreamGone) {
pushSection.append(
el(
'div',
'git-status-note',
data.upstreamGone
? 'The upstream branch does not exist on the remote (never pushed, or deleted), so these commits are on no remote.'
: 'This branch has no upstream, so these commits are on no remote yet.'
)
);
}
for (const c of data.unpushed) pushSection.append(this._gitCommitRow(c));
if (data.unpushedCount > data.unpushed.length) {
pushSection.append(
el('div', 'git-status-more', `…and ${data.unpushedCount - data.unpushed.length} older commits.`)
);
}
}
body.append(pushSection);
},
/**
* `rows` as folders (collapsed until clicked) holding their files. A folder with one child folder and
* nothing else is merged into it (`src/web/public` as one row) so a deep path is one click, not five.
* Which folders are open survives the 15 s re-render (`_gitTreeOpen`, keyed by repo, group and folder).
*/
_gitFileTree(rows, data, kind) {
const root = { dirs: new Map(), files: [] };
for (const f of rows) {
const trailing = f.path.endsWith('/');
const parts = f.path.replace(/\/$/, '').split('/');
const leaf = parts.pop() + (trailing ? '/' : '');
let node = root;
for (const part of parts) {
if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
node = node.dirs.get(part);
}
node.files.push({ f, leaf });
}
const open = (this._gitTreeOpen = this._gitTreeOpen || new Set());
const count = (n) => n.files.length + [...n.dirs.values()].reduce((sum, d) => sum + count(d), 0);
const build = (node, prefix) => {
const out = [];
for (const [name0, child0] of [...node.dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
let name = name0;
let child = child0;
while (child.files.length === 0 && child.dirs.size === 1) {
const [n, c] = [...child.dirs][0];
name += `/${n}`;
child = c;
}
const key = `${data.repoRoot}|${kind}|${prefix}${name}`;
const dir = this._gitEl('details', 'git-tree-dir');
dir.open = open.has(key);
dir.addEventListener('toggle', () => (dir.open ? open.add(key) : open.delete(key)));
const summary = this._gitEl('summary', 'git-tree-summary');
summary.append(this._gitEl('span', 'git-tree-name', `${name}/`));
summary.append(this._gitEl('span', 'git-tree-count', String(count(child))));
dir.append(summary);
const inner = this._gitEl('div', 'git-tree-children');
inner.append(...build(child, `${prefix}${name}/`));
dir.append(inner);
out.push(dir);
}
for (const { f, leaf } of node.files.sort((a, b) => a.leaf.localeCompare(b.leaf))) {
out.push(this._gitFileRow(f, data, leaf));
}
return out;
};
return build(root, '');
},
_gitFileRow(f, data, displayName) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const row = el('div', 'git-status-file');
// Untracked entries have `?`; staged ones show the index letter, the rest the working-tree letter.
const letter =
f.kind === 'untracked' ? '?' : f.kind === 'conflicted' ? 'U' : f.kind === 'staged' ? f.index : f.worktree;
const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter);
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
row.dataset.gitKey = `${f.kind}|${f.path}`;
row.append(badge);
const name = el('span', 'git-status-path', displayName ?? f.path);
if (displayName) name.title = f.path;
row.append(name);
if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`));
// An untracked folder has no single diff; every other row opens its changes.
if (!f.path.endsWith('/') && data.repoRoot) {
row.classList.add('git-status-file--clickable');
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.title = 'Show what changed';
const open = () => this.openGitDiff(data.repoRoot, f, letter);
row.addEventListener('click', open);
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
});
}
return row;
},
// ── Diff view ───────────────────────────────────────────────────────────
/** Show `file`'s changes in the panel (a Back button returns to the list). */
async openGitDiff(repoRoot, file, letter) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
this._gitDiffView = view;
this._renderGitStatusPanel();
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
// Back, another file or another session while this was in flight: drop the answer.
if (this._gitDiffView !== view) return;
let body = null;
try {
body = res ? await res.json() : null;
} catch {
/* fall through */
}
if (this._gitDiffView !== view) return;
if (res && res.ok && body?.success) {
view.state = 'ok';
view.result = body.data;
} else {
view.state = 'error';
view.error = body?.error || 'Could not read the diff.';
}
this._renderGitStatusPanel();
},
closeGitDiff() {
this._gitDiffView = null;
this._renderGitStatusPanel();
},
_renderGitDiffView(body, view) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
body.replaceChildren();
const bar = el('div', 'git-diff-bar');
const back = el('button', 'btn-toolbar btn-sm', '← Back');
back.type = 'button';
back.addEventListener('click', () => this.closeGitDiff());
bar.append(back);
bar.append(el('span', 'git-diff-path', view.file.path));
const kindLabel = { staged: 'staged', unstaged: 'not staged', untracked: 'new file', conflicted: 'conflict' };
bar.append(el('span', 'git-diff-kind', kindLabel[view.file.kind] || ''));
if (view.letter !== 'D') {
const open = el('button', 'btn-toolbar btn-sm', 'Open file');
open.type = 'button';
open.addEventListener('click', () =>
this.openFilePreview?.(`${view.repoRoot}/${view.file.path}`, this.activeSessionId)
);
bar.append(open);
}
body.append(bar);
if (view.state === 'loading') {
body.append(el('div', 'git-status-empty', 'Reading the diff…'));
return;
}
if (view.state === 'error') {
body.append(el('div', 'git-status-empty', view.error));
return;
}
const { diff, truncated, binary } = view.result;
if (binary) body.append(el('div', 'git-status-note', 'This is a binary file; there is no text diff to show.'));
if (!diff.trim()) {
if (!binary) body.append(el('div', 'git-status-empty', 'No textual changes (the file may differ only in mode).'));
return;
}
const pre = el('pre', 'git-diff');
const frag = document.createDocumentFragment();
for (const line of diff.split('\n')) {
let cls = 'git-diff-line';
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
else if (
/^(diff --git|index |--- |\+\+\+ |new file|deleted file|similarity|rename |old mode|new mode)/.test(line)
)
cls += ' git-diff-line--meta';
else if (line.startsWith('+')) cls += ' git-diff-line--add';
else if (line.startsWith('-')) cls += ' git-diff-line--del';
frag.append(el('span', cls, line + '\n'));
}
pre.append(frag);
body.append(pre);
if (truncated) body.append(el('div', 'git-status-more', 'Diff cut short: it is larger than the viewer shows.'));
},
_gitCommitRow(c) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const row = el('div', 'git-status-commit');
row.append(el('span', 'git-status-hash', c.hash));
row.append(el('span', 'git-status-subject', c.subject));
const meta = c.time ? `${c.author} · ${this.formatRelativeTime?.(c.time * 1000) ?? ''}` : c.author;
row.append(el('span', 'git-status-commit-meta', meta));
return row;
},
});
+42
View File
@@ -67,6 +67,25 @@
'Open away digest': '打开离开期间摘要',
'Session Manager': '会话管理器',
'Session actions': '会话操作',
Ungrouped: '未分组',
'Group actions': '分组操作',
'Group name': '分组名称',
'Web tab actions': '网页标签操作',
'Web tab settings': '网页标签设置',
'New group': '新建分组',
'Rename group': '重命名分组',
'Move group up': '上移分组',
'Move group down': '下移分组',
'Delete group': '删除分组',
'Move up': '上移',
'Move down': '下移',
'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组',
'Could not save tab groups.': '无法保存标签分组。',
'Tab groups changed elsewhere; part of your edit no longer applies.':
'标签分组已在别处更改;你的部分编辑已不再适用。',
'Tab groups kept changing elsewhere; your edit was not saved.': '标签分组在别处持续更改;你的编辑未保存。',
'Your tab group edit was not saved.': '你的标签分组编辑未保存。',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
@@ -769,6 +788,22 @@
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/':
'仅允许字母、数字、连字符和下划线;将在 ~/codeman-cases/ 中创建。',
'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.':
'仅允许字母、数字、连字符和下划线;将在下方的父文件夹中创建。',
'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.':
'在 ~/codeman-cases 下新建工作区,并生成独立的 CLAUDE.md。',
'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.':
'在你选择的文件夹中新建工作区,并生成独立的 CLAUDE.md。',
'Create in a custom folder': '在自定义文件夹中创建',
'📁 Create in a custom folder': '📁 在自定义文件夹中创建',
'By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.':
'新案例默认创建在 ~/codeman-cases 下。选择其他文件夹后,案例会改为创建在那里,并像其他案例一样列出。',
'Parent Folder': '父文件夹',
'Pick the folder the new case folder should be created inside.': '选择要在其中创建新案例文件夹的文件夹。',
'Choose the folder to create the case in': '选择要在其中创建案例的文件夹',
'Not available for a Docker case': 'Docker 案例不可用',
'Not available with a custom folder': '使用自定义文件夹时不可用',
'Browse…': '浏览…',
'Docker exports': 'Docker 导出',
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
'Runs inside an isolated container. Multiple sessions can share the same container.':
@@ -933,6 +968,13 @@
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
[
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
],
];
for (const [pattern, replacement] of patterns) {
const match = source.match(pattern);
+166 -4
View File
@@ -567,6 +567,19 @@
<div class="file-browser-status" id="fileBrowserStatus"></div>
</div>
<!-- Git status panel (git-status-ui.js): what the active session's repo has not committed or pushed. -->
<div class="git-status-panel" id="gitStatusPanel" role="dialog" aria-label="Git status">
<div class="git-status-header">
<span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span>
<div class="git-status-actions">
<button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">&times;</button>
</div>
</div>
<div class="git-status-body" id="gitStatusBody" data-i18n-skip></div>
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
</div>
<!-- File Preview Overlay -->
<div class="file-preview-overlay" id="filePreviewOverlay">
<div class="file-preview-window">
@@ -771,6 +784,13 @@
<!-- Orchestrator button hidden until feature is ready -->
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">&#x2699; Orchestrator</button> -->
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<!-- Git status of the active session's repository (git-status-ui.js). Optional and per-device
(App Settings → Header & Panels → Bottom bar, default OFF), so it is hidden until that
setting is on AND the session is a local git repository. -->
<button type="button" class="btn-toolbar btn-sm btn-git-status" id="gitStatusBtn" hidden aria-expanded="false" aria-controls="gitStatusPanel" onclick="app.toggleGitStatusPanel()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>
<span class="git-status-label" data-i18n-skip></span>
</button>
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -1822,6 +1842,22 @@
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Key tester</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="key tester keyboard shift enter newline diagnose keydown keypress">
<div class="set-row-text">
<span class="set-row-label">Key tester</span>
<span class="set-row-desc">Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup. Useful when a shortcut such as Shift+Enter behaves differently on one device. Nothing is sent to a session.</span>
</div>
<input type="text" id="keyTesterInput" class="set-input" data-raw-keys readonly autocomplete="off" spellcheck="false"
placeholder="Click here, then press keys"
onkeydown="app.keyTesterEvent(event)" onkeypress="app.keyTesterEvent(event)" onkeyup="app.keyTesterEvent(event)">
</div>
<pre id="keyTesterLog" class="set-note mono" style="display:none;white-space:pre-wrap" data-i18n-skip></pre>
</div>
</div>
</section>
<!-- ══ Header &amp; Panels ═══════════════════════════════════════ -->
@@ -1919,6 +1955,26 @@
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Bottom bar</h4></div>
<div class="set-group-body">
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
<div class="set-row-text">
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="git status folders tree flat list collapsed expand files">
<div class="set-row-text">
<span class="set-row-label">Git status: group files by folder <span class="set-scope">device</span></span>
<span class="set-row-desc">In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Subagent windows</h4></div>
<div class="set-group-body">
@@ -2487,6 +2543,30 @@
</div>
</div>
</div>
<div class="set-group" id="mcpSyncGroup">
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
<div class="set-row-text">
<span class="set-row-label">Enable MCP server sync</span>
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
</div>
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
</div>
<span>
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
<button class="btn-toolbar btn-sm btn-primary" id="mcpSyncApplyBtn" onclick="app.mcpSync(true)">Sync now</button>
</span>
</div>
<div id="mcpSyncResult" class="set-note" style="display:none"></div>
</div>
</div>
</section>
<!-- ══ Notifications ════════════════════════════════════════════ -->
@@ -2614,6 +2694,57 @@
</div>
</div>
</div>
<div class="set-group" id="webhookGroup" style="display:none">
<div class="set-group-head"><h4>Webhook (ntfy, Slack, Discord)</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="webhook ntfy slack discord notification phone headless">
<div class="set-row-text">
<span class="set-row-label">Send alerts to a webhook</span>
<span class="set-row-desc">Posts the same events as push notifications (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any URL, so a server with no browser open can still reach your phone. The URL is a secret: it is stored on the server only and is never shown again once saved. On public ntfy.sh anyone who guesses the topic can read it, so pick a long random one.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="webhookEnabled"><span class="slider"></span></label>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Service</span></div>
<select id="webhookKind" class="set-select">
<option value="ntfy">ntfy</option>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic JSON</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Webhook URL</span>
<span class="set-row-desc" id="webhookUrlHint">Nothing saved yet.</span>
</div>
<input type="password" id="webhookUrl" class="set-input" autocomplete="off" spellcheck="false" placeholder="https://ntfy.sh/your-topic">
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Which events</span>
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
</div>
<select id="webhookScope" class="set-select">
<option value="attention">Needs attention</option>
<option value="all">Everything</option>
</select>
</div>
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Save and test</span>
<span class="set-row-desc">The main Settings Save saves this group too. Send test saves pending edits first.</span>
</div>
<span>
<button class="btn-toolbar btn-sm btn-primary" id="webhookSaveBtn" onclick="app.saveWebhook()">Save</button>
<button class="btn-toolbar btn-sm" id="webhookTestBtn" onclick="app.testWebhook()">Send test</button>
<button class="btn-toolbar btn-sm" id="webhookClearBtn" onclick="app.clearWebhook()" style="display:none">Remove URL</button>
</span>
</div>
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
</section>
<!-- ══ Voice ════════════════════════════════════════════════════ -->
@@ -2738,6 +2869,20 @@
</div>
<p class="set-section-blurb">Paths, automation and remote access. Set once, rarely touched.</p>
<div class="set-group" id="doctorGroup">
<div class="set-group-head"><h4>Diagnostics</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="diagnostics doctor dependencies tmux node claude codex check install">
<div class="set-row-text">
<span class="set-row-label">Check this machine</span>
<span class="set-row-desc">Runs <code>codeman doctor</code> on the server: which agent CLIs, tmux, Node and the optional office tools are installed, their versions, and how to install what is missing.</span>
</div>
<button class="btn-toolbar btn-sm" id="doctorRunBtn" onclick="app.runDoctor()">Run checks</button>
</div>
<div id="doctorResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Paths</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
@@ -2878,18 +3023,30 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 8v8M8 12h8"/></svg>
<h2>Create New</h2>
</div>
<p class="set-section-blurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
<p class="set-section-blurb" id="newCaseBlurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
<div class="form-row">
<label>Case Name</label>
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
<span class="form-hint" id="newCaseNameHint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
</div>
<div class="form-row">
<label>Description (optional)</label>
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
</div>
<div class="form-row" id="newCaseCustomPathToggleRow">
<label class="checkbox-row"><input type="checkbox" id="newCaseCustomPathToggle" onchange="app.toggleNewCaseCustomPath()"> 📁 Create in a custom folder</label>
<span class="form-hint">By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.</span>
</div>
<div class="form-row" id="newCaseCustomPathRow" style="display:none">
<label>Parent Folder</label>
<div class="path-input-group">
<input type="text" id="newCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
<button type="button" class="btn path-input-browse" onclick="app.openNewCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint" id="newCasePathPreview">Pick the folder the new case folder should be created inside.</span>
</div>
<div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker" onchange="app.toggleNewCaseCustomPath()"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
</div>
@@ -3751,10 +3908,14 @@
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Shows iOS Safari IME composition text inside the terminal. Must precede terminal-ui.js. -->
<script defer src="mobile-ime-preview.js"></script>
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
<script defer src="terminal-keycode229-recovery.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<!-- Owner tab layout projection (grouped vertical rail); pure, read by app.js. -->
<script defer src="tab-layout-browser.js"></script>
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script>
@@ -3775,6 +3936,7 @@
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
<script defer src="git-status-ui.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+297
View File
@@ -0,0 +1,297 @@
/**
* @fileoverview In-terminal preview of IME composition text on iOS Safari.
*
* On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
* Korean, dictation) is not visible inside the terminal until it commits, so
* the user types blind. The controller listens to the helper textarea's
* composition events and asks the caller to render the latest composition
* (`phase: 'provisional'`), coalesced to one render per animation frame and
* capped at 2048 characters. When xterm emits the committed text through
* onData, the caller hands it to `consumeTerminalData()`, which switches the
* preview to `phase: 'committed'` until something else shows the text: the
* local echo overlay or a prediction (`completeCommit`), authoritative
* terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer. The
* same 2 s bound applies while waiting for a commit that never reaches onData
* (the user deleted the whole composition), so a later unrelated chunk is never
* mistaken for it.
*
* Keydown ordering: xterm registers its textarea keydown listener in the
* capture phase inside terminal.open() and finalizes the composition there
* (CompositionHelper.keydown), emitting the commit through onData
* synchronously. The controller therefore observes keydown in the capture
* phase on an ANCESTOR (`keydownTarget`, the terminal element), which runs
* before any listener on the textarea itself, and finalizes on exactly the
* keys xterm does.
*
* VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
* and every callback is wrapped so a failing render cannot block the wire.
* `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
* composition view untouched.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
*/
(function (global) {
'use strict';
const COMMITTED_VISUAL_TTL = 2000;
const PREVIEW_CAP = 2048;
// keyCodes on which xterm 6's CompositionHelper.keydown keeps composing
// (CapsLock, the IME "composition character", Shift/Ctrl/Alt). Any other
// keydown during a composition finalizes it.
const KEEP_COMPOSING_KEYCODES = new Set([20, 229, 16, 17, 18]);
const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
function isIosWebKitTouch(nav = navigator) {
const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
const platform = String(nav && nav.platform ? nav.platform : '');
const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
}
function create(options) {
const textarea = options.textarea;
// Must be the textarea or an ancestor of it, so its capture listener runs
// before xterm's capture listener on the textarea.
const keydownTarget = options.keydownTarget || textarea;
const render = typeof options.render === 'function' ? options.render : function () {};
const clear = typeof options.clear === 'function' ? options.clear : function () {};
const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
const setTimer = options.setTimer || global.setTimeout.bind(global);
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
let generation = 0;
let composing = false;
let awaitingCommit = false;
let committed = false;
let latestValue = '';
let renderPhase = null;
let frameToken = null;
let timerToken = null;
let finalizedByKeydown = false;
let destroyed = false;
let invokingClear = false;
function safely(callback, ...args) {
try {
return callback(...args);
} catch (_error) {
return undefined;
}
}
function cancelScheduledFrame() {
const token = frameToken;
frameToken = null;
if (token && token.id !== undefined) safely(cancelFrame, token.id);
}
function cancelCommittedTimer() {
const token = timerToken;
timerToken = null;
if (token && token.id !== undefined) safely(clearTimer, token.id);
}
function clearVisual() {
if (invokingClear) return;
invokingClear = true;
safely(clear);
invokingClear = false;
}
function cleanup() {
generation += 1;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = false;
latestValue = '';
renderPhase = null;
finalizedByKeydown = false;
clearVisual();
}
function scheduleLatestPreview(phase) {
if (destroyed) return;
renderPhase = phase;
if (frameToken) return;
const token = { generation, id: undefined };
frameToken = token;
const callback = function () {
if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
frameToken = null;
const value = latestValue.slice(0, PREVIEW_CAP);
const phaseToRender = renderPhase;
safely(render, { text: value, phase: phaseToRender });
};
const id = safely(scheduleFrame, callback);
if (frameToken === token) {
if (id === undefined) frameToken = null;
else token.id = id;
}
}
function beginComposition() {
cleanup();
if (destroyed) return;
composing = true;
}
function updateComposition(event) {
if (!composing) return;
latestValue = event.data == null ? '' : String(event.data);
scheduleLatestPreview('provisional');
}
function onComposingInput(event) {
if (!event.isComposing) return;
updateComposition({ data: event.data == null ? textarea.value : event.data });
}
function armFallbackTimer(isCurrent) {
const token = { generation, id: undefined };
timerToken = token;
const callback = function () {
if (destroyed || timerToken !== token || token.generation !== generation || !isCurrent()) return;
timerToken = null;
cleanup();
};
const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
if (timerToken === token) {
if (id === undefined) {
timerToken = null;
return false;
}
token.id = id;
}
return true;
}
function finalizeComposition(value, fromKeydown) {
if (!composing) return;
composing = false;
awaitingCommit = true;
committed = false;
finalizedByKeydown = fromKeydown;
latestValue = value == null ? latestValue : String(value);
scheduleLatestPreview('provisional');
// A commit that never reaches onData (the composition was deleted, so
// xterm emits nothing) must not leave the controller waiting forever.
cancelCommittedTimer();
const owner = generation;
armFallbackTimer(function () {
return awaitingCommit && generation === owner;
});
}
function onCompositionEnd(event) {
if (finalizedByKeydown) {
finalizedByKeydown = false;
return;
}
finalizeComposition(event.data, false);
}
// Mirrors CompositionHelper.keydown in @xterm/xterm 6.0.0
// (src/browser/input/CompositionHelper.ts:94-108): while composing, keyCode
// 20/229 and 16/17/18 keep the composition open and every other keyCode
// finalizes it. `isComposing` and `key` are deliberately not consulted,
// because xterm does not consult them.
function onKeydown(event) {
if (keydownTarget !== textarea && event.target !== textarea) return;
if (!composing || KEEP_COMPOSING_KEYCODES.has(event.keyCode)) return;
finalizeComposition(latestValue, true);
}
function reset() {
if (destroyed) return;
cleanup();
}
function consumeTerminalData(data) {
if (
destroyed ||
!awaitingCommit ||
typeof data !== 'string' ||
data.length === 0 ||
CONTROL_OR_LINE_BREAK.test(data)
) {
return false;
}
generation += 1;
const owner = generation;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = true;
latestValue = data;
renderPhase = 'committed';
safely(onCommit, data);
if (destroyed || generation !== owner || !committed) return true;
scheduleLatestPreview('committed');
if (destroyed || generation !== owner || !committed) return true;
const armed = armFallbackTimer(function () {
return committed;
});
if (!armed && !destroyed && generation === owner && committed) cleanup();
return true;
}
function completeCommit(result) {
if (destroyed || !result || result.predicted !== true || !committed) return;
cleanup();
}
function noteAuthoritativeOutput() {
if (destroyed || !committed) return;
cleanup();
}
const listeners = [
[textarea, 'compositionstart', beginComposition],
[textarea, 'compositionupdate', updateComposition],
[textarea, 'input', onComposingInput],
[textarea, 'compositionend', onCompositionEnd],
[keydownTarget, 'keydown', onKeydown, true],
[textarea, 'blur', reset],
];
for (const [target, type, listener, capture] of listeners) target.addEventListener(type, listener, capture);
function destroy() {
if (destroyed) return;
destroyed = true;
for (const [target, type, listener, capture] of listeners) target.removeEventListener(type, listener, capture);
cleanup();
}
return {
consumeTerminalData,
completeCommit,
noteAuthoritativeOutput,
reset,
destroy,
get state() {
return {
generation,
composing,
awaitingCommit,
committed,
latest: latestValue,
framePending: frameToken !== null,
timerPending: timerToken !== null,
};
},
};
}
global.MobileImePreview = { create, isIosWebKitTouch };
})(globalThis);
+27 -11
View File
@@ -153,7 +153,6 @@ Object.assign(CodemanApp.prototype, {
const edges = this._collectLineageEdges();
if (edges.length === 0) return;
this._lineageEdgeCount = edges.length;
if (!rects) rects = new Map();
// PHASE 1 — reads.
@@ -162,19 +161,35 @@ Object.assign(CodemanApp.prototype, {
const stripRect = strip.getBoundingClientRect();
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
for (const edge of edges) {
for (const id of [edge.parentId, edge.childId]) {
const key = 'tab:' + id;
if (rects.has(key)) continue;
// A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the arc moves to that group's header (a
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
// header would be an arc from a row to itself: skipped.
const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
rects.set(key, tab ? tab.getBoundingClientRect() : null);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header };
};
const resolvedEdges = [];
for (const edge of edges) {
const parentEndpoint = resolveEndpoint(edge.parentId);
const childEndpoint = resolveEndpoint(edge.childId);
if (parentEndpoint.key === childEndpoint.key) continue;
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
for (const endpoint of [parentEndpoint, childEndpoint]) {
if (rects.has(endpoint.key)) continue;
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
}
}
this._lineageEdgeCount = resolvedEdges.length;
// PHASE 2 — writes, from the cache only.
for (const edge of edges) {
const parentRect = rects.get('tab:' + edge.parentId);
const childRect = rects.get('tab:' + edge.childId);
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
const parentRect = rects.get(parentEndpoint.key);
const childRect = rects.get(childEndpoint.key);
if (!parentRect || !childRect) continue;
const geom = compute({
@@ -191,7 +206,8 @@ Object.assign(CodemanApp.prototype, {
// The working class marches the dashes, so an active worker is visible along
// the line itself. `status` is the CHILD's, which is the interesting end.
const working = edge.status === 'working' ? ' lineage-line--working' : '';
line.setAttribute('class', 'connection-line lineage-line' + working);
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
// Every arc out of one tab shares it — see _lineageColorFor().
@@ -211,7 +227,7 @@ Object.assign(CodemanApp.prototype, {
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working);
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
svg.appendChild(dot);
+177 -23
View File
@@ -1874,10 +1874,14 @@ Object.assign(CodemanApp.prototype, {
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = (await caseRes.json())?.data ?? {};
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
// Create the case if it doesn't exist
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2084,10 +2088,14 @@ Object.assign(CodemanApp.prototype, {
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = (await caseRes.json())?.data ?? {};
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
// Create the case if it doesn't exist
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2589,6 +2597,62 @@ Object.assign(CodemanApp.prototype, {
return typeof confirmed === 'string' ? confirmed : name;
},
/**
* Write an inline rename, one PUT per session at a time, in the order the
* user made them. The editor can be reopened (or cancelled, or replaced by a
* group rename) while a PUT is in flight, so the write lives here rather than
* in the editor: a confirmed name is applied locally even after its editor is
* gone, and the "already that name" check runs only once the earlier writes
* have landed, so confirming the name still on screen is a real write.
* Resolves { status: 'confirmed' | 'failed' | 'deleted' }; never rejects,
* and reports a failed write itself, since its editor may be gone by then.
* `_inlineRenamePending` holds the newest queued name per session, so an
* editor reopened over a write in flight starts from that name rather than
* the one the server has not replaced yet.
*/
_queueInlineSessionName(sessionId, desiredName) {
this._inlineRenameWrites ??= new Map();
this._inlineRenamePending ??= new Map();
const writes = this._inlineRenameWrites;
const pending = this._inlineRenamePending;
pending.set(sessionId, desiredName);
// Chained from a settled promise, so one rejected write cannot stop the
// writes queued behind it.
const prev = (writes.get(sessionId) || Promise.resolve()).catch(() => {});
const task = prev.then(async () => {
const session = this.sessions.get(sessionId);
if (!session) return { status: 'deleted' };
if (session.name === desiredName) return { status: 'confirmed' };
let confirmed = null;
try {
confirmed = await this._putSessionName(sessionId, desiredName);
} catch {
// A failure is a value, so a later write in the chain still runs.
}
if (!this.sessions.has(sessionId)) return { status: 'deleted' };
if (confirmed === null) {
this.showToast('Failed to rename', 'error');
return { status: 'failed' };
}
try {
this._applyLocalSessionName(sessionId, confirmed);
this.renderSessionTabs();
} catch (err) {
// The server holds the name; a local repaint failing is not a failed write.
console.error('[rename] applying the confirmed name failed', err);
}
return { status: 'confirmed' };
});
writes.set(sessionId, task);
const cleanup = () => {
if (writes.get(sessionId) !== task) return;
writes.delete(sessionId);
pending.delete(sessionId);
};
task.then(cleanup, cleanup);
return task;
},
async saveSessionName() {
if (!this.editingSessionId) return;
// Captured: the modal can be closed (or switched to another session) while
@@ -2877,7 +2941,11 @@ Object.assign(CodemanApp.prototype, {
tabName.classList.add('tab-name-renaming');
const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name);
// A rename still in flight is the user's last word, not the name the
// server has yet to replace: start from it, and compare against it below.
const shownName = this._inlineRenamePending?.get(sessionId) ?? session.name;
const renameInFlight = shownName !== session.name;
const parsed = parseSessionPrefix(shownName);
const originalContent = tabName.textContent;
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
const restoreOriginalChildren = () => {
@@ -2898,13 +2966,17 @@ Object.assign(CodemanApp.prototype, {
const input = document.createElement('input');
input.type = 'text';
input.value = parsed ? parsed.suffix : (session.name || '');
input.value = parsed ? parsed.suffix : (shownName || '');
input.placeholder = parsed ? 'Add description...' : currentName;
input.className = 'tab-rename-input';
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
// should give the whole line to the input.
const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px';
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
// should give the whole line to the input. The header editor may shrink to
// nothing, while a rail or sidebar row always keeps room to type.
const inRail = !!tabName.closest('.tab-rail');
const inSidebar = !inRail && !!this.isSessionSidebarActive?.();
const renameWidth = inRail ? 'auto' : inSidebar ? '100%' : '80px';
const renameMinWidth = inRail || inSidebar ? '4rem' : '0';
input.style.cssText = `width: ${renameWidth}; min-width: ${renameMinWidth}; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
tabName.appendChild(input);
input.focus();
@@ -2954,22 +3026,20 @@ Object.assign(CodemanApp.prototype, {
const suffix = input.value.trim();
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
if (fullName === session.name) restoreOriginalChildren();
// An unchanged confirm puts the old label back, unless the editor opened
// over a rename in flight: that label was repainted from the server's
// older name, so show the in-flight name rather than make it look lost.
if (fullName === shownName && !renameInFlight) restoreOriginalChildren();
else tabName.textContent = fullName || originalContent;
// Skip the API call if the session vanished between focus and blur.
const stillExists = this.sessions.has(sessionId);
if (stillExists && fullName !== session.name) {
const confirmed = await this._putSessionName(sessionId, fullName);
// Skip the API call if the session vanished between focus and blur. The
// queue applies the confirmed name to this.sessions before the re-render
// below repaints from it (see _applyLocalSessionName()).
if (this.sessions.has(sessionId)) {
const result = await this._queueInlineSessionName(sessionId, fullName);
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
if (confirmed === null) {
restoreOriginalChildren();
this.showToast('Failed to rename', 'error');
} else {
// The re-render below repaints from this.sessions, so the new name has
// to be in the map before it runs (see _applyLocalSessionName()).
this._applyLocalSessionName(sessionId, confirmed);
}
// The queue reports a failure itself; the editor only puts its label back.
if (result.status === 'failed') restoreOriginalChildren();
}
// Re-render tabs to restore full tab structure
completeCurrentRename();
@@ -3098,6 +3168,16 @@ Object.assign(CodemanApp.prototype, {
showCreateCaseModal() {
document.getElementById('newCaseName').value = '';
document.getElementById('newCaseDescription').value = '';
// Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the
// server refuses it (it writes outside the cases directory and into the shared registry).
const customToggle = document.getElementById('newCaseCustomPathToggle');
if (customToggle) customToggle.checked = false;
const customPath = document.getElementById('newCasePath');
if (customPath) customPath.value = '';
const me = window.__codemanUser || {};
const customRow = document.getElementById('newCaseCustomPathToggleRow');
if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
this.toggleNewCaseCustomPath();
document.getElementById('linkCaseName').value = '';
document.getElementById('linkCasePath').value = '';
const remoteFields = [
@@ -3265,6 +3345,71 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Custom-folder row for Create New: shows or hides the parent-folder field, and keeps it and the
* Docker option mutually exclusive (a Docker case has its own workspace flow, and the quick-create
* route has no `path`).
*/
toggleNewCaseCustomPath() {
const custom = document.getElementById('newCaseCustomPathToggle');
const docker = document.getElementById('newCaseDocker');
const row = document.getElementById('newCaseCustomPathRow');
if (!custom || !row) return;
row.style.display = custom.checked ? '' : 'none';
// The "under ~/codeman-cases" wording is wrong while a custom folder is picked.
const blurb = document.getElementById('newCaseBlurb');
if (blurb) {
blurb.textContent = custom.checked
? 'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.'
: 'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.';
}
const nameHint = document.getElementById('newCaseNameHint');
if (nameHint) {
nameHint.textContent = custom.checked
? 'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.'
: 'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/';
}
custom.disabled = !!docker?.checked;
custom.title = docker?.checked ? 'Not available for a Docker case' : '';
if (docker) {
docker.disabled = custom.checked;
docker.title = custom.checked ? 'Not available with a custom folder' : '';
}
this.updateNewCasePathPreview();
},
/** The folder the case would be created in: the parent field plus the case name. */
_newCaseTargetPath() {
const rawParent = (document.getElementById('newCasePath')?.value || '').trim();
const name = (document.getElementById('newCaseName')?.value || '').trim();
if (!rawParent || !name) return '';
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
const parent = rawParent.replace(/\/+$/, '');
return `${parent}/${name}`;
},
updateNewCasePathPreview() {
const hint = document.getElementById('newCasePathPreview');
if (!hint) return;
const target = this._newCaseTargetPath();
hint.textContent = target ? `Will create: ${target}` : 'Pick the folder the new case folder should be created inside.';
},
openNewCasePathPicker() {
const input = document.getElementById('newCasePath');
PathPicker.open({
title: 'Choose the folder to create the case in',
initialPath: input.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
input.value = path;
this.updateNewCasePathPreview();
input.focus();
input.setSelectionRange(path.length, path.length);
},
});
},
async createCase() {
const name = document.getElementById('newCaseName').value.trim();
const description = document.getElementById('newCaseDescription').value.trim();
@@ -3282,9 +3427,16 @@ Object.assign(CodemanApp.prototype, {
// One-click "Run in Docker": create the case folder AND a container, then start
// a session inside it. Optional expandable settings override the defaults.
const inDocker = document.getElementById('newCaseDocker')?.checked;
const customFolder = !inDocker && document.getElementById('newCaseCustomPathToggle')?.checked;
if (customFolder && !(document.getElementById('newCasePath')?.value || '').trim()) {
this.showToast('Choose the folder to create the case in', 'error');
return;
}
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
const payload = inDocker
? { name, description, ...this._collectDockerQuickSettings() }
: customFolder
? { name, description, path: this._newCaseTargetPath() }
: { name, description };
try {
@@ -3307,7 +3459,9 @@ Object.assign(CodemanApp.prototype, {
// Start a session INSIDE the container (routes through quick-start).
await this.runClaude();
} else {
this.showToast(`Case "${name}" created`, 'success');
// The server's path is the folder actually created (~ expanded, symlinks resolved).
const createdIn = data.data?.case?.path || payload.path;
this.showToast(customFolder ? `Case "${name}" created in ${createdIn}` : `Case "${name}" created`, 'success');
}
} else {
this.showToast(data.error || 'Failed to create case', 'error');
+332 -1
View File
@@ -416,6 +416,14 @@ Object.assign(CodemanApp.prototype, {
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
// The routes read the SAVED setting, so remember what it was on open: switching it on
// here does nothing server-side until Save (see mcpSync()).
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility();
this._applyDoctorAdminGate();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -443,6 +451,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
// 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
@@ -1137,6 +1147,301 @@ Object.assign(CodemanApp.prototype, {
this._updateCheck = null;
},
/**
* Settings → Terminal & Input → Key tester: prints what the browser reports for each key event.
* Read-only and local; it never reaches a session. keypress is shown on purpose: that event is
* why a Shift-only Enter used to submit (xterm drops Ctrl/Alt keypresses, not Shift ones).
*/
keyTesterEvent(ev) {
const log = document.getElementById('keyTesterLog');
if (!log) return;
// Never preventDefault on keydown: that suppresses the keypress this panel exists to show.
// The field is readonly, so nothing is typed into it either way.
const mods = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey'].filter((m) => ev[m]).map((m) => m.replace('Key', ''));
const line =
`${ev.type.padEnd(8)} key=${JSON.stringify(ev.key)} code=${ev.code || '-'} ` +
`mods=${mods.join('+') || 'none'}` +
(ev.type === 'keypress' ? ` charCode=${ev.charCode}` : '') +
(ev.repeat ? ' (repeat)' : '');
const lines = (log.textContent ? log.textContent.split('\n') : []).concat(line);
log.textContent = lines.slice(-14).join('\n');
log.style.display = 'block';
},
/**
* MCP sync is opt-in (`mcpSyncEnabled`): with the flag off the action row is hidden rather than
* shown disabled, because both endpoints would only answer 403. Called on open and from the
* checkbox's own onchange (assigning .checked fires no change event).
*/
applyMcpSyncVisibility() {
const on = document.getElementById('appSettingsMcpSync')?.checked ?? false;
const row = document.getElementById('mcpSyncActionRow');
if (row) row.style.display = on ? '' : 'none';
const out = this.$('mcpSyncResult');
if (!on && out) { out.style.display = 'none'; out.innerHTML = ''; }
this._applyMcpSyncAdminGate();
},
/**
* Both /api/mcp-sync verbs are admin-only in multi-user mode (they write files in the server
* user's home), so a non-admin gets no MCP group at all, switch included, the same way
* _applyCliManagementAdminGate hides the CLI list. Also wired to `codeman:me`, because
* `window.__codemanUser`'s real role can resolve after settings were opened once.
*/
_applyMcpSyncAdminGate() {
const group = document.getElementById('mcpSyncGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},
/**
* GET /api/doctor is admin-only in multi-user mode (it names install paths on the host), so a
* non-admin gets no Diagnostics group instead of a button that can only answer 403. Also
* wired to `codeman:me` for the same late-resolving role as the groups above.
*/
_applyDoctorAdminGate() {
const group = document.getElementById('doctorGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
const show = (html) => {
if (out) { out.style.display = 'block'; out.innerHTML = html; }
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
show('Working…');
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
show(escapeHtml(body?.error || 'MCP sync failed.'));
return;
}
const data = body.data;
const rows = data.targets.map((t) => {
if (t.status === 'absent') return `<li><b>${escapeHtml(t.label)}</b>: not installed, skipped</li>`;
if (t.status === 'skipped') return `<li><b>${escapeHtml(t.label)}</b>: not touched (${escapeHtml(t.error || 'config location unknown')})</li>`;
if (t.status === 'unreadable') return `<li><b>${escapeHtml(t.label)}</b>: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})</li>`;
if (t.status === 'failed') return `<li><b>${escapeHtml(t.label)}</b>: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged</li>`;
const verb = data.applied ? 'added' : 'would add';
const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date'];
if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`);
const count = `${t.servers.length} server${t.servers.length === 1 ? '' : 's'}`;
return `<li><b>${escapeHtml(t.label)}</b> (${count}): ${parts.join('; ')}</li>`;
});
const conflicts = data.conflicts.length
? `<p>Defined differently across CLIs (each existing definition is kept; the first CLI's is copied where the name is missing): ${data.conflicts.map(escapeHtml).join(', ')}</p>`
: '';
const disabled = data.disabled?.length
? `<p>Switched off in their own CLI, so not copied: ${data.disabled.map(escapeHtml).join(', ')}</p>`
: '';
const unsupported = data.unsupported?.length
? `<p>No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}</p>`
: '';
show(`<ul>${rows.join('')}</ul>${conflicts}${disabled}${unsupported}`);
},
/**
* Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a
* settings-payload field: the URL is a secret, so it never round-trips through settings.json or
* this page. The URL box is write-only; the status line shows scheme + host only.
*
* Three ways to save, one PUT: the group's own Save, Send test (saves pending edits first, so it
* never tests the old URL while the box shows a new one), and the modal's main Save, which calls
* saveWebhook() beside the settings PUT the same way it saves the model config
* (saveModelConfigFromSettings). `_webhookLoaded` is what loadWebhook() put on screen, so
* `_webhookPending()` can tell an edited group from an untouched one.
*/
_webhookSay(text, bad = false) {
const out = document.getElementById('webhookResult');
if (!out) return;
out.textContent = text;
out.style.display = text ? 'block' : 'none';
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
},
async loadWebhook() {
const group = document.getElementById('webhookGroup');
if (!group) return;
const res = await this._api('/api/webhook');
if (!res || !res.ok) {
this._webhookLoaded = null;
group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route
return;
}
let body = null;
try { body = await res.json(); } catch { /* leave hidden */ }
if (!body || body.success === false) { this._webhookLoaded = null; group.style.display = 'none'; return; }
const d = body.data;
group.style.display = '';
document.getElementById('webhookEnabled').checked = d.enabled === true;
document.getElementById('webhookKind').value = d.kind;
document.getElementById('webhookScope').value = d.scope;
const url = document.getElementById('webhookUrl');
url.value = '';
url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic';
document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.';
const clearBtn = document.getElementById('webhookClearBtn');
if (clearBtn) clearBtn.style.display = d.hasUrl ? '' : 'none';
// Read back from the controls, so a value the <select> does not offer compares as what is shown.
this._webhookLoaded = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
if (d.lastResult) {
const when = new Date(d.lastResult.at).toLocaleString();
this._webhookSay(
d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`,
!d.lastResult.ok
);
} else {
this._webhookSay('');
}
},
/** True when the visible webhook group differs from what loadWebhook() last showed. */
_webhookPending() {
const group = document.getElementById('webhookGroup');
const loaded = this._webhookLoaded;
if (!group || group.style.display === 'none' || !loaded) return false;
return (
document.getElementById('webhookUrl').value.trim() !== '' ||
document.getElementById('webhookEnabled').checked !== loaded.enabled ||
document.getElementById('webhookKind').value !== loaded.kind ||
document.getElementById('webhookScope').value !== loaded.scope
);
},
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
async saveWebhook() {
const payload = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
const url = document.getElementById('webhookUrl').value.trim();
if (url) payload.url = url; // blank = keep the saved one (Remove URL is the way to clear it)
const res = await this._api('/api/webhook', { method: 'PUT', body: payload });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
const error = body?.error || 'Could not save the webhook.';
this._webhookSay(error, true);
return error;
}
await this.loadWebhook();
this._webhookSay('Saved.');
return '';
},
/** Delete the saved URL from the server (the API clears on `url: ""`), which also turns the channel off. */
async clearWebhook() {
if (!confirm('Remove the saved webhook URL from the server? Webhook alerts stop until you save a new one.')) return;
const res = await this._api('/api/webhook', { method: 'PUT', body: { url: '', enabled: false } });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not remove the webhook URL.', true);
return;
}
await this.loadWebhook();
this._webhookSay('Webhook URL removed.');
},
async testWebhook() {
const btn = document.getElementById('webhookTestBtn');
if (btn) btn.disabled = true;
try {
if (this._webhookPending() && (await this.saveWebhook())) return; // the save's error is already shown
this._webhookSay('Sending…');
const res = await this._apiPost('/api/webhook/test', {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not send the test.', true);
return;
}
const r = body.data;
this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok);
} finally {
if (btn) btn.disabled = false;
}
},
/**
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
*/
async runDoctor() {
const out = document.getElementById('doctorResult');
const btn = document.getElementById('doctorRunBtn');
if (!out) return;
const say = (text) => {
out.replaceChildren(document.createTextNode(text));
out.style.display = 'block';
};
if (btn) btn.disabled = true;
say('Checking…');
try {
const res = await this._api('/api/doctor');
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
say(body?.error || 'The check failed.');
return;
}
const { tools, summary, platform } = body.data;
const glyph = { ok: '✓', missing: '✗', outdated: '!', error: '!', skipped: '–' };
const list = document.createElement('ul');
list.style.margin = '0';
list.style.paddingLeft = '1.2em';
for (const t of tools) {
const li = document.createElement('li');
const strong = document.createElement('b');
// As the terminal doctor marks it: a missing OPTIONAL tool is ○, only a required one ✗.
const mark = t.status === 'missing' && !t.required ? '○' : glyph[t.status] || '?';
strong.textContent = `${mark} ${t.label}`;
li.append(strong);
const bits = [t.status];
if (t.version) bits.push(t.version);
if (t.status !== 'ok' && t.status !== 'skipped') bits.push(t.required ? 'required' : 'optional');
if (t.reason) bits.push(t.reason);
li.append(document.createTextNode(` ${bits.join(' · ')}`));
if (t.path) {
const p = document.createElement('div');
p.className = 'mono';
p.textContent = t.path;
li.append(p);
}
if (t.status === 'missing' && t.installHint) {
const h = document.createElement('div');
h.textContent = `Install: ${t.installHint}`;
li.append(h);
}
list.append(li);
}
const head = document.createElement('p');
head.textContent =
`${summary.ok} ok · ${summary.requiredMissing} required missing · ${summary.optionalMissing} optional missing` +
` (${platform.environment})`;
out.replaceChildren(head, list);
out.style.display = 'block';
} finally {
if (btn) btn.disabled = false;
}
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
@@ -2182,6 +2487,7 @@ Object.assign(CodemanApp.prototype, {
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
@@ -2194,6 +2500,8 @@ Object.assign(CodemanApp.prototype, {
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -2446,6 +2754,9 @@ Object.assign(CodemanApp.prototype, {
showSessionButton: _ssb,
showAwayDigestButton: _adb,
showCronButton: _crb,
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
showGitStatus: _sgs,
gitStatusTree: _gst,
showTabDetachButton: _tdb,
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
mobileOverviewEnabled: _mov,
@@ -2455,6 +2766,7 @@ Object.assign(CodemanApp.prototype, {
sessionLineageLines: _sll,
...serverSettings
} = settings;
let webhookError = '';
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
@@ -2479,7 +2791,16 @@ Object.assign(CodemanApp.prototype, {
// Save model configuration separately
await this.saveModelConfigFromSettings();
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
// settings.json), so like the model config above it is saved beside the settings PUT, not in
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else {
this.showToast('Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
@@ -2490,7 +2811,11 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Settings saved locally', 'warning');
}
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
} else {
this.closeAppSettings();
}
// Voice availability is a server-side answer, so re-probe after a save:
// otherwise the mic keeps using the pre-save provider until the next reload.
@@ -3371,6 +3696,10 @@ Object.assign(CodemanApp.prototype, {
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
}
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
// match the setting, so a live toggle needs no reload.
this.applyGitStatusVisibility?.();
// 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');
@@ -3719,7 +4048,7 @@ Object.assign(CodemanApp.prototype, {
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
@@ -4130,4 +4459,6 @@ Object.assign(CodemanApp.prototype, {
document.addEventListener?.('codeman:me', () => {
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
window.app?._applyDoctorAdminGate?.();
});
+640 -5
View File
@@ -460,6 +460,31 @@ textarea:focus-visible {
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
}
.xterm-helpers .codeman-ime-preview {
position: absolute;
left: var(--xterm-helper-left, 0px);
top: var(--xterm-helper-top, 0px);
z-index: 6;
pointer-events: none;
white-space: pre;
color: var(--terminal-foreground, var(--text, #fff));
font-family: var(--font-mono, monospace);
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 1.2;
height: 1.2em;
}
.xterm-helpers .codeman-ime-preview[data-phase='provisional'] {
text-decoration: underline;
text-decoration-style: dotted;
}
.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {
display: none;
}
/* Session tab focus */
.session-tab:focus-visible {
outline: 2px solid var(--accent);
@@ -634,6 +659,163 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
min-width: 0;
}
/* --- Grouped rail (owner tab layout) ---------------------------------- */
/* Only rendered when the owner has at least one group (tab-layout-browser.js).
Each group's rows sit in their OWN flex column, so the activity sort's inline
`order` (and web tabs' `order: 9999`) rank rows within a group, never across. */
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group,
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-refs {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 2px;
min-width: 0;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group + .tab-layout-group {
margin-top: 4px;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
/* Positioned so a collapsed header can carry the tab alert ring (::before). */
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 4px 6px 2px;
border: 0;
border-radius: 4px;
color: var(--text-muted);
background: transparent;
font: inherit;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-align: left;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
cursor: pointer;
user-select: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:hover {
color: var(--text);
background: var(--bg-hover);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tab-layout-group-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-layout-group-count {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.tab-layout-group-chevron::before {
content: '\25BE';
display: inline-block;
}
/* Keyed on the section, not aria-expanded: a group with no open rows is a tree
leaf with no aria-expanded at all, and its chevron must still show its state. */
.tab-layout-group--collapsed > .tab-layout-group-toggle .tab-layout-group-chevron::before {
content: '\25B8';
}
/* Group editing: the header's menu glyph, the inline rename editor, and the
pointer-drag marks. All of it lives inside the grouped rail only. */
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu {
flex: 0 0 auto;
padding: 0 4px;
border-radius: 3px;
cursor: pointer;
opacity: 0;
letter-spacing: 0;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header:hover .tab-layout-group-menu,
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header:focus-visible .tab-layout-group-menu {
opacity: 1;
}
/* No hover on a touch tablet, and a long press there is not a contextmenu
event: the glyph is the only way into the group menu, so keep it shown. */
@media (hover: none) {
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu {
opacity: 1;
}
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu:hover {
color: var(--text);
background: var(--bg-tertiary, var(--bg-hover));
}
/* The label is a nowrap ellipsis box; while it holds the editor it becomes a
plain flex slot so the input gets the whole width and repaints as you type. */
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-name--renaming {
display: flex;
overflow: visible;
text-overflow: clip;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-rename-input {
flex: 1 1 0;
width: auto;
min-width: 0;
padding: 1px 4px;
border: 1px solid var(--accent);
border-radius: 3px;
outline: none;
color: var(--text);
background: var(--bg-input);
font: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: normal;
user-select: text;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .session-tab,
html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layout-group-header {
user-select: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active {
cursor: grabbing;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-dragging {
opacity: 0.45;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-before {
box-shadow: inset 0 2px 0 var(--accent);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-after {
box-shadow: inset 0 -2px 0 var(--accent);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-into {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
html[data-tab-orientation='vertical'] .header-right {
margin-left: auto;
}
@@ -1666,22 +1848,51 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
/* An input's intrinsic width counts toward the label's min-content, so with
the default min-width:auto a long `w<n>-<case>` prefix plus the input
pushed the label past its row: the prefix slid out of view and, in the
compact rail, the input was clipped mid-word. */
min-width: 0;
}
/* The prefix gives way first, down to a stub with an ellipsis. */
:is(
html[data-tab-orientation='vertical'] .tab-rail,
html[data-session-list='sidebar'] .session-sidebar
) .tab-name-renaming .tab-rename-prefix {
flex: 0 0 auto;
flex: 0 1 auto;
min-width: 2rem;
max-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
}
/* The input takes the rest of the row. Its 4rem floor is the editor's own
inline min-width (startInlineRename picks it per layout). */
:is(
html[data-tab-orientation='vertical'] .tab-rail,
html[data-session-list='sidebar'] .session-sidebar
) .tab-name-renaming .tab-rename-input {
flex: 1 1 0;
width: auto;
min-width: 0;
}
/* A compact rail row has no room for both the editor and its adornments, so
they step aside while the name is being edited (the re-render that ends the
edit brings them back). */
html[data-tab-orientation='vertical'].tab-rail-compact
.tab-rail
.session-tab:has(.tab-name-renaming)
:is(
.tab-mode,
.tab-exited-badge,
.tab-detached-badge,
.tab-badge,
.tab-subagent-badge,
.tab-ultracode-badge,
.tab-actions
) {
display: none;
}
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
@@ -1707,7 +1918,9 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
line-height: 1;
color: var(--text-muted);
overflow: hidden;
cursor: default;
/* A control, like its gear/detach siblings: the pointer must not read as
"nothing here" over the one button that closes the session. */
cursor: pointer;
border-radius: 3px;
/* Performance: use transform for GPU acceleration */
transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out;
@@ -1760,7 +1973,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
The overlay paints above the tab's inline content (positioned vs flow), which
is fine at these alphas and is exactly what keeps it visible over the active
tab's opaque-ish background. */
.session-tabs .session-tab.tab-alert-action::before {
.session-tabs .session-tab.tab-alert-action::before,
.session-tabs .tab-layout-group-header.tab-alert-action::before {
content: '';
position: absolute;
inset: -2px;
@@ -1785,7 +1999,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
}
.session-tabs .session-tab.tab-alert-idle::before {
.session-tabs .session-tab.tab-alert-idle::before,
.session-tabs .tab-layout-group-header.tab-alert-idle::before {
content: '';
position: absolute;
inset: -2px;
@@ -10510,6 +10725,21 @@ kbd {
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
}
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
and dotted, so it reads as "somewhere in there" rather than a real row. */
.connection-line.lineage-line--proxied {
stroke-dasharray: 2 7;
opacity: 0.46;
}
.connection-line.lineage-line--proxied.lineage-line--working {
opacity: 0.62;
}
.lineage-line-dot.lineage-line-dot--proxied {
opacity: 0.46;
}
/* The child end marches while that worker is actually working, so the line
itself carries the signal. Motion is opt-out-able at the OS level. */
@media (prefers-reduced-motion: no-preference) {
@@ -18694,6 +18924,13 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
line-clamp: 3;
}
/* ...except while it is being edited: this rule outranks the shared renaming
rule's unclamp, so restate it (the editor is a flex row, never clamped). */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name.tab-name-renaming {
-webkit-line-clamp: unset;
line-clamp: unset;
}
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-folder {
font-size: 0.66rem;
margin-top: 0.1rem;
@@ -19153,3 +19390,401 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
font-size: 13px;
color: var(--text-muted);
}
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
/* .btn-toolbar sets display:flex, which beats the [hidden] attribute's UA display:none. */
.btn-git-status[hidden] {
display: none;
}
.btn-git-status {
gap: 0.4rem;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.68rem;
padding: 0.2rem 0.55rem;
white-space: nowrap;
}
.btn-git-status .git-status-label {
letter-spacing: 0.02em;
}
/* The skin block in styles.css nests every .btn-toolbar rule under html:not([data-skin="og"]), which
out-ranks a bare .btn-git-status.<tone> rule, so the tones carry the same `html .toolbar` prefix
(specificity (0,3,1) against the skin's (0,2,1)). Without it the amber never shows. */
html .toolbar .btn-git-status.git-status--clean {
color: var(--text-muted);
}
html .toolbar .btn-git-status.git-status--clean svg {
color: rgb(34, 197, 94);
}
html .toolbar .btn-git-status.git-status--dirty {
color: #e0a030;
border-color: rgba(224, 160, 48, 0.4);
}
html .toolbar .btn-git-status.git-status--conflict {
color: #e5534b;
border-color: rgba(229, 83, 75, 0.5);
}
html .toolbar .btn-git-status[aria-expanded='true'] {
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* Same look as the Files window. Default spot is just left of it so both can be open. */
.git-status-panel {
position: fixed;
top: calc(var(--header-height) + 10px);
/* Just left of the Files window; on a narrow viewport slide right so the left edge never leaves the screen. */
right: clamp(8px, calc(100vw - 388px), 320px);
width: 380px;
max-width: calc(100vw - 16px);
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
max-height: 600px;
min-width: 260px;
min-height: 220px;
background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--control-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 100;
display: none;
flex-direction: column;
overflow: hidden;
resize: both;
}
.git-status-panel.visible {
display: flex;
}
.git-status-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.6rem;
background: var(--bg-input);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
cursor: move;
user-select: none;
touch-action: none;
}
.git-status-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
}
.git-status-branch {
margin-left: 0.4rem;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
font-weight: 400;
color: var(--text-muted);
}
.git-status-actions {
display: flex;
gap: 0.25rem;
}
.git-status-body {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0.6rem;
font-size: 0.74rem;
color: var(--text);
}
.git-status-footer {
padding: 0.35rem 0.6rem;
border-top: 1px solid var(--border);
font-size: 0.64rem;
color: var(--text-muted);
flex-shrink: 0;
}
.git-status-empty,
.git-status-ok,
.git-status-note,
.git-status-more {
color: var(--text-muted);
padding: 0.25rem 0;
}
.git-status-ok {
color: rgb(34, 197, 94);
}
.git-status-branchline {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.6rem;
}
.git-status-chip {
padding: 0.1rem 0.45rem;
border: 1px solid var(--border);
border-radius: 999px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.66rem;
color: var(--text-muted);
}
.git-status-chip--warn {
color: #e0a030;
border-color: rgba(224, 160, 48, 0.4);
}
.git-status-section {
margin-bottom: 0.75rem;
}
.git-status-section-title {
margin: 0 0 0.3rem;
font-size: 0.72rem;
font-weight: 600;
color: var(--text);
}
.git-status-group {
margin-bottom: 0.4rem;
}
.git-status-group-title {
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0.2rem 0;
}
.git-status-file,
.git-status-commit {
display: flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.15rem 0.25rem;
border-radius: 4px;
min-width: 0;
}
.git-status-file--clickable {
cursor: pointer;
}
.git-status-file--clickable:hover,
.git-status-file--clickable:focus-visible {
background: var(--bg-hover);
outline: none;
}
.git-status-badge {
flex: 0 0 1.1rem;
text-align: center;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.66rem;
font-weight: 700;
color: #e0a030;
}
.git-status-badge--A,
.git-status-badge--new {
color: rgb(34, 197, 94);
}
.git-status-badge--D,
.git-status-badge--U {
color: #e5534b;
}
.git-status-path,
.git-status-subject {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
}
.git-status-subject {
font-family: inherit;
flex: 1;
}
.git-status-orig,
.git-status-commit-meta {
flex: 0 0 auto;
font-size: 0.64rem;
color: var(--text-muted);
}
.git-status-hash {
flex: 0 0 auto;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.66rem;
color: #e0a030;
}
/* One section per repository when a session's folder holds several (git-status-ui.js). */
.git-status-repo {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.5rem;
overflow: hidden;
}
.git-status-repo-summary {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
background: var(--bg-input);
list-style: none;
min-width: 0;
}
.git-status-repo-summary::-webkit-details-marker {
display: none;
}
.git-status-repo-name {
font-weight: 600;
font-size: 0.74rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-status-repo-path,
.git-status-repo-branch {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.64rem;
color: var(--text-muted);
white-space: nowrap;
}
.git-status-repo-state {
margin-left: auto;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.68rem;
color: var(--text-muted);
white-space: nowrap;
}
.git-status-repo-state--dirty {
color: #e0a030;
}
.git-status-repo-body {
padding: 0.5rem 0.6rem;
}
/* Git status panel: per-file diff view */
.git-diff-bar {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.4rem;
min-width: 0;
}
.git-diff-path {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
}
.git-diff-kind {
font-size: 0.64rem;
color: var(--text-muted);
white-space: nowrap;
}
.git-diff {
margin: 0;
padding: 0.3rem 0;
overflow-x: auto;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.68rem;
line-height: 1.45;
border: 1px solid var(--border);
border-radius: 4px;
}
.git-diff-line {
display: block;
padding: 0 0.5rem;
white-space: pre;
}
.git-diff-line--add {
background: color-mix(in srgb, var(--green) 18%, transparent);
}
.git-diff-line--del {
background: color-mix(in srgb, var(--red) 18%, transparent);
}
.git-diff-line--hunk {
color: var(--text-muted);
background: var(--bg-hover);
}
.git-diff-line--meta {
color: var(--text-muted);
}
/* Git status panel: files grouped under collapsible folders */
.git-tree-dir > .git-tree-summary {
display: flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.15rem 0.25rem;
border-radius: 4px;
cursor: pointer;
min-width: 0;
}
.git-tree-dir > .git-tree-summary:hover {
background: var(--bg-hover);
}
.git-tree-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
}
.git-tree-count {
font-size: 0.64rem;
color: var(--text-muted);
}
.git-tree-children {
margin-left: 0.7rem;
padding-left: 0.4rem;
border-left: 1px solid var(--border);
}
+750
View File
@@ -0,0 +1,750 @@
/**
* @fileoverview Browser projection and editing of the owner tab layout.
*
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
* is the server model). Browser assets cannot import that TypeScript, so this
* module is a small, dependency-free mirror that owns four things:
*
* 1. Projection: which live sessions and open web tabs land in which group,
* and which rows a collapsed group hides.
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
* byte-identical to the flat rail's row.
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a capped, backed-off retry.
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
* row) applied optimistically and saved through ONE serialized
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
* version conflict.
*
* The server stays the only authority for layout content. Collapse is a
* per-device view preference and lives in localStorage only.
*
* Grouped rendering is opt-in by construction: a layout with no groups (every
* owner until they create one) projects to `null`, and the caller keeps the flat
* rail exactly as it was.
*
* @dependency none
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
*/
(function initCodemanTabLayout(global) {
'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
const refKey = (ref) => `${ref.kind}:${ref.id}`;
const validRef = (ref) =>
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))];
// `placement: 'manual'` must survive the round trip: the browser writes whole
// layouts back, and dropping it would re-attach a hand-placed child session to
// its parent's subtree on the next save.
const copyRef = (r) =>
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id };
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
const MAX_GROUPS = 32;
const MAX_NAME_LENGTH = 60;
/**
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
* server adding model fields cannot leak half-understood state into the view.
*/
function normalizeLayout(value) {
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
const groups = Array.isArray(value.groups) ? value.groups : [];
return {
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '',
groups: groups
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
.map((group) => ({
id: group.id,
name: typeof group.name === 'string' ? group.name : '',
refs: copyRefs(group.refs),
})),
ungrouped: copyRefs(value.ungrouped),
};
}
function hasGroups(layout) {
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
}
/** Stored collapse ids, or null when the stored value is not a JSON array. */
function parseCollapsedIds(raw) {
if (raw === null) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? stableIds(parsed) : null;
} catch (_error) {
return null;
}
}
/**
* Read the per-device collapse ids. `ok: false` means the STORE failed (a read
* or write threw), and the caller then keeps every group expanded. A malformed
* VALUE is not a store failure: it reads as "nothing collapsed" and is
* rewritten, or a shape left behind by another build (a rollback) would leave
* collapse disabled on this device for good.
*/
function loadCollapsedGroupIds(storage, validGroupIds) {
try {
const parsed = parseCollapsedIds(storage.getItem(COLLAPSED_STORAGE_KEY));
const loaded = parsed || [];
if (validGroupIds === undefined) return { ids: loaded, ok: true };
// Garbage-collect ids of groups that no longer exist, so a deleted group's
// id cannot silently collapse a future group that reuses it.
const valid = new Set(stableIds(validGroupIds));
const kept = loaded.filter((id) => valid.has(id));
if (!parsed || kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
return { ids: kept, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds);
try {
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
return { ids, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
/**
* Project a layout onto what is live in this browser.
*
* Every live session and open web tab appears exactly once: stored refs keep
* their group and stored order; anything the layout has not caught up with yet
* (a session created a moment ago, a web tab opened on this device only) is
* appended to the ungrouped section in the caller's order. Saved web tabs that
* are not open here are skipped, as are refs to sessions that are gone.
*
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
* tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
* null when the layout has no groups: the caller renders the flat rail
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
* collapse hides (`hidden`); `sectionByRef` maps every placed row
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
*/
function project(layoutInput, options = {}) {
if (!layoutInput) return null;
const layout = normalizeLayout(layoutInput);
if (!hasGroups(layout)) return null;
const liveSessionIds = stableIds(options.liveSessionIds);
const openWebviewIds = stableIds(options.openWebviewIds);
const live = new Set(liveSessionIds);
const open = new Set(openWebviewIds);
const collapsed = new Set(asIds(options.collapsedGroupIds));
const highlighted = options.activeWebviewId
? `webview:${options.activeWebviewId}`
: options.activeSessionId
? `session:${options.activeSessionId}`
: '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
const placed = new Set();
const visibleRefs = [];
const hiddenTabGroupByRef = {};
const sectionByRef = {};
const sections = [];
const place = (refs, sectionId, isCollapsed) => {
const shown = [];
const hidden = [];
let count = 0;
for (const ref of refs) {
const key = refKey(ref);
if (placed.has(key) || !renderable(ref)) continue;
placed.add(key);
sectionByRef[key] = sectionId;
count++;
const copy = { kind: ref.kind, id: ref.id };
if (isCollapsed && key !== highlighted) {
hiddenTabGroupByRef[key] = sectionId;
hidden.push(copy);
continue;
}
shown.push(copy);
visibleRefs.push(copy);
}
return { shown, hidden, count };
};
for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id);
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
}
const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
];
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
if (ungrouped.count > 0) {
sections.push({
id: null,
name: '',
refs: ungrouped.shown,
hidden: [],
count: ungrouped.count,
collapsed: false,
});
}
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
}
const ALERT_RANK = { action: 2, idle: 1 };
/**
* The most urgent alert behind each COLLAPSED header: `{ [groupId]: 'action' |
* 'idle' }` over the session rows the collapse hides. A shown row (the kept
* selection, any expanded group) draws its own alert, so it is not counted
* here. `alertOf(sessionId)` is the caller's tab alert lookup.
*/
function hiddenGroupAlerts(projection, alertOf) {
const result = {};
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
for (const section of sections) {
if (section.id === null || !Array.isArray(section.hidden)) continue;
let best = null;
for (const ref of section.hidden) {
if (ref.kind !== 'session') continue;
const alert = alertOf(ref.id);
if (ALERT_RANK[alert] && (!best || ALERT_RANK[alert] > ALERT_RANK[best])) best = alert;
}
if (best) result[section.id] = best;
}
return result;
}
/**
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
* where, the headers' names, what a collapse hides), as opposed to a row's own
* status/name/badges. The incremental render path only patches rows in place,
* so a change here forces a full rebuild.
*
* Deliberately NOT the layout version: the server bumps it on every session
* create/close and order PUT, and a bump that moves nothing visible must not
* cost every client a full tab-strip rebuild. `layout` is accepted for
* signature stability only.
*/
function structureKey(_layout, projection, collapsedGroupIds) {
if (!projection) return null;
return JSON.stringify({
collapsed: stableIds(collapsedGroupIds).sort(),
sections: projection.sections.map((section) => [
section.id,
section.name,
section.count,
section.refs.map(refKey),
(section.hidden || []).map(refKey),
]),
});
}
/**
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
* `escapeHtml` is the caller's escaper. Group names are user content, so they
* are escaped and marked `data-i18n-skip`.
*
* The caller makes the list itself the `tree` and marks rows up as treeitems
* (app.js `_applyTabTreeSemantics`); this markup supplies the structure:
* - a named group's header is a level-1 `treeitem` carrying `aria-expanded`.
* Its rows are a sibling `group`, so the header OWNS it via `aria-owns`
* (the rows sit below the header visually, not inside it).
* - a COLLAPSED group owns nothing: the one row it still shows (the
* selection) is a level-1 sibling, never the child of a closed node.
* - a group with NO open rows is a leaf: no `aria-expanded`, no owned group,
* so it is not announced as an expanded parent of an empty group.
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
* divider only, hidden from assistive tech, and its rows are not a group.
*/
function renderProjection(projection, renderRef, escapeHtml) {
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
return sections
.map((section, index) => {
const rows = section.refs.map((ref) => renderRef(ref)).join('');
if (section.id === null) {
return (
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
`<div class="tab-layout-group-header tab-layout-ungrouped-header" aria-hidden="true"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
);
}
const id = escapeHtml(section.id);
const refsId = `tab-layout-group-refs-${index}`;
const nameId = `tab-layout-group-name-${index}`;
const leaf = section.count === 0;
const expanded = !section.collapsed && !leaf;
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
return (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)" oncontextmenu="event.preventDefault(); app.openTabGroupMenu(event, this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span>` +
// Pointer path to the group menu (right-click on the header works too).
// Deliberately NOT a button and not focusable: a treeitem holds no
// interactive children, and the keyboard path is Shift+F10 /
// ContextMenu on the header itself. aria-hidden keeps the glyph out of
// the header's accessible name.
'<span class="tab-layout-group-menu" aria-hidden="true" title="Group actions" ' +
'onclick="event.stopPropagation(); app.openTabGroupMenu(event, this.closest(\'[data-tab-group-header]\').dataset.tabGroupHeader)">&#x22EF;</span></div>' +
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
);
})
.join('');
}
/**
* Newest-wins layout loading. A response that was overtaken by a later load is
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
* retry, replacing any retry already pending.
*
* Retries back off and stop: the delay doubles from `retryDelayMs` up to
* `maxRetryDelayMs`, and after `maxRetries` consecutive failures nothing more
* is scheduled (`scheduleRetry(fn, delayMs)`). The next outside load (an SSE
* reconnect re-runs init, a `tab:layoutChanged` re-reads) tries again, and
* any success resets the count.
*/
function createLoadCoordinator(options) {
const baseDelay = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 5000;
const maxDelay = Number.isFinite(options.maxRetryDelayMs) ? options.maxRetryDelayMs : 60000;
const maxRetries = Number.isSafeInteger(options.maxRetries) ? options.maxRetries : 4;
let generation = 0;
let disposed = false;
let retryHandle = null;
let failures = 0;
const clearRetry = () => {
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
retryHandle = null;
};
const load = async () => {
if (disposed) return false;
const requestGeneration = ++generation;
clearRetry();
try {
const layout = await options.fetchLayout();
if (disposed || requestGeneration !== generation) return false;
failures = 0;
options.applyLayout(layout);
return true;
} catch (_error) {
if (disposed || requestGeneration !== generation) return false;
failures++;
options.applyFallback();
if (failures <= maxRetries) {
const delay = Math.min(maxDelay, baseDelay * 2 ** (failures - 1));
retryHandle = options.scheduleRetry(() => load(), delay);
}
return false;
}
};
return {
load,
dispose() {
disposed = true;
generation++;
clearRetry();
},
};
}
// ─── Editing ────────────────────────────────────────────────────────────
//
// The browser edits through NAMED operations, not by diffing arrays: a write
// that loses a version race (409) is rebased by replaying the same operations
// on the layout the server returned, so a concurrent edit elsewhere survives.
// The server stays the authority: it re-validates and normalizes every PUT.
function editError(message) {
throw new Error(`Tab layout edit failed: ${message}`);
}
const clampIndex = (value, length) => (Number.isInteger(value) ? Math.max(0, Math.min(value, length)) : length);
function groupName(value) {
const name = typeof value === 'string' ? value.trim() : '';
if (!name || name.length > MAX_NAME_LENGTH) editError(`group name must be 1-${MAX_NAME_LENGTH} characters`);
return name;
}
function refLocations(layout) {
return [
...layout.groups.flatMap((group) => group.refs.map((ref) => ({ groupId: group.id, ref }))),
...layout.ungrouped.map((ref) => ({ groupId: null, ref })),
];
}
function containerRefs(layout, groupId) {
if (groupId === null) return layout.ungrouped;
const group = layout.groups.find((candidate) => candidate.id === groupId);
if (!group) editError('unknown group');
return group.refs;
}
/**
* The rows that move together with `ref`: the session plus every descendant
* that still follows its parent (non-manual, parent stored). Mirrors the
* server's moveRef block so the optimistic rail matches what it will store.
* `parents` maps a session id to its parent session id.
*/
function lineageBlock(layout, ref, parents) {
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
const children = new Map();
for (const [childId, parentId] of Object.entries(parents || {})) {
const child = stored.get(`session:${childId}`);
if (!child || child.placement === 'manual' || !stored.has(`session:${parentId}`)) continue;
if (!children.has(parentId)) children.set(parentId, []);
children.get(parentId).push(childId);
}
const keys = new Set();
const visit = (key) => {
if (keys.has(key)) return;
keys.add(key);
if (key.startsWith('session:')) for (const id of children.get(key.slice(8)) || []) visit(`session:${id}`);
};
visit(refKey(ref));
return keys;
}
/**
* Where a moved row lands, as the server's `index` (counted AFTER the moved
* block is taken out): before or after `anchor` in that container, or at its
* end when there is no anchor.
*/
function moveDestination(layoutInput, ref, groupId, anchor, placement, parents) {
const layout = normalizeLayout(layoutInput);
const block = lineageBlock(layout, ref, parents);
const remaining = containerRefs(layout, groupId).filter((candidate) => !block.has(refKey(candidate)));
// No anchor means "at the end", and an operation with no index keeps
// meaning that when it is replayed onto a layout that has changed since.
if (!anchor) return { groupId };
const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor));
if (at < 0) return { groupId };
return { groupId, index: placement === 'after' ? at + 1 : at };
}
/**
* Map a finished drag to ONE operation (or null for a drop that changes
* nothing). Pure, so the drop -> PUT mapping is testable without a pointer.
*
* source: { type: 'ref', ref } | { type: 'group', groupId }
* target: { type: 'ref', ref, groupId, placement: 'before' | 'after' }
* | { type: 'group', groupId } (a named group's header or empty body)
* | { type: 'ungrouped' }
*
* A group dropped on another group (or any row in it) takes that group's slot;
* dropped on the Ungrouped section it goes last. A row dropped on a row lands
* before/after it, on a header it is appended to that group.
*/
function dropOperation(layoutInput, source, target, parents) {
const layout = normalizeLayout(layoutInput);
if (!source || !target) return null;
if (source.type === 'group') {
const from = layout.groups.findIndex((group) => group.id === source.groupId);
if (from < 0) return null;
const targetId = target.type === 'ungrouped' ? null : (target.groupId ?? null);
const to = targetId === null ? layout.groups.length - 1 : layout.groups.findIndex((g) => g.id === targetId);
if (to < 0 || to === from) return null;
return { type: 'reorderGroup', groupId: source.groupId, index: to };
}
if (source.type !== 'ref' || !validRef(source.ref)) return null;
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(source.ref));
if (!location) return null;
let groupId;
let anchor = null;
let placement = 'before';
if (target.type === 'ref' && validRef(target.ref)) {
// Onto itself or onto a row that moves with it: nowhere to go.
if (lineageBlock(layout, source.ref, parents).has(refKey(target.ref))) return null;
groupId = target.groupId ?? null;
anchor = target.ref;
placement = target.placement === 'after' ? 'after' : 'before';
} else if (target.type === 'group') {
groupId = target.groupId ?? null;
if (groupId === location.groupId) return null;
} else if (target.type === 'ungrouped') {
groupId = null;
if (location.groupId === null) return null;
} else return null;
if (groupId !== null && !layout.groups.some((group) => group.id === groupId)) return null;
const destination = moveDestination(layout, source.ref, groupId, anchor, placement, parents);
const operation = {
type: 'moveRef',
ref: { kind: source.ref.kind, id: source.ref.id },
groupId: destination.groupId,
...(destination.index === undefined ? {} : { index: destination.index }),
parents: parents || {},
};
return contentKey(applyOperation(layout, operation)) === contentKey(layout) ? null : operation;
}
/**
* Apply one operation to a copy of the layout. Throws when the operation no
* longer makes sense (an unknown group or row); a rebase drops that one
* operation and keeps the rest. Replays are idempotent where it matters for
* recovery: creating a group that already exists is a no-op.
*/
function applyOperation(layoutInput, operation) {
const layout = normalizeLayout(layoutInput);
const op = operation || {};
const groupIndex = layout.groups.findIndex((group) => group.id === op.groupId);
switch (op.type) {
case 'createGroup': {
if (typeof op.id !== 'string' || !op.id) editError('invalid group id');
const name = groupName(op.name);
if (layout.groups.some((group) => group.id === op.id)) return layout;
if (layout.groups.length >= MAX_GROUPS) editError('group limit reached');
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, { id: op.id, name, refs: [] });
return layout;
}
case 'renameGroup':
if (groupIndex < 0) editError('unknown group');
layout.groups[groupIndex].name = groupName(op.name);
return layout;
case 'deleteGroup': {
// Already gone (deleted elsewhere): nothing left to do.
if (groupIndex < 0) return layout;
const [removed] = layout.groups.splice(groupIndex, 1);
layout.ungrouped.push(...removed.refs);
return layout;
}
case 'reorderGroup': {
if (groupIndex < 0) editError('unknown group');
const [moved] = layout.groups.splice(groupIndex, 1);
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
return layout;
}
case 'moveRef': {
if (!validRef(op.ref)) editError('invalid row');
const targetKey = refKey(op.ref);
if (!refLocations(layout).some((item) => refKey(item.ref) === targetKey)) editError('unknown row');
const destinationId = op.groupId ?? null;
containerRefs(layout, destinationId);
const keys = lineageBlock(layout, op.ref, op.parents);
const block = refLocations(layout)
.filter((item) => keys.has(refKey(item.ref)))
.map((item) => copyRef(item.ref));
// A hand-moved child stops following its parent (server moveRef does the same).
const head = block.find((item) => refKey(item) === targetKey);
if (op.ref.kind === 'session' && op.parents?.[op.ref.id]) head.placement = 'manual';
block.sort((a, b) => (a === head ? -1 : b === head ? 1 : 0));
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !keys.has(refKey(ref)));
layout.ungrouped = layout.ungrouped.filter((ref) => !keys.has(refKey(ref)));
const destination = containerRefs(layout, destinationId);
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
return layout;
}
default:
return editError(`unknown operation ${op.type}`);
}
}
/** Layout content without version metadata: equal keys mean "nothing to save". */
function contentKey(layoutInput) {
const layout = normalizeLayout(layoutInput);
return JSON.stringify([layout.groups, layout.ungrouped]);
}
/** Replay operations, dropping (and counting) the ones that no longer apply. */
function replayOperations(base, operations) {
let layout = normalizeLayout(base);
const kept = [];
let dropped = 0;
for (const operation of operations) {
try {
layout = applyOperation(layout, operation);
kept.push(operation);
} catch (_error) {
dropped++;
}
}
return { layout, kept, dropped };
}
/**
* Serialized, optimistic writer for `PUT /api/tab-layout`.
*
* - enqueue() applies an operation at once (the rail repaints optimistically)
* and schedules a flush; operations enqueued in the same turn share a PUT.
* - Exactly ONE write is in flight. Operations enqueued meanwhile wait and are
* sent on top of the version that write returns.
* - A 409 carries the server's current layout: the in-flight operations are
* replayed onto it and re-sent with its version (bounded attempts). A 400
* (a row vanished between read and write) re-reads and rebases the same way.
* - Anything else, or attempts exhausted, drops the batch and reports it; the
* caller re-reads so the rail shows the server's truth.
*
* options: { initialLayout, put({ baseVersion, layout }) -> { ok, status,
* layout }, fetchLayout?(), applyLayout(layout, meta), reportError?(message),
* onSettled?(), onFailure?(), schedule?(fn), cancel?(handle), maxAttempts? }
*/
function createEditCoordinator(options) {
let authoritative = normalizeLayout(options.initialLayout);
let optimistic = authoritative;
let pending = [];
let inFlight = [];
let writing = false;
let timer = null;
let disposed = false;
const schedule = options.schedule || ((fn) => setTimeout(fn, 0));
const cancel = options.cancel || ((handle) => clearTimeout(handle));
const maxAttempts = options.maxAttempts || 3;
const report = (message) => options.reportError?.(message);
const publish = (meta) => options.applyLayout(normalizeLayout(optimistic), meta);
const queue = () => {
if (timer === null) timer = schedule(flush);
};
async function flush() {
timer = null;
if (disposed || writing || pending.length === 0) return;
writing = true;
inFlight = pending;
pending = [];
let failed = false;
let reportedDrop = false;
let rereadFor400 = false;
try {
for (let attempt = 0; attempt < maxAttempts && inFlight.length; attempt++) {
const desired = replayOperations(authoritative, inFlight);
inFlight = desired.kept;
if (desired.dropped && !reportedDrop) {
reportedDrop = true;
report('Tab groups changed elsewhere; part of your edit no longer applies.');
}
// Nothing left to change (dropped, or already true on the server).
if (!inFlight.length || contentKey(desired.layout) === contentKey(authoritative)) {
inFlight = [];
break;
}
const response = await options.put({ baseVersion: authoritative.version, layout: desired.layout });
if (disposed) return;
if (response?.ok && response.layout) {
authoritative = normalizeLayout(response.layout);
inFlight = [];
} else if (response?.status === 409 && response.layout) {
authoritative = normalizeLayout(response.layout);
} else if (response?.status === 400 && options.fetchLayout && !rereadFor400) {
// Maybe our base was stale in a way the server reports as invalid:
// re-read once. A 400 that survives that is a refusal, not a race.
rereadFor400 = true;
authoritative = normalizeLayout(await options.fetchLayout());
if (disposed) return;
} else {
throw new Error('Tab layout save failed');
}
}
if (inFlight.length) {
failed = true;
report('Tab groups kept changing elsewhere; your edit was not saved.');
}
} catch (_error) {
failed = true;
report('Could not save tab groups.');
} finally {
inFlight = [];
writing = false;
if (!disposed) {
const rebased = replayOperations(authoritative, pending);
// Edits made while the write was in flight are rebased here, so one the
// conflict made inapplicable is dropped here too, and says so (once).
if (rebased.dropped && !failed && !reportedDrop) {
report('Tab groups changed elsewhere; part of your edit no longer applies.');
}
pending = rebased.kept;
optimistic = rebased.layout;
publish({ authoritative: true });
if (failed) options.onFailure?.();
if (pending.length) queue();
else options.onSettled?.();
}
}
}
return {
/** Apply now, save soon. Throws (and changes nothing) for an invalid edit. */
enqueue(operation) {
optimistic = applyOperation(optimistic, operation);
pending.push(operation);
publish({ optimistic: true });
queue();
return normalizeLayout(optimistic);
},
/**
* Re-apply operations recovered after a reload. Returns false (and queues
* nothing) when the layout already reflects them, e.g. the keepalive save
* landed before the page went away.
*/
restore(operations) {
const replayed = replayOperations(optimistic, Array.isArray(operations) ? operations : []);
if (!replayed.kept.length || contentKey(replayed.layout) === contentKey(optimistic)) return false;
optimistic = replayed.layout;
pending.push(...replayed.kept);
publish({ optimistic: true });
queue();
return true;
},
/**
* Adopt a layout read from the server (SSE reload). Pending operations are
* rebased onto it. Refused while a write is in flight (its result decides)
* and for a layout older than the one already held.
*/
adoptExternal(layout) {
if (disposed || writing) return false;
const next = normalizeLayout(layout);
if (next.version < authoritative.version) return false;
authoritative = next;
const rebased = replayOperations(next, pending);
if (rebased.dropped) report('Tab groups changed elsewhere; part of your edit no longer applies.');
pending = rebased.kept;
optimistic = rebased.layout;
publish({ authoritative: true, external: true });
return true;
},
flush,
isWriting: () => writing,
hasPending: () => writing || pending.length > 0,
/** Every operation not yet confirmed by the server, oldest first. */
pendingOperations: () => JSON.parse(JSON.stringify([...inFlight, ...pending])),
baseVersion: () => authoritative.version,
getLayout: () => normalizeLayout(optimistic),
dispose() {
disposed = true;
if (timer !== null) cancel(timer);
timer = null;
pending = [];
},
};
}
global.CodemanTabLayout = {
normalizeLayout,
hasGroups,
project,
hiddenGroupAlerts,
structureKey,
renderProjection,
applyOperation,
moveDestination,
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
dropOperation,
contentKey,
createEditCoordinator,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
MAX_GROUPS,
};
})(typeof window !== 'undefined' ? window : globalThis);
+5 -1
View File
@@ -298,7 +298,9 @@ Object.assign(CodemanApp.prototype, {
},
closeTabRailActionMenu(options = {}) {
const menu = document.querySelector('.tab-rail-action-menu');
// The group menu borrows this class for its look but has its own owner
// (closeTabGroupMenu); removing its DOM here would strand its listeners.
const menu = document.querySelector('.tab-rail-action-menu:not(.tab-layout-group-action-menu)');
const trigger = this._tabRailActionMenuTrigger;
menu?.remove();
if (this._tabRailActionMenuOutside) {
@@ -325,6 +327,8 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
// Group placement (vertical rail with a tab layout only; [] elsewhere).
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
+75 -27
View File
@@ -77,11 +77,14 @@
this._bufferLoading = false;
this._bufferRefreshPending = false;
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
// are held there with their arrival time instead of written under it.
// `_liveQueue` is non-null from the pull's response until its finally
// block: live frames are held there with their arrival time instead of
// written under the replay. `_markerOwed` is the "disconnected" marker a
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
this._historyPullAt = 0;
this._historyPullUseless = false;
this._liveQueue = null;
this._markerOwed = false;
this._onWheel = null;
}
@@ -169,7 +172,9 @@
// session (this.sessionId), never the primary pane's
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B is deliberately plainer — see the fileoverview).
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -177,6 +182,7 @@
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
}
return false;
}
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
@@ -301,18 +307,37 @@
}
// The socket's close, split out of connect() so the tests can drive it.
// While a history pull is running the marker waits for the pull's finally
// block: written now, it would sit above the output the pull is still
// holding (flushed after it on a skip, a downgrade or a failed fetch) or
// land in the middle of a chunked replay.
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
// written now, it would sit above the output a pull is still holding (flushed
// after it on a skip, a downgrade or a failed fetch), above a refresh's
// replay, or in the middle of a chunked replay. A pull still waiting for its
// response holds the marker too, for as long as the request takes (up to its
// budget, see _pullHistory()).
_onSocketClosed() {
this._wsReady = false;
this._wsClosed = true;
if (!this._liveQueue) this._writeDisconnectedMarker();
if (this._bufferLoading) this._markerOwed = true;
else this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a history pull that ends on a
// closed socket can write it (see _pullHistory()'s finally block).
// Settles a marker the pane owes: set when a close lands during a load (the
// replay would otherwise sit below it) or when a load wipes the terminal on
// a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses
// a write() on a later tick: a marker written here would land in the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
// The refresh re-owes the marker on a closed socket and stamps it itself.
if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed;
this._markerOwed = false;
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a load that ends owing it on a
// closed socket can write it (see _stampMarkerIfOwed()).
_writeDisconnectedMarker() {
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
}
@@ -351,6 +376,7 @@
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
@@ -430,27 +456,44 @@
// main thread) and replays it under the reader's current place. Holds the
// single-flight flag across the fetch AND the replay, like _loadBuffer().
async _pullHistory() {
// A close before the pull already wrote its marker; one during it did not.
const closedBefore = this._wsClosed;
this._bufferLoading = true;
this._liveQueue = [];
let replayed = false;
let capturedAt = 0;
// Two budgets on one signal. The request itself gets the primary pane's
// (CodemanFetchDeadline, constants.js): live output is not held while it
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
// for that whole budget. Once the headers land live output IS held, so the
// body read gets the short one instead: a body that hangs would otherwise
// freeze the pane for the long budget. Aborting lands in the catch below,
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
// be re-armed, hence the controller; without AbortController the pull
// simply has no deadline.
const controller = global.AbortController ? new global.AbortController() : null;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
clearTimeout(abortTimer);
abortTimer = setTimeout(() => controller.abort(), ms);
};
try {
// A deadline, because live output is held for as long as this runs: a
// request that hangs would otherwise freeze the whole pane. Aborting
// lands in the catch below, which releases the flag and the queue. It
// covers the body read too, not just the headers.
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
signal: controller?.signal,
});
armDeadline(HISTORY_PULL_TIMEOUT_MS);
// The cutoff below is the response's arrival, the same `since` rule the
// primary pane uses (_finishBufferLoad). It is a client clock standing in
// for the instant tmux took the capture, which lies somewhere in the
// round trip, so a frame in that window can be lost or doubled. Bounded
// by one round trip and not closable without a server-side capture time.
capturedAt = performance.now();
// Opened only now: a frame from before the response is either replaced by
// the capture or written unchanged, so holding it for the round trip
// bought nothing and froze the pane for as long as the fetch took.
this._liveQueue = [];
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer;
const term = this.terminal;
if (!buffer || !term || this._destroyed) return;
@@ -476,6 +519,7 @@
this._historyPullUseless = false;
term.write('\x1bc');
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(term, buffer, () => this._destroyed);
if (this._destroyed || !this.terminal) return;
// xterm parses asynchronously: an empty write's callback fires only
@@ -490,6 +534,7 @@
} catch {
/* Best-effort — live output keeps arriving whatever happens here. */
} finally {
clearTimeout(abortTimer);
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
@@ -500,15 +545,14 @@
if (entry.clear) this.terminal?.clear();
else this.terminal?.write(entry.data);
}
// A replay's own `\x1bc` wipes a marker written before the pull,
// painting a fresh, current-looking history while onData keeps
// silently dropping every keystroke on the dead socket, so re-stamp it
// after a replay. A close DURING the pull wrote no marker at all
// (_onSocketClosed() defers it while the queue is live), so write it
// whether or not this pull replayed. Checked after the queue flush so
// it is the last thing on screen, matching what the close would have
// left had the pull never run.
if (this._wsClosed && (replayed || !closedBefore)) this._writeDisconnectedMarker();
// Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
// one written before it, which would paint a fresh, current-looking
// history while onData keeps silently dropping every keystroke on the
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
// is left to that refresh, which writes it below its own replay.
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
@@ -525,6 +569,10 @@
return;
}
this.terminal?.clear();
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
// trailing refresh behind a pull that the socket's close then interrupts),
// so a refresh on a closed socket owes it back once its replay is written.
if (this._wsClosed) this._markerOwed = true;
void this._loadBuffer();
}
+271 -8
View File
@@ -55,6 +55,9 @@
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
// focus itself, and the accessory bar, which is built to be used while the
// keyboard is open.
// ⚠️ A roving-tabindex widget parks every item but one at tabindex=-1, so the
// `[tabindex]` arm cannot see its items: the grouped tab rail's rows and
// headers are listed by role instead, or tapping one would drop the keyboard.
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
'input',
'textarea',
@@ -64,6 +67,7 @@
'[contenteditable=""]',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
'[role="treeitem"]',
'.keyboard-accessory-bar',
'.path-picker-overlay',
].join(',');
@@ -250,6 +254,235 @@ Object.assign(CodemanApp.prototype, {
this._keyCode229Recovery = null;
},
_destroyMobileImePreview() {
try {
this._mobileImePreview?.destroy?.();
} catch {
// The preview is visual-only; terminal replacement must continue.
}
this._mobileImePreview = null;
this._mobileImePreviewSessionId = null;
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreviewNode?.remove?.();
} catch {
// Best-effort node cleanup only.
}
try {
this._mobileImePreviewHelpers?.classList?.remove('codeman-ime-preview-owned');
} catch {
// Best-effort ownership cleanup only.
}
this._mobileImePreviewNode = null;
this._mobileImePreviewHelpers = null;
try {
if (this._mobileImePreviewOfflineHandler) {
window.removeEventListener('offline', this._mobileImePreviewOfflineHandler);
}
if (this._mobileImePreviewPagehideHandler) {
window.removeEventListener('pagehide', this._mobileImePreviewPagehideHandler);
}
} catch {
// Best-effort listener cleanup only.
}
this._mobileImePreviewOfflineHandler = null;
this._mobileImePreviewPagehideHandler = null;
},
/**
* iOS Safari IME preview (mobile-ime-preview.js). WebKit does not show the
* text an IME is composing inside the terminal, so the user types blind.
*
* Two homes, chosen per render:
* - Local echo on: typed text sits in the LocalEchoOverlay and the PTY
* cursor stays at the prompt start, under the overlay's opaque text (z 7,
* `.xterm-screen`). So the overlay draws the composition itself, as an
* underlined tail after its pending text (`setComposition`).
* - Otherwise (a shell, or the overlay could not place it): a span inside
* `.xterm-helpers`, positioned by the same --xterm-helper-left/top vars as
* the helper textarea, which follow the PTY cursor.
*
* Visual only: nothing here touches the input path, and every failure
* leaves no DOM behind.
*/
_initMobileImePreview() {
this._destroyMobileImePreview();
let preview = null;
let helpers = null;
try {
if (typeof MobileImePreview === 'undefined' || !MobileImePreview?.isIosWebKitTouch?.()) return;
const textarea = this.terminal?.textarea;
helpers = this.terminal?.element?.querySelector?.('.xterm-helpers');
if (!textarea || !helpers) return;
preview = document.createElement('span');
this._mobileImePreviewNode = preview;
this._mobileImePreviewHelpers = helpers;
preview.className = 'codeman-ime-preview';
preview.setAttribute('aria-hidden', 'true');
preview.hidden = true;
helpers.appendChild(preview);
const syncPreviewTypography = () => {
try {
const compositionView =
helpers.querySelector?.('.composition-view') || this.terminal?.element?.querySelector?.('.composition-view');
if (!compositionView || !preview.style) return;
const style = typeof getComputedStyle === 'function' ? getComputedStyle(compositionView) : compositionView.style;
for (const property of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'height']) {
const value = style?.[property] || compositionView.style?.[property];
if (value) preview.style[property] = value;
}
const theme = this.terminal?.options?.theme;
let foreground = theme?.foreground;
let background = theme?.background;
if (!foreground || !background) {
try {
const current = window.codemanCurrentXtermTheme?.();
foreground = foreground || current?.foreground;
background = background || current?.background;
} catch {
// Theme lookup is best-effort; retain the safe terminal fallback.
}
}
preview.style.color = foreground || '#e0e0e0';
// Opaque, like xterm's own composition view, so the preview does not
// overprint whatever sits at the cursor (a dim composer placeholder).
preview.style.backgroundColor = background || '#0d0d0d';
} catch {
// Typography matching is visual-only and must not block input.
}
};
// The overlay only when it is what shows typed text right now (local echo
// on, and not handed back to plain PTY echo by a composer nav key).
const localEchoOverlay = () =>
this._localEchoEnabled && !this._echoPassthroughSessions?.has(this.activeSessionId)
? this._localEchoOverlay || null
: null;
const clearOverlayComposition = () => {
try {
if (this._localEchoOverlay?.composition) this._localEchoOverlay.setComposition('');
} catch {}
};
const hideSpan = () => {
try {
preview.hidden = true;
} catch {}
try {
preview.textContent = '';
} catch {}
try {
delete preview.dataset.phase;
} catch {}
try {
helpers.classList.remove('codeman-ime-preview-owned');
} catch {}
};
const clearPreview = () => {
clearOverlayComposition();
hideSpan();
};
const controller = MobileImePreview.create({
textarea,
// An ancestor of the textarea: its capture-phase keydown listener runs
// before xterm's capture listener on the textarea, which finalizes the
// composition and emits the commit synchronously.
keydownTarget: this.terminal.element,
render: ({ text, phase }) => {
try {
const overlay = localEchoOverlay();
if (overlay && typeof overlay.setComposition === 'function') {
overlay.setComposition(text);
// No prompt found = nothing drawn: fall back to the span.
if (!text || overlay.state?.visible) {
hideSpan();
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
return;
}
overlay.setComposition('');
} else {
clearOverlayComposition();
}
syncPreviewTypography();
preview.textContent = text;
preview.dataset.phase = phase;
preview.hidden = !text;
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
} catch {
clearPreview();
}
},
clear: clearPreview,
});
this._mobileImePreview = controller;
this._mobileImePreviewSessionId = this.activeSessionId;
// Offline and pagehide only reset: initTerminal() runs once per page
// load, so destroying on pagehide would leave the preview off for good
// after a back-forward cache restore (iOS Safari keeps pages there).
this._mobileImePreviewOfflineHandler = () => {
try {
this._mobileImePreview?.reset?.();
} catch {
// Disconnect cleanup is visual-only.
}
};
this._mobileImePreviewPagehideHandler = this._mobileImePreviewOfflineHandler;
window.addEventListener('offline', this._mobileImePreviewOfflineHandler);
window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler);
} catch {
this._destroyMobileImePreview();
}
},
/**
* Tell the IME preview about a chunk xterm emitted through onData. Returns
* true when the chunk is the IME's committed text, in which case the preview
* holds it (phase 'committed') until something else shows it. Never throws.
*/
_consumeMobileImeTerminalData(data) {
let isImeCommit = false;
try {
isImeCommit = this._mobileImePreview?.consumeTerminalData?.(data) === true;
} catch {
// The preview is visual-only; normal terminal input must continue.
}
// Output accepted from here on can carry the echo of this commit.
if (isImeCommit) this._mobileImeCommitOutputSeq = this._terminalOutputSeq || 0;
return isImeCommit;
},
/**
* Clear a committed IME preview once terminal output accepted AFTER the
* commit has been parsed. `flushedOutputSeq` is the output sequence a fully
* written flush covered (null when part of it was deferred), so output that
* was already queued before the commit can never clear it early.
*/
_noteMobileImeAuthoritativeOutput(flushedOutputSeq, sessionId) {
try {
const commitSeq = this._mobileImeCommitOutputSeq;
if (commitSeq === null || commitSeq === undefined || flushedOutputSeq === null) return;
if (sessionId !== this.activeSessionId || !(flushedOutputSeq > commitSeq)) return;
this._mobileImeCommitOutputSeq = null;
this._mobileImePreview?.noteAuthoritativeOutput?.();
} catch {
// Authoritative output is never delayed or consumed by the preview.
}
},
/**
* The local echo overlay has just taken a committed IME chunk through the
* ordinary printable/paste branch, so it now shows the text: release the
* preview instead of waiting for terminal output.
*/
_transferMobileImeCommitToLocalEcho() {
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreview?.completeCommit?.({ predicted: true });
} catch {
// Ownership transfer is visual-only.
}
},
initTerminal() {
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
@@ -307,6 +540,7 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._initMobileImePreview();
this._installMobileTapMouseGuard();
this._installShiftDragSelection();
this._installTouchSelectionFocusGuard();
@@ -447,8 +681,11 @@ Object.assign(CodemanApp.prototype, {
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
// which the inner application recognizes as "insert newline" vs carriage return.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
if (this.activeSessionId) {
// This handler also runs for keypress/keyup: xterm drops a keypress carrying Ctrl/Alt
// but NOT one carrying only Shift, so unless every event type is swallowed here,
// Shift+Enter's keypress sends a bare \r (submit) after the newline. Only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown' && this.activeSessionId) {
if (this._localEchoEnabled) {
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
@@ -1170,9 +1407,10 @@ Object.assign(CodemanApp.prototype, {
// has to re-resolve their gate before the redraw, not just move them.
this.applyLineageLineSettings?.();
this.updateConnectionLines();
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// Unguarded on purpose: hasPending excludes an IME composition, so a
// composition-only overlay would stay on the old prompt row. rerender()
// is a no-op when the overlay has nothing to draw.
this._localEchoOverlay?.rerender();
// Pane B (split view) has its own container and its own fit()/resize
// frame — this observer only ever measured Pane A's container, so
// without this call Pane B never learned about a window resize, an
@@ -1212,6 +1450,8 @@ Object.assign(CodemanApp.prototype, {
// survives tab switches and reconnects.
const handleTerminalData = (data) => {
// Before anything can rewrite `data`: is this chunk the IME's commit?
const isImeCommit = this._consumeMobileImeTerminalData(data);
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
// the PTY even while the CJK input field owns focus. Without this exception
// tapping to move the cursor silently does nothing whenever Chinese input
@@ -1289,6 +1529,8 @@ Object.assign(CodemanApp.prototype, {
// When enabled, keystrokes are buffered locally in the overlay for
// instant visual feedback. Nothing is sent to the PTY until Enter
// (or a control char) is pressed — avoids out-of-order char delivery.
// An IME commit takes the same printable/paste branch as typed text,
// and the overlay then shows it in place of the preview.
if (this._localEchoEnabled && !echoPassthrough) {
if (data === '\x7f') {
const source = this._localEchoOverlay?.removeChar();
@@ -1345,6 +1587,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length > 1 && data.charCodeAt(0) >= 32) {
// Paste: append to overlay only (sent on Enter)
this._localEchoOverlay?.appendText(data);
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
if (data.charCodeAt(0) < 32) {
@@ -1490,6 +1733,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length === 1 && data.charCodeAt(0) >= 32) {
// Printable char: add to overlay only (sent on Enter)
this._localEchoOverlay?.addChar(data);
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
}
@@ -3525,6 +3769,9 @@ Object.assign(CodemanApp.prototype, {
},
batchTerminalWrite(data) {
// Arrival order of output, so the IME preview can tell output that
// followed a commit from output that was already queued before it.
this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1;
// Feed the renderer watchdog. Recorded before the buffer-load early return
// below: a write that is queued rather than written still means the pipeline
// owes us a frame once it drains.
@@ -3588,6 +3835,7 @@ Object.assign(CodemanApp.prototype, {
// Accumulate raw data (may contain DEC 2026 markers)
this.pendingWrites.push(data);
this._pendingWritesOutputSeq = this._terminalOutputSeq;
this._scheduleTerminalWriteFlush();
},
@@ -3619,6 +3867,7 @@ Object.assign(CodemanApp.prototype, {
// Transfer buffered data to normal pending writes
this.pendingWrites.push(this.flickerFilterBuffer);
this._pendingWritesOutputSeq = this._terminalOutputSeq;
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
@@ -3649,6 +3898,15 @@ Object.assign(CodemanApp.prototype, {
* Position is tracked dynamically by _findPrompt() on every render.
*/
_updateLocalEchoState() {
if (this._mobileImePreviewSessionId !== this.activeSessionId) {
this._mobileImePreviewSessionId = this.activeSessionId;
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreview?.reset?.();
} catch {
// The preview is visual-only; session switching must continue.
}
}
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
@@ -3854,6 +4112,9 @@ Object.assign(CodemanApp.prototype, {
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
}
// Newest output this chunk fully contains, for the IME preview. A split
// chunk may not hold that output yet, so it reports nothing.
const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null);
this._terminalWriteInFlight = true;
this._terminalWriteInFlightBytes = writeChunk.length;
try {
@@ -3871,6 +4132,7 @@ Object.assign(CodemanApp.prototype, {
// because the test's write mock moved the viewport synchronously.)
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
this._scheduleTerminalWriteFlush();
this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
});
} catch (err) {
this._terminalWriteInFlight = false;
@@ -3900,9 +4162,10 @@ Object.assign(CodemanApp.prototype, {
// Re-position local echo overlay after terminal writes — Ink redraws can
// move the ❯ prompt to a different row, making the overlay invisible.
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// Unguarded on purpose: hasPending excludes an IME composition, so a
// composition-only overlay (the first word of a prompt) would otherwise
// stay on the old row. rerender() is a no-op when there is nothing to draw.
this._localEchoOverlay?.rerender();
// After Tab completion: detect the completed text in the overlay.
// Use terminal.write('', callback) to defer detection until xterm.js
+17 -6
View File
@@ -304,13 +304,26 @@ Object.assign(CodemanApp.prototype, {
let idx = startIndex;
for (const id of this.webviewOrder) {
if (!this.webviews.get(id)) continue;
parts.push(this.renderWebviewTab(id, idx));
idx++;
}
return parts.join('');
},
/**
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
* The grouped vertical rail places single web tabs into their group with this,
* so a web tab's markup is the same in every layout.
*/
renderWebviewTab(id, idx) {
const webview = this.webviews.get(id);
if (!webview) continue;
if (!webview) return '';
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
@@ -322,10 +335,7 @@ Object.assign(CodemanApp.prototype, {
</span>
</span>
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">&#x2699;</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</span></span>
</div>`);
idx++;
}
return parts.join('');
</div>`;
},
_webviewGlobeIcon() {
@@ -348,6 +358,7 @@ Object.assign(CodemanApp.prototype, {
// A web tab is active, so no session tab may also look active.
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
}
this._syncTabTreeSelection?.(container);
},
// ── Opening / closing ─────────────────────────────────────────────────────
+2 -1
View File
@@ -18,7 +18,8 @@
* session record involved. A dropped plan therefore returns the user to
* resuming by hand, one at a time, which is where they are without this
* feature. What the plan held that a transcript does not is the owner, the
* name, the env overrides, the effort, the advisor model and the lineage.
* name, the env overrides, the effort, the model, the advisor model and the
* lineage.
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
* import and no IO, which keeps it unit-testable and cycle-free.
* - Spending is take-then-build: `take()` removes entries synchronously, before
+142 -12
View File
@@ -50,6 +50,8 @@ import {
} from '../../git-clone.js';
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import { boundedPathExists, describeUnknownPath, probePath } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
@@ -163,6 +165,9 @@ function gitDiagnosticLine(stderr: string): string {
* the clone response says so out loud instead of silently merging into them.
*/
function repoShipsClaudeSettings(casePath: string): boolean {
// Deliberately NOT the bounded path probe: the tree was just cloned into the
// local case space (and lstat'ed synchronously moments ago), so a bound protects
// nothing here, while a probe answering "unknown" could silently drop this warning.
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
}
@@ -266,7 +271,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
cases.push({
name: e.name,
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
hasClaudeMd: await boundedPathExists(join(casePath, 'CLAUDE.md')),
location: 'local',
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
});
@@ -281,17 +286,21 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const existingNames = new Set(cases.map((c) => c.name));
if (admin) {
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
if (existingNames.has(name) || !SAFE_CASE_NAME.test(name)) continue;
const state = await probePath(path);
if (state === 'absent') continue;
// An unreachable linked case (a dead network mount) stays listed and says
// so: dropping it would read as "deleted" and invite a same-name local case.
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
hasClaudeMd: state === 'present' && (await boundedPathExists(join(path, 'CLAUDE.md'))),
linked: true,
location: 'linked-local',
...(state === 'unknown' ? { unreachable: true } : {}),
});
}
}
}
// Get remote cases (owner-scoped; legacy no-owner = admin-only)
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
@@ -333,7 +342,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const dockerCaseInfo: CaseInfo = {
name: dockerCase.name,
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
location: 'docker',
docker: {
hostId: host.id,
@@ -424,8 +433,109 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: { cases: summaries } };
});
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description } = parseBody(CreateCaseSchema, req.body);
/**
* `POST /api/cases` with a `path`: create the folder (or fill an EMPTY existing one), scaffold it
* exactly like a normal case, and register it in the linked-cases registry so it lists, resolves
* and deletes (unlinks, never removes files) like any linked case. Everything is judged before
* anything is written; a failure after the first write undoes what this call created.
*/
async function createCaseInCustomFolder(
name: string,
description: string | undefined,
customPath: string,
req: FastifyRequest,
reply: { code: (n: number) => unknown }
): Promise<ApiResponse<{ case: { name: string; path: string } }>> {
const ownCasesDir = resolveCasesDir(getAuthUser(req));
if (existsSync(join(ownCasesDir, name))) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
const linkedCases = await readLinkedCases();
if (linkedCases[name]) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`Case "${name}" is already linked to ${linkedCases[name]}`
);
}
// The caller's own cases dir and the shared one (the same folder outside multi-user mode).
const casesDirs = [...new Set([ownCasesDir, resolveCasesDir()])];
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir(), casesDirs });
if (!prepared.ok) {
// A parent that did not answer is not a bad request: OPERATION_FAILED (422), like
// POST /api/sessions for a workingDir on a dead mount.
if (prepared.code === 'UNREACHABLE') {
reply.code(422);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, prepared.reason);
}
const status = prepared.code === 'NOT_FOUND' ? 404 : prepared.code === 'EXISTS' ? 409 : 400;
reply.code(status);
const code =
prepared.code === 'NOT_FOUND'
? ApiErrorCode.NOT_FOUND
: prepared.code === 'EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: ApiErrorCode.INVALID_INPUT;
return createErrorResponse(code, prepared.reason);
}
const casePath = prepared.path;
const alreadyAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
if (alreadyAs) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${alreadyAs}"`);
}
const made: string[] = [];
try {
if (!prepared.existedEmpty) {
mkdirSync(casePath);
made.push(casePath);
}
mkdirSync(join(casePath, 'src'));
made.push(join(casePath, 'src'));
const templatePath = await ctx.getDefaultClaudeMdPath();
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
made.push(join(casePath, 'CLAUDE.md'));
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
await writeHooksConfig(casePath);
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
// Re-read right before writing, so a case linked since the check above is not dropped. This only
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
// requests that both read before either writes can still lose one entry.
const fresh = await readLinkedCases();
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return { success: true, data: { case: { name, path: casePath } } };
} catch (err) {
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
// entries inside the empty folder the user picked; never anything else.
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
if ((err as { conflict?: boolean }).conflict) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
}
app.post('/api/cases', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description, path: customPath } = parseBody(CreateCaseSchema, req.body);
// A custom folder writes outside the cases directory and registers the path in the shared,
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
if (customPath !== undefined) {
const denied = adminOnly(req, reply);
if (denied) return denied;
return createCaseInCustomFolder(name, description, customPath, req, reply);
}
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
if (!casePath) {
@@ -1618,7 +1728,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return {
name,
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
location: 'docker',
docker: {
hostId: host.id,
@@ -1633,16 +1743,32 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
const casePath = await resolveCasePath(name, getAuthUser(req));
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
if (!existsSync(casePath)) {
// NOT_FOUND means DEFINITELY absent: the Run button creates a case on it, so
// a path that merely did not answer (a dead network mount) must never get it.
// One path, asked for explicitly: probe it even while unrelated mounts are dead.
const state = await probePath(casePath, { pastCap: true });
if (state === 'absent') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
}
if (state === 'unknown') {
// The linked registry knows where the case lives, so say where, and that
// it is not answering. A local case has no such record to fall back on.
if (!linked) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('Case folder', casePath, { pastCap: true })
);
}
return { name, path: casePath, hasClaudeMd: false, linked: true, unreachable: true };
}
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
return {
name,
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
// Probed like the folder above, or a healthy case reads as having no CLAUDE.md under the cap.
hasClaudeMd: (await probePath(join(casePath, 'CLAUDE.md'), { pastCap: true })) === 'present',
...(linked && { linked: true }),
};
});
@@ -1660,7 +1786,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const fixPlanPath = join(casePath, '@fix_plan.md');
if (!existsSync(fixPlanPath)) {
const fixPlanState = await probePath(fixPlanPath, { pastCap: true });
if (fixPlanState === 'unknown') {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Case folder is not responding or not readable');
}
if (fixPlanState === 'absent') {
return { exists: false, content: null, todos: [] };
}
+100
View File
@@ -0,0 +1,100 @@
/**
* @fileoverview `GET /api/doctor` — the `codeman doctor` dependency report (Node, the agent CLIs,
* tmux, LibreOffice, MS Office) for Settings → System → Diagnostics.
*
* The probe engine is synchronous (`which` + `<bin> --version` per tool, each up to its own
* timeout), so it must never run on the server's event loop: a handful of slow probes would
* freeze every request and every SSE client, with the process still alive. The default runner
* therefore runs `codeman doctor --json` in a CHILD PROCESS of this same entry script and
* parses its output; the runner is injected so tests never spawn anything.
*
* Read-only, but the report names install paths and versions on the host, so in multi-user
* mode it is admin only (the same bar as the other host-introspection routes).
*/
import { execFile } from 'node:child_process';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { isAdmin } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { TOOL_CATEGORIES } from '../../config/dependency-registry.js';
import type { DependencyReportJson } from '../../utils/dependency-report.js';
export type DoctorRunner = (category?: string) => Promise<DependencyReportJson>;
const DOCTOR_TIMEOUT_MS = 30_000;
function isReport(v: unknown): v is DependencyReportJson {
const r = v as Partial<DependencyReportJson> | null;
return !!r && Array.isArray(r.tools) && typeof r.summary === 'object' && r.summary !== null;
}
/**
* Run `doctor --json` out of process. The CLI exits non-zero when a required tool is missing,
* and still prints the report, so a non-zero exit with parseable stdout is a normal result.
*/
export const defaultDoctorRunner: DoctorRunner = (category) =>
new Promise((resolve, reject) => {
const args = [
...process.execArgv,
process.argv[1],
'doctor',
'--json',
...(category ? ['--category', category] : []),
];
execFile(
process.execPath,
args,
{ timeout: DOCTOR_TIMEOUT_MS, maxBuffer: 1024 * 1024, env: process.env },
(err, stdout) => {
if (err && (err as { killed?: boolean }).killed) {
return reject(new Error(`timed out after ${DOCTOR_TIMEOUT_MS / 1000} s`));
}
try {
const parsed: unknown = JSON.parse(stdout);
if (isReport(parsed)) return resolve(parsed);
} catch {
/* fall through to the error below */
}
reject(err ?? new Error('doctor produced no report'));
}
);
});
export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner = defaultDoctorRunner): void {
// Each run forks a full Node process, so two tabs or a script must not stack them: callers
// asking for the same category while one is in flight share its promise.
const inFlight = new Map<string, Promise<DependencyReportJson>>();
const runShared = (category?: string): Promise<DependencyReportJson> => {
const key = category ?? '';
let running = inFlight.get(key);
if (!running) {
running = runner(category).finally(() => inFlight.delete(key));
inFlight.set(key, running);
}
return running;
};
app.get(
'/api/doctor',
async (req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<DependencyReportJson>> => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
const { category } = req.query as { category?: string };
if (category !== undefined && !(TOOL_CATEGORIES as readonly string[]).includes(category)) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Unknown category "${category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`
);
}
try {
return { success: true, data: await runShared(category) };
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `doctor failed: ${getErrorMessage(err)}`);
}
}
);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
* an overview: the enclosing repository, or each repository found below a folder that holds several
* projects (see `getGitWorkspaceOverview` for exactly which).
*
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
* Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or
* inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through
* `findSessionOrFail`, like every session-scoped route.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { redactGitCredentials } from '../../git-clone.js';
import { readDockerCases } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { findSessionOrFail } from '../route-helpers.js';
import {
emptyOverview,
findWorkspaceRepo,
getGitFileDiff,
getGitWorkspaceOverview,
getGitWorkspaceStatus,
type GitFileDiff,
type GitFileKind,
type GitRunner,
type GitWorkspaceOverview,
} from '../../git-workspace-status.js';
import type { SessionPort } from '../ports/index.js';
/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */
const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
export function registerGitStatusRoutes(
app: FastifyInstance,
ctx: SessionPort,
git?: GitRunner,
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
): void {
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
const { id } = req.params as { id: string };
const { fresh } = req.query as { fresh?: string };
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
return {
success: true,
data: await getGitWorkspaceOverview(session.workingDir, {
git,
fresh: fresh === '1',
dockerWorkspaces: await dockerWorkspaces(),
}),
};
});
// The diff of one file the panel lists. `repo` and `path` are matched against the CURRENT status
// (a repository this session's folder holds, a path git reported in it) rather than trusted, so
// the route cannot be pointed at an arbitrary directory or file. The repository is checked against
// the overview's own (cached) list with the Docker roots as they are now, and only that one
// repository's status is refreshed: a click must not re-read every repository in the folder.
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
const { id } = req.params as { id: string };
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
}
const repoRoot = repo
? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
: null;
const status = repoRoot ? await getGitWorkspaceStatus(repoRoot, { git, fresh: true }) : null;
const entry =
status?.state === 'ok'
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
: undefined;
if (!status?.repoRoot || !entry) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
}
try {
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
} catch (err) {
reply.code(500);
return createErrorResponse(
ApiErrorCode.INTERNAL_ERROR,
`git diff failed: ${redactGitCredentials(getErrorMessage(err))}`
);
}
});
}
+4
View File
@@ -13,6 +13,7 @@ export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerApprovalRoutes } from './approval-routes.js';
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerSessionRoutes } from './session-routes.js';
@@ -28,6 +29,9 @@ export { registerWsRoutes } from './ws-routes.js';
export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export { registerDoctorRoutes } from './doctor-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
+97
View File
@@ -0,0 +1,97 @@
/**
* @fileoverview MCP server sync (src/mcp-sync.ts).
*
* GET /api/mcp-sync — dry run: per participating CLI, which servers it has and which it would gain.
* POST /api/mcp-sync — apply: add the missing servers to each CLI's own config file.
*
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
* is admin only. A second apply while one is running answers 409. Responses carry server names
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file; one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type McpSyncResult,
} from '../../types.js';
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.mcpSyncEnabled === true;
}
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
return enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
.map((e) => ({
id: e.id,
label: e.label,
...e.capabilities.mcpConfig!,
installed: isCliEntryInstalled(e, availability),
}));
}
/**
* Installed, enabled agent CLIs with no known MCP config file (sync cannot touch them). One that
* is not installed is left out, the same way a supported one that is not installed reads `absent`.
*/
export function mcpUnsupportedLabels(availability: Record<string, boolean>): string[] {
return enabledClis()
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability))
.map((e) => e.label);
}
async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
if (isMultiUserMode() && !isAdmin(req)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
if (!(await readMcpSyncEnabled())) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'MCP sync is disabled. Turn on "Enable MCP server sync" in Settings and save first.'
);
}
return null;
}
export function registerMcpSyncRoutes(app: FastifyInstance): void {
const run = async (req: FastifyRequest, reply: FastifyReply, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
const denied = await gate(req);
if (denied) {
reply.code(403);
return denied;
}
try {
const availability = await probeStockCliAvailability();
const targets = mcpSyncTargets(availability);
const data = await syncMcpServers(targets, { apply, env: process.env }, mcpUnsupportedLabels(availability));
return { success: true, data };
} catch (err) {
if (err instanceof McpSyncBusyError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
};
app.get('/api/mcp-sync', (req, reply) => run(req, reply, false));
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
}
+1
View File
@@ -195,6 +195,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
),
effort: saved.effort,
model: saved.model,
advisorModel: saved.advisorModel,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
+75 -21
View File
@@ -30,7 +30,13 @@ import {
type OmpConfig,
type RemoteHost,
} from '../../types.js';
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import {
Session,
cliTakesSessionModel,
isAltScreenStripMode,
isExternalCliMode,
isMuxAltScreenOnlyStripMode,
} from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
@@ -107,6 +113,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js';
@@ -167,6 +174,7 @@ import {
toSessionDocker,
} from '../../docker-hosts.js';
import { LRUMap } from '../../utils/lru-map.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
@@ -888,6 +896,25 @@ export function registerSessionRoutes(
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
const body = parseBody(CreateSessionSchema, req.body);
// The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model
// in its own config object (`codexConfig.model` and so on), so a `model` here would be
// dropped without a word; refuse it before anything is written for the session.
if (body.model && !cliTakesSessionModel(body.mode ?? 'claude')) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model'
);
}
// An attach launches nothing (the remote agent is already running), so a launch model
// or advisor would be dropped the same way, so both are refused, as they have been since
// they were added. The older launch fields (effort, envOverrides) predate this and keep
// their silent ignore here, since refusing them now would break existing callers.
if (body.attachRemoteSession && (body.model || body.advisorModel)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model and advisorModel shape a new launch, and attachRemoteSession launches nothing; leave them out when attaching'
);
}
let workingDir = body.workingDir || process.cwd();
let remote = undefined;
@@ -945,16 +972,23 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
}
// Validate workingDir exists and is a directory
// Validate workingDir exists and is a directory. Bounded: a workingDir on a
// network mount that stopped answering must not freeze the event loop, and
// "did not answer" is reported as such, never as "does not exist".
if (body.workingDir) {
try {
const stat = statSync(workingDir);
if (!stat.isDirectory()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
const kind = await probePathKind(workingDir, { pastCap: true });
if (kind === 'unknown') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('workingDir', workingDir, { pastCap: true })
);
}
} catch {
if (kind === 'absent') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
}
if (kind !== 'directory') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
}
// envOverrides flow through Session → tmux setenv (ephemeral, per-session).
@@ -1073,9 +1107,10 @@ export function registerSessionRoutes(
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
// in that CLI's own config object.
// 'claude-settings-file' — claude alone, whose model is written to
// <case>/.claude/settings.local.json rather than passed as
// a flag, so the app-wide default applies here.
// 'claude-settings-file' — claude alone, whose persistent model is written to
// <case>/.claude/settings.local.json (`modelOverride`). A
// per-session `model` from the caller goes out as --model and
// wins; without one, the app-wide default applies.
// 'none' — shell has no model; deepseek's is a composition entry in
// the profile's config tree, not a session field
// (docs/deepseek-integration.md). Both get nothing.
@@ -1086,7 +1121,7 @@ export function registerSessionRoutes(
| string
| undefined)
: modelSource?.source === 'claude-settings-file'
? modelConfig?.defaultModel || undefined
? body.model || modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
@@ -2093,21 +2128,16 @@ export function registerSessionRoutes(
// ========== Send Named Key (tmux send-keys -H) ==========
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
// Uses send-keys -H (hex) to inject a newline chord: 0x0a (line feed) by default, or the CLI's
// own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs
// 0x0d (carriage return = submit).
app.post('/api/sessions/:id/send-key', async (req) => {
const { id } = req.params as { id: string };
const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : '';
// Map key names to hex byte sequences
const KEY_HEX_MAP: Record<string, string[]> = {
'S-Enter': ['0a'], // \n (line feed)
'C-Enter': ['0a'], // \n (line feed)
};
const hex = KEY_HEX_MAP[key];
if (!hex) {
if (key !== 'S-Enter' && key !== 'C-Enter') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
}
@@ -2117,6 +2147,18 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
}
// Key names map to hex byte sequences. Ctrl+Enter is always a line feed; Shift+Enter is the
// CLI's own newline chord (`capabilities.newline`, default line feed), so a CLI that wants
// Esc+Enter declares it in the registry instead of being special-cased here.
const NEWLINE_HEX: Record<NewlineSequence, string[]> = {
'line-feed': ['0a'], // \n
'esc-enter': ['1b', '0d'], // ESC CR, the Alt/Option+Enter chord
};
const hex =
key === 'C-Enter'
? NEWLINE_HEX['line-feed']
: NEWLINE_HEX[getCli(session.mode)?.capabilities.newline ?? 'line-feed'];
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
@@ -3660,9 +3702,21 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
}
// Bounded probe of a local case folder: a linked case can sit on a network mount
// that stopped answering, and a synchronous check there froze the whole server.
// Only a DEFINITE absence may scaffold a new case; "did not answer" must not
// create one over the top of where the real case is mounted.
const localCaseState = remote || docker ? undefined : await probePathKind(resolvedCasePath, { pastCap: true });
if (localCaseState === 'unknown') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('Case folder', resolvedCasePath, { pastCap: true })
);
}
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
// non-docker cases — docker workspaces are scaffolded in their own block below)
if (!remote && !docker && !existsSync(resolvedCasePath)) {
if (localCaseState === 'absent') {
try {
mkdirSync(resolvedCasePath, { recursive: true });
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
+109
View File
@@ -0,0 +1,109 @@
/**
* @fileoverview Webhook notification settings (src/webhook-notify.ts).
*
* GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
* PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
* POST /api/webhook/test — send one test message with the saved config
*
* The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
* stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
* the channel receives every session's events, the same reach an admin's own Web Push has.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type WebhookResult,
type WebhookStatus,
} from '../../types.js';
import { isAdmin, parseBody } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { WebhookUpdateSchema } from '../schemas.js';
import {
maskWebhookUrl,
readWebhookConfig,
webhookUrlProblem,
writeWebhookConfig,
type WebhookNotifier,
} from '../../webhook-notify.js';
export interface WebhookRouteDeps {
notifier: WebhookNotifier;
configDir: string;
/** The instance's window title, so a test message says which machine sent it. */
hostTitle: () => string;
}
export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
const status = async (): Promise<WebhookStatus> => {
const cfg = await readWebhookConfig(deps.configDir);
return {
enabled: cfg.enabled,
kind: cfg.kind,
scope: cfg.scope,
hasUrl: cfg.url !== '',
urlMasked: maskWebhookUrl(cfg.url),
lastResult: deps.notifier.lastResult,
};
};
app.get('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});
app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
const current = await readWebhookConfig(deps.configDir);
const next = {
enabled: patch.enabled ?? current.enabled,
kind: patch.kind ?? current.kind,
scope: patch.scope ?? current.scope,
url: patch.url !== undefined ? patch.url.trim() : current.url,
};
if (next.url) {
const problem = webhookUrlProblem(next.url);
if (problem) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
}
if (next.enabled && !next.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
}
try {
await writeWebhookConfig(deps.configDir, next);
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
return { success: true, data: await status() };
});
app.post('/api/webhook/test', async (req, reply): Promise<ApiResponse<WebhookResult>> => {
const no = denied(req, reply);
if (no) return no;
const cfg = await readWebhookConfig(deps.configDir);
if (!cfg.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
}
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
});
}
+46
View File
@@ -18,6 +18,8 @@ import {
MIN_TERMINAL_SCROLLBACK_LINES,
} from '../config/terminal-history.js';
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
@@ -313,6 +315,7 @@ const CodexConfigSchema = z
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
reasoningEffort: z.enum(CODEX_REASONING_EFFORTS).optional(),
resumeSessionId: z
.string()
.max(100)
@@ -543,6 +546,21 @@ export const CreateSessionSchema = z.object({
advisorModel: advisorModelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
/**
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
* disk. Wins over the app-wide default model. A subset of the registry's `model-claude`
* pattern, so a value accepted here is never rejected at launch. The first character must be
* a letter or digit: the value lands in argv, and no model id opens with `-`, so a
* flag-shaped value is refused here rather than left to the launch quoting. An empty string
* means no per-session model, as it does for `modelOverride`. Claude only: the route refuses
* it for any other CLI and on a remote attach.
*/
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-[\]]*$/)
.or(z.literal(''))
.optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
@@ -650,6 +668,13 @@ export const CreateCaseSchema = z.object({
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
description: z.string().max(1000).optional(),
/**
* Create the case in this folder instead of under the cases directory. Absolute, or starting with
* `~`. Only length-bounded here: what makes it acceptable (shape, blocked trees, symlinks, an
* existing folder with contents) is judged by `prepareNewCasePath()` in web/case-path.ts, which
* also produces the user-facing reason.
*/
path: z.string().min(1).max(1000).optional(),
});
/**
@@ -1346,6 +1371,13 @@ export const SettingsUpdateSchema = z
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
*/
cliManagementEnabled: z.boolean().optional(),
/**
* MCP server sync (src/mcp-sync.ts): copies each enabled CLI's user-level MCP servers into
* the other CLIs' own config files. SYNCED, default OFF: it writes other tools' config in
* the server user's home (including any env values and headers on the servers), so it is
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
*/
mcpSyncEnabled: z.boolean().optional(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
@@ -1871,6 +1903,20 @@ export const PushPreferencesUpdateSchema = z.object({
pushPreferences: z.record(z.string(), z.boolean()),
});
/**
* PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
* kind or scope never needs the secret re-sent, and an empty string clears it. The kind and scope
* lists are the store's own, so the schema can never accept a value the store would coerce away.
*/
export const WebhookUpdateSchema = z
.object({
enabled: z.boolean().optional(),
kind: z.enum(WEBHOOK_KINDS).optional(),
scope: z.enum(WEBHOOK_SCOPES).optional(),
url: z.string().max(2048).optional(),
})
.strict();
// ========== Ralph Loop ==========
/** POST /api/ralph-loop/start */
+59 -22
View File
@@ -44,6 +44,9 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
import type { WebhookUrgency } from '../types/push.js';
import { webviewFetch } from './webview-egress.js';
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
import type { RemoteWakeRegistry } from '../remote-wake.js';
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
@@ -180,6 +183,7 @@ import {
registerApprovalRoutes,
registerRebootRestoreRoutes,
registerReadMyMindRoutes,
registerGitStatusRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
@@ -197,6 +201,9 @@ import {
registerVoiceRoutes,
registerWebviewRoutes,
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerWebhookRoutes,
registerDoctorRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
readCustomModelEndpointsEnabled,
@@ -368,6 +375,8 @@ export class WebServer extends EventEmitter {
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private userFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private readonly titleHostname: string;
@@ -1114,6 +1123,7 @@ export class WebServer extends EventEmitter {
registerApprovalRoutes(this.app, ctx);
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerGitStatusRoutes(this.app, ctx);
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
@@ -1130,6 +1140,13 @@ export class WebServer extends EventEmitter {
registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerMcpSyncRoutes(this.app);
registerWebhookRoutes(this.app, {
notifier: this.webhookNotifier,
configDir: getDataDir(),
hostTitle: () => this.windowTitle,
});
registerDoctorRoutes(this.app);
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
@@ -2652,6 +2669,25 @@ export class WebServer extends EventEmitter {
const template = WebServer.PUSH_EVENT_MAP[event];
if (!template) return;
const sessionName = (data.sessionName as string) || '';
const sessionId = (data.sessionId as string) || '';
const body = WebServer.pushBodyText(event, data, sessionName);
// Webhook channel (ntfy / Slack / Discord / generic): independent of Web Push, so it runs BEFORE
// the "no subscriptions" return below, which is exactly the headless-server case it exists for.
// Fire-and-forget; WebhookNotifier dedupes, caps what is in flight and never throws.
void this.webhookNotifier
.notify({
event,
title: template.title,
body,
urgency: template.urgency as WebhookUrgency,
sessionId: sessionId || undefined,
sessionName: sessionName || undefined,
host: this.windowTitle,
})
.catch(() => undefined);
const subscriptions = this.pushStore.getAll();
if (subscriptions.length === 0) return;
@@ -2670,9 +2706,6 @@ export class WebServer extends EventEmitter {
const vapidKeys = this.pushStore.getVapidKeys();
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
const sessionName = (data.sessionName as string) || '';
const sessionId = (data.sessionId as string) || '';
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
// must reach only the owner's devices (+ admins) — the body embeds the session
// name + activity, so cross-user delivery would leak it. Resolved once here; the
@@ -2680,25 +2713,6 @@ export class WebServer extends EventEmitter {
const multiUserPush = isMultiUserMode();
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
// Build body text from event data
let body = sessionName ? `[${sessionName}]` : '';
if (event === SseEvent.SessionError && data.error) {
body += body ? ' ' : '';
body += String(data.error).slice(0, 200);
} else if (event === SseEvent.RespawnBlocked && data.reason) {
body += body ? ' ' : '';
body += String(data.reason);
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
body += body ? ' ' : '';
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
}
const payload = JSON.stringify({
title: template.title,
// Hostname-aware prefix so OS-level notifications from multiple Codeman
@@ -2752,6 +2766,28 @@ export class WebServer extends EventEmitter {
}
}
/** The notification body for an event (shared by Web Push and the webhook channel). */
private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string {
let body = sessionName ? `[${sessionName}]` : '';
if (event === SseEvent.SessionError && data.error) {
body += body ? ' ' : '';
body += String(data.error).slice(0, 200);
} else if (event === SseEvent.RespawnBlocked && data.reason) {
body += body ? ' ' : '';
body += String(data.reason);
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
body += body ? ' ' : '';
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
}
return body;
}
private cleanupDeadSSEClients(): void {
this.sse.cleanupDeadClients();
}
@@ -3489,6 +3525,7 @@ export class WebServer extends EventEmitter {
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
model: savedState?.model,
advisorModel: savedState?.advisorModel,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
+319
View File
@@ -0,0 +1,319 @@
/**
* @fileoverview Webhook notifications (ntfy, Slack, Discord, generic JSON) for the events that
* already trigger Web Push, so a headless server can reach a phone without a browser tab or a
* push subscription.
*
* Split in three, so the parts that matter are testable without a network:
* - pure: `webhookUrlProblem`, `maskWebhookUrl`, `shouldSendWebhook`, `buildWebhookRequest`
* - store: `~/.codeman/webhook.json`, written 0600 via tmp+rename (the URL is a bearer secret:
* anyone holding a Slack/Discord webhook URL can post as it)
* - IO: `sendWebhook` (injected fetch) and `WebhookNotifier` (dedupe, in-flight cap, last result)
*
* Rules the code keeps and the tests pin:
* - The URL is configured only through the admin-only `/api/webhook` routes and kept OUT of
* `settings.json`, which every logged-in user can read through `GET /api/settings`.
* - Delivery goes through `webviewFetch`: link-local and cloud-metadata targets are refused on
* the RESOLVED address at connect time, redirects are not followed, and the call is bounded
* by a timeout. Loopback and LAN stay allowed on purpose (a local ntfy is the feature).
* - The URL never appears in a log line, a result, or an error message.
* - Session names and error text are user/agent-controlled, so they cannot ping a channel:
* Discord gets `allowed_mentions: { parse: [] }` and Slack control characters are escaped.
*
* @module webhook-notify
*/
import { existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { blockedWebviewHostReason } from './web/webview-egress-policy.js';
import { isEgressBlockedError } from './web/webview-egress.js';
import {
WEBHOOK_KINDS,
WEBHOOK_SCOPES,
type WebhookConfig,
type WebhookKind,
type WebhookResult,
type WebhookScope,
type WebhookUrgency,
} from './types/push.js';
const WEBHOOK_FILE = 'webhook.json';
const MAX_URL_LENGTH = 2048;
const SEND_TIMEOUT_MS = 5000;
const MAX_BODY_CHARS = 500;
/** Same event + session within this window is sent once: a flapping prompt must not flood a channel. */
const DEDUPE_WINDOW_MS = 3000;
const MAX_IN_FLIGHT = 5;
export const DEFAULT_WEBHOOK_CONFIG: WebhookConfig = { enabled: false, kind: 'ntfy', url: '', scope: 'attention' };
export interface WebhookMessage {
event: string;
title: string;
body: string;
urgency: WebhookUrgency;
sessionId?: string;
sessionName?: string;
/** The Codeman instance's window title, so several machines are told apart. */
host?: string;
}
// ---------------------------------------------------------------------------
// Pure
// ---------------------------------------------------------------------------
/** Why `raw` cannot be a webhook URL, or null. Used at save time; delivery re-checks the resolved address. */
export function webhookUrlProblem(raw: string): string | null {
if (raw.length > MAX_URL_LENGTH) return 'URL is too long';
let url: URL;
try {
url = new URL(raw);
} catch {
return 'Not a valid URL';
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'Only http and https URLs are allowed';
if (url.username || url.password) return 'Put credentials in the path or a header-less token, not user:password@';
const blocked = blockedWebviewHostReason(url.hostname);
if (blocked) return `Refused: ${blocked}`;
return null;
}
/** Scheme + host only: the path and query of a webhook URL are the secret. */
export function maskWebhookUrl(raw: string): string {
try {
const url = new URL(raw);
return `${url.protocol}//${url.host}/•••`;
} catch {
return '';
}
}
export function shouldSendWebhook(cfg: WebhookConfig, urgency: WebhookUrgency): boolean {
if (!cfg.enabled || !cfg.url) return false;
return cfg.scope === 'all' || urgency !== 'info';
}
const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
/** A header value must be single-line printable ASCII; anything else goes out RFC 2047 encoded. */
function headerSafe(value: string): string {
const oneLine = value.replace(/[\r\n]+/g, ' ').trim();
return /^[\x20-\x7e]*$/.test(oneLine) ? oneLine : `=?UTF-8?B?${Buffer.from(oneLine, 'utf8').toString('base64')}?=`;
}
/** Slack parses `<!channel>`, `<@U123>` and `<url|text>`; escaping the three control characters turns them to text. */
const slackEscape = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const NTFY_PRIORITY: Record<WebhookUrgency, string> = { critical: '5', warning: '4', info: '3' };
const NTFY_TAGS: Record<WebhookUrgency, string> = {
critical: 'rotating_light',
warning: 'bell',
info: 'white_check_mark',
};
export interface WebhookRequest {
method: 'POST';
headers: Record<string, string>;
body: string;
}
export function buildWebhookRequest(kind: WebhookKind, msg: WebhookMessage, now: Date = new Date()): WebhookRequest {
const title = clip(msg.title, 120);
const body = clip(msg.body, MAX_BODY_CHARS);
const prefix = msg.host ? `${clip(msg.host, 60)}: ` : '';
switch (kind) {
case 'ntfy':
return {
method: 'POST',
headers: {
'Content-Type': 'text/plain; charset=utf-8',
Title: headerSafe(`${prefix}${title}`),
Priority: NTFY_PRIORITY[msg.urgency],
Tags: NTFY_TAGS[msg.urgency],
},
body: body || title,
};
case 'slack':
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `*${slackEscape(`${prefix}${title}`)}*${body ? `\n${slackEscape(body)}` : ''}` }),
};
case 'discord':
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: clip(`**${prefix}${title}**${body ? `\n${body}` : ''}`, 1900),
// Agent output and session names are not trusted to @everyone a channel.
allowed_mentions: { parse: [] },
}),
};
case 'generic':
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: msg.event,
title,
body,
urgency: msg.urgency,
sessionId: msg.sessionId ?? null,
sessionName: msg.sessionName ?? null,
host: msg.host ?? null,
at: now.toISOString(),
}),
};
}
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export function webhookConfigPath(configDir: string): string {
return join(configDir, WEBHOOK_FILE);
}
function coerce(raw: unknown): WebhookConfig {
const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
return {
enabled: r.enabled === true,
kind: (WEBHOOK_KINDS as readonly unknown[]).includes(r.kind)
? (r.kind as WebhookKind)
: DEFAULT_WEBHOOK_CONFIG.kind,
url: typeof r.url === 'string' ? r.url : '',
scope: (WEBHOOK_SCOPES as readonly unknown[]).includes(r.scope)
? (r.scope as WebhookScope)
: DEFAULT_WEBHOOK_CONFIG.scope,
};
}
export async function readWebhookConfig(configDir: string): Promise<WebhookConfig> {
try {
return coerce(JSON.parse(await fs.readFile(webhookConfigPath(configDir), 'utf-8')));
} catch {
return { ...DEFAULT_WEBHOOK_CONFIG };
}
}
/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */
export async function writeWebhookConfig(configDir: string, cfg: WebhookConfig): Promise<void> {
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
const target = webhookConfigPath(configDir);
const tmp = `${target}.${process.pid}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(coerce(cfg), null, 2), { mode: 0o600 });
await fs.rename(tmp, target);
} catch (err) {
await fs.unlink(tmp).catch(() => undefined);
throw err;
}
}
// ---------------------------------------------------------------------------
// IO
// ---------------------------------------------------------------------------
export type WebhookFetch = (target: URL, init: RequestInit) => Promise<Response>;
/**
* What went wrong, without the URL: blocked / timed out / refused / an HTTP status. An egress
* refusal is recognised by its `CODEMAN_EGRESS_BLOCKED` code anywhere in the cause chain (undici
* wraps the lookup's error as `TypeError('fetch failed', { cause })`), never by message text.
*/
function describeError(err: unknown): string {
const e = err as { name?: string; cause?: { code?: string } };
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') return 'Timed out';
if (isEgressBlockedError(err)) return 'Refused: target is a link-local or cloud-metadata address';
if (e?.cause?.code === 'ENOTFOUND') return 'Host not found';
if (e?.cause?.code === 'ECONNREFUSED') return 'Connection refused';
return 'Network error';
}
export async function sendWebhook(
cfg: Pick<WebhookConfig, 'kind' | 'url'>,
msg: WebhookMessage,
fetchImpl: WebhookFetch
): Promise<WebhookResult> {
const at = Date.now();
const problem = webhookUrlProblem(cfg.url);
if (problem) return { ok: false, error: problem, at };
const req = buildWebhookRequest(cfg.kind, msg);
try {
const res = await fetchImpl(new URL(cfg.url), {
method: req.method,
headers: req.headers,
body: req.body,
redirect: 'manual',
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
});
void res.body?.cancel().catch(() => undefined);
if (res.status >= 300 && res.status < 400) {
return { ok: false, status: res.status, error: 'The URL redirects; use the final URL', at };
}
return res.ok
? { ok: true, status: res.status, at }
: { ok: false, status: res.status, error: `HTTP ${res.status}`, at };
} catch (err) {
return { ok: false, error: describeError(err), at };
}
}
/**
* Sends the notifications the server decides on. Fire-and-forget by design (a slow webhook must
* never delay Web Push or a request), so it dedupes, caps what is in flight, and remembers only
* the last result for the Settings status line.
*/
export class WebhookNotifier {
private lastSent = new Map<string, number>();
private inFlight = 0;
private last: WebhookResult | null = null;
constructor(
private readonly load: () => Promise<WebhookConfig>,
private readonly fetchImpl: WebhookFetch,
private readonly now: () => number = Date.now
) {}
get lastResult(): WebhookResult | null {
return this.last;
}
async notify(msg: WebhookMessage): Promise<void> {
const cfg = await this.load();
if (!shouldSendWebhook(cfg, msg.urgency)) return;
const key = `${msg.event}:${msg.sessionId ?? ''}`;
const t = this.now();
const prev = this.lastSent.get(key);
if (prev !== undefined && t - prev < DEDUPE_WINDOW_MS) return;
if (this.inFlight >= MAX_IN_FLIGHT) return;
this.lastSent.set(key, t);
if (this.lastSent.size > 256) {
for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k);
}
this.inFlight++;
try {
this.last = await sendWebhook(cfg, msg, this.fetchImpl);
} finally {
this.inFlight--;
}
}
/** A deliberate test send: bypasses `enabled`, scope and dedupe, and records the result. */
async sendTest(cfg: Pick<WebhookConfig, 'kind' | 'url'>, host?: string): Promise<WebhookResult> {
const result = await sendWebhook(
cfg,
{
event: 'webhook:test',
title: 'Codeman test notification',
body: 'If you can read this, webhook notifications are working.',
urgency: 'info',
host,
},
this.fetchImpl
);
this.last = result;
return result;
}
}
+108 -1
View File
@@ -12,7 +12,7 @@
* assertions see exactly what a spawned pane would.
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import { buildAdvisorSettings, buildInteractiveArgs } from '../src/session-cli-builder.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
@@ -27,6 +27,23 @@ import {
const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true';
/**
* What the direct-PTY fallback hands `pty.spawn`. The file and argv are recorded, then a
* harmless stand-in runs instead: the real `claude` must never start from a test, and the
* stand-in is a real process so `Session.stop()` has a real pid to signal.
*/
const ptySpawns = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>);
vi.mock('node-pty', async (importOriginal) => {
const real = await importOriginal<typeof import('node-pty')>();
return {
...real,
spawn: (file: string, args: string[] | string, options: import('node-pty').IPtyForkOptions) => {
ptySpawns.push({ file, args: Array.isArray(args) ? args : [args] });
return real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options);
},
};
});
function extractSettingsJson(cmd: string): unknown {
const idx = cmd.indexOf('--settings ');
expect(idx).toBeGreaterThan(-1);
@@ -164,6 +181,96 @@ describe('buildInteractiveArgs advisorModel (direct-PTY fallback)', () => {
});
});
/**
* #514 (per-session `model` → `--model`) and #530 landed together. A claude session carrying
* both must launch with `--model <id>` AND the advisor folded into the one `--settings` JSON,
* whatever the effort, on the tmux template and on the direct-PTY fallback alike. Recovery of
* the same pair is pinned in test/session-model-recovery.test.ts.
*/
describe('a per-session model together with an advisor', () => {
const MODEL = 'claude-fable-5-1';
const cases = [
['ultracode', { ultracode: true, advisorModel: 'opus' }],
['high', { advisorModel: 'opus' }],
[undefined, { advisorModel: 'opus' }],
] as const;
it.each(cases)('tmux template carries --model and the merged --settings, effort %s', (effort, settings) => {
for (const resumeSessionId of [undefined, '11111111-2222-3333-4444-555555555555']) {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
model: MODEL,
effort,
advisorModel: 'opus',
resumeSessionId,
claudeCliVersion: null,
});
// The resume variant renders `resume || new`, so the model appears once per branch.
expect(cmd).toContain(`--model "${MODEL}"`);
expect(cmd).not.toContain('--advisor ');
const settingsFlags = cmd.match(/--settings /g) ?? [];
expect(settingsFlags.length).toBe(resumeSessionId ? 2 : 1);
expect(extractSettingsJson(cmd)).toEqual(settings);
if (effort === 'high') expect(cmd).toContain("--effort 'high'");
}
});
it('keeps the statusLine exporter in the same object beside the model', () => {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
model: MODEL,
effort: 'ultracode',
advisorModel: 'fable',
statusLineCommand: EXPORTER_CMD,
claudeCliVersion: null,
});
expect(cmd).toContain(`--model "${MODEL}"`);
expect(cmd.match(/--settings /g)).toHaveLength(1);
expect(extractSettingsJson(cmd)).toEqual({
ultracode: true,
advisorModel: 'fable',
statusLine: { type: 'command', command: EXPORTER_CMD },
});
});
it.each(cases)('direct-PTY args carry --model and the merged --settings, effort %s', (effort, settings) => {
const args = buildInteractiveArgs('sid', 'normal', MODEL, undefined, effort, undefined, null, 'opus');
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
if (effort === 'high') expect(args).toEqual(expect.arrayContaining(['--effort', 'high']));
});
describe('a real Session on the direct-PTY fallback', () => {
const live: Session[] = [];
afterEach(async () => {
for (const s of live.splice(0)) await s.stop();
ptySpawns.length = 0;
});
it.each(cases)('hands pty.spawn both, effort %s', async (effort, settings) => {
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: false,
model: MODEL,
advisorModel: 'opus',
effort,
});
live.push(session);
await session.startInteractive();
expect(ptySpawns).toHaveLength(1);
const { args } = ptySpawns[0];
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
});
});
});
describe('Session advisorModel', () => {
it('stores a valid advisor and persists it through toState()', () => {
const session = new Session({ workingDir: '/tmp', advisorModel: 'opus' });
+369
View File
@@ -0,0 +1,369 @@
/**
* @fileoverview Tests for the bounded path probe (src/utils/bounded-path-probe.ts):
* a stat() that never settles (an unreachable hard network mount) must not hold
* the caller past the timeout, must read as "unknown" rather than "absent", must
* not be re-issued while it is still pending, must not let stalled probes pile up
* in libuv's shared threadpool, and must not make unrelated healthy paths unknown.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('node:fs/promises', () => ({
default: { stat: vi.fn() },
}));
// The kernel mount table the probe scopes a stall by. `/mnt/nas` and `/mnt/nas b`
// (a mount point with a space, octal-escaped in the table) are network mounts;
// everything else sits on the root filesystem. `null` = no table (not Linux).
const mounts = vi.hoisted(() => ({
table: null as string | null,
default: [
'sysfs /sys sysfs rw 0 0',
'/dev/sda1 / ext4 rw 0 0',
'nas:/export /mnt/nas nfs rw,hard 0 0',
'nas:/other /mnt/nas\\040b nfs rw,hard 0 0',
'',
].join('\n'),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const readFileSync = ((path: unknown, ...rest: unknown[]) => {
if (String(path) === '/proc/self/mounts') {
if (mounts.table === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return mounts.table;
}
return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.readFileSync;
return { ...actual, readFileSync, default: { ...actual, readFileSync } };
});
import fs from 'node:fs/promises';
import {
boundedPathExists,
describeUnknownPath,
isNearStalledPath,
probePath,
probePathKind,
unknownPathReason,
} from '../src/utils/bounded-path-probe.js';
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING, PATH_PROBE_TIMEOUT_MS } from '../src/config/path-probe.js';
const stat = vi.mocked(fs.stat);
const dirStats = { isDirectory: () => true } as never;
const fileStats = { isDirectory: () => false } as never;
let releases: Map<string, () => void>;
let warn: ReturnType<typeof vi.spyOn>;
/**
* Make the first stat() of each given path hang until released (the mount is
* down); every later stat, and every other path, answers "a directory exists".
*/
function hangOn(paths: string[]): Map<string, () => void> {
stat.mockImplementation((path) => {
if (!paths.includes(String(path)) || releases.has(String(path))) return Promise.resolve(dirStats);
return new Promise((resolve) => {
releases.set(String(path), () => resolve(dirStats));
});
});
return releases;
}
/** Start probes for `paths` and let them time out, leaving each one stalled. */
async function stall(paths: string[]): Promise<void> {
const pending = paths.map((p) => probePath(p));
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await Promise.all(pending)).toEqual(paths.map(() => 'unknown'));
}
beforeEach(() => {
mounts.table = mounts.default;
releases = new Map();
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(async () => {
// Settle every stalled stat so module state does not leak into the next test.
releases.forEach((release) => release());
if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(0);
else await new Promise((r) => setTimeout(r, 0));
vi.useRealTimers();
stat.mockReset();
warn.mockRestore();
});
describe('probePath', () => {
it('tells present, absent and unreadable apart', async () => {
stat.mockImplementation(async (path) => {
if (String(path) === '/present') return dirStats;
if (String(path) === '/eio') throw Object.assign(new Error('EIO'), { code: 'EIO' });
if (String(path) === '/notdir/child') throw Object.assign(new Error('ENOTDIR'), { code: 'ENOTDIR' });
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
expect(await probePath('/present')).toBe('present');
expect(await probePath('/missing')).toBe('absent');
expect(await probePath('/notdir/child')).toBe('absent');
// A soft mount that gave up answers EIO: that is not proof the path is gone.
expect(await probePath('/eio')).toBe('unknown');
expect(await boundedPathExists('/present')).toBe(true);
expect(await boundedPathExists('/missing')).toBe(false);
expect(await boundedPathExists('/eio')).toBe(false);
// An error answer is neither a stall nor a refusal.
expect(unknownPathReason('/eio')).toBe('unreadable');
expect(describeUnknownPath('Case folder', '/eio')).toBe('Case folder is not responding or not readable: /eio');
});
it('reports whether a present path is a directory', async () => {
stat.mockImplementation(async (path) => (String(path) === '/dir' ? dirStats : fileStats));
expect(await probePathKind('/dir')).toBe('directory');
expect(await probePathKind('/file')).toBe('file');
});
it('answers unknown (not absent) after the timeout, and does not re-probe until the stat settles', async () => {
vi.useFakeTimers();
hangOn(['/mnt/stalled/case']);
const result = probePath('/mnt/stalled/case');
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await result).toBe('unknown');
// A second caller gets the cached verdict immediately, without another stat.
expect(await probePath('/mnt/stalled/case')).toBe('unknown');
expect(stat).toHaveBeenCalledTimes(1);
// Once the mount answers, the path is probed afresh.
releases.get('/mnt/stalled/case')!();
await vi.advanceTimersByTimeAsync(0);
expect(await probePath('/mnt/stalled/case')).toBe('present');
expect(stat).toHaveBeenCalledTimes(2);
});
it('shares one in-flight stat between concurrent callers of the same path', async () => {
hangOn(['/slow']);
const a = probePath('/slow');
const b = boundedPathExists('/slow');
expect(stat).toHaveBeenCalledTimes(1);
releases.get('/slow')!();
expect(await a).toBe('present');
expect(await b).toBe(true);
});
it('does not give concurrent healthy probes a false negative', async () => {
stat.mockImplementation(async () => dirStats);
const results = await Promise.all(['/a', '/b', '/c', '/d', '/e'].map((p) => boundedPathExists(p)));
expect(results).toEqual([true, true, true, true, true]);
});
it('still probes a healthy path as present while fewer unrelated paths are stalled than the cap', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES - 1 }, (_, i) => `/mnt/nas-${i}/project`);
hangOn(dead);
await stall(dead);
expect(await probePath('/home/user/codeman-cases/healthy')).toBe('present');
expect(await boundedPathExists('/home/user/codeman-cases/healthy/CLAUDE.md')).toBe(true);
expect(isNearStalledPath('/home/user/codeman-cases/healthy')).toBe(false);
});
it('answers unknown, without a stat, for paths near a stalled one', async () => {
vi.useFakeTimers();
hangOn(['/mnt/nas/project-one']);
await stall(['/mnt/nas/project-one']);
stat.mockClear();
// Its own files, and a sibling linked case on the same mount.
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
expect(await probePath('/mnt/nas/project-two')).toBe('unknown');
expect(isNearStalledPath('/mnt/nas/project-two/.claude/settings.local.json')).toBe(true);
expect(stat).not.toHaveBeenCalled();
releases.get('/mnt/nas/project-one')!();
await vi.advanceTimersByTimeAsync(0);
expect(isNearStalledPath('/mnt/nas/project-two')).toBe(false);
expect(await probePath('/mnt/nas/project-two')).toBe('present');
});
it('reads octal-escaped mount points from the table', async () => {
vi.useFakeTimers();
hangOn(['/mnt/nas b/one']);
await stall(['/mnt/nas b/one']);
expect(isNearStalledPath('/mnt/nas b/two')).toBe(true);
expect(isNearStalledPath('/mnt/nas/two')).toBe(false);
});
it('never takes the root filesystem down with a stalled path on it, only that path', async () => {
vi.useFakeTimers();
hangOn(['/srv/projects/stuck']);
await stall(['/srv/projects/stuck']);
expect(await probePath('/srv/projects/stuck/CLAUDE.md')).toBe('unknown');
expect(await probePath('/srv/projects/other')).toBe('present');
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
});
it('narrows a stall on a local mount to the stalled path, even when that mount is not /', async () => {
// /home is its own local filesystem; ~/nas is a symlink to a network mount, so
// the stalled path is typed under /home. Only network and FUSE mounts widen.
vi.useFakeTimers();
mounts.table = [mounts.default, '/dev/sdb1 /home ext4 rw,relatime 0 0', ''].join('\n');
hangOn(['/home/user/nas/project']);
await stall(['/home/user/nas/project']);
stat.mockClear();
expect(await probePath('/home/user/nas/project/CLAUDE.md')).toBe('unknown');
expect(isNearStalledPath('/home/user/codeman-cases/one')).toBe(false);
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
expect(await probePath('/home/user/nas/other')).toBe('present');
expect(stat).toHaveBeenCalledTimes(2);
});
it('widens a stall to the whole mount for network and FUSE filesystems', async () => {
vi.useFakeTimers();
mounts.table = [
mounts.default,
'nas:/four /srv/nas4 nfs4 rw,hard 0 0',
'user@host:/ /srv/sshfs fuse.sshfs rw 0 0',
'//nas/share /srv/smb cifs rw 0 0',
'',
].join('\n');
const dead = ['/srv/nas4/one', '/srv/sshfs/one'];
hangOn(dead);
await stall(dead);
expect(isNearStalledPath('/srv/nas4/two')).toBe(true);
expect(isNearStalledPath('/srv/sshfs/two')).toBe(true);
expect(isNearStalledPath('/srv/smb/two')).toBe(false);
expect(isNearStalledPath('/srv/elsewhere')).toBe(false);
});
it('narrows a stall to the stalled path when there is no mount table', async () => {
vi.useFakeTimers();
mounts.table = null;
hangOn(['/mnt/nas/project-one']);
await stall(['/mnt/nas/project-one']);
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
expect(await probePath('/mnt/nas/project-two')).toBe('present');
});
it('refuses new stats once stalled probes would tie up the threadpool, answering unknown', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/case`);
hangOn(dead);
await stall(dead);
stat.mockClear();
// Every slot is held by a stat that never returned: refuse another, but never
// claim the path is absent.
expect(await probePath('/healthy/elsewhere')).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
// Once the stalled stats settle, probing resumes normally.
releases.forEach((release) => release());
await vi.advanceTimersByTimeAsync(0);
expect(await probePath('/healthy/elsewhere')).toBe('present');
expect(stat).toHaveBeenCalledTimes(1);
});
it('lets a pastCap probe through the cap, still bounded and still recorded as stalled', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/full-${i}/case`);
hangOn([...dead, '/mnt/another-dead/case']);
await stall(dead);
stat.mockClear();
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
const hung = probePath('/mnt/another-dead/case', { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await hung).toBe('unknown');
// A retry is answered from the stall record, not with another stat.
expect(await probePath('/mnt/another-dead/case', { pastCap: true })).toBe('unknown');
expect(stat).toHaveBeenCalledTimes(2);
});
it('stops pastCap probes at the threadpool ceiling, answering unknown without a stat', async () => {
vi.useFakeTimers();
// Fill the bulk cap, then let pastCap probes stall until the ceiling is reached.
const dead = Array.from({ length: PATH_PROBE_STALL_CEILING + 1 }, (_, i) => `/mnt/ceiling-${i}/case`);
hangOn(dead);
await stall(dead.slice(0, MAX_STALLED_PATH_PROBES));
for (const path of dead.slice(MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING)) {
const hung = probePath(path, { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await hung).toBe('unknown');
}
stat.mockClear();
// One worker must stay free: no new stat, even for an explicit request.
const refused = probePath(dead[PATH_PROBE_STALL_CEILING], { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await refused).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
// ...and the message says the folder was never checked, rather than blaming it.
expect(unknownPathReason('/healthy/explicit', { pastCap: true })).toBe('refused');
expect(unknownPathReason(dead[0], { pastCap: true })).toBe('stalled');
expect(describeUnknownPath('workingDir', '/healthy/explicit', { pastCap: true })).toMatch(
/^workingDir was not checked: .*not answering.*: \/healthy\/explicit$/
);
expect(describeUnknownPath('workingDir', dead[0], { pastCap: true })).toBe(
`workingDir is not responding or not readable: ${dead[0]}`
);
// Once one stalled stat settles, an explicit request is probed again.
releases.get(dead[0])!();
await vi.advanceTimersByTimeAsync(0);
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
});
it('keeps the bulk cap below the ceiling, so a pastCap probe has room', async () => {
// Read under a controlled environment: the limits are computed at import from
// UV_THREADPOOL_SIZE and CODEMAN_PATH_PROBE_MAX_STALLED, which the test process
// could otherwise inherit.
const saved = { uv: process.env.UV_THREADPOOL_SIZE, max: process.env.CODEMAN_PATH_PROBE_MAX_STALLED };
const limitsUnder = async (uv: string | undefined, max: string | undefined) => {
if (uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
else process.env.UV_THREADPOOL_SIZE = uv;
if (max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = max;
vi.resetModules();
return import('../src/config/path-probe.js');
};
try {
const defaults = await limitsUnder(undefined, undefined);
expect([defaults.MAX_STALLED_PATH_PROBES, defaults.PATH_PROBE_STALL_CEILING]).toEqual([2, 3]);
const bigPool = await limitsUnder('8', undefined);
expect([bigPool.MAX_STALLED_PATH_PROBES, bigPool.PATH_PROBE_STALL_CEILING]).toEqual([6, 7]);
// An override may reach the ceiling but never pass it.
const overridden = await limitsUnder(undefined, '64');
expect(overridden.MAX_STALLED_PATH_PROBES).toBe(overridden.PATH_PROBE_STALL_CEILING);
} finally {
if (saved.uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
else process.env.UV_THREADPOOL_SIZE = saved.uv;
if (saved.max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = saved.max;
vi.resetModules();
}
});
it('warns once when a path first stalls and once when the cap engages', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/gone-${i}/case`);
hangOn(dead);
await stall([dead[0]]);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('/mnt/gone-0/case');
// Asking again about the same stalled path does not warn again.
await probePath(dead[0]);
expect(warn).toHaveBeenCalledTimes(1);
await stall(dead.slice(1));
warn.mockClear();
await probePath('/healthy/one');
await probePath('/healthy/two');
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toMatch(/stalled/i);
});
});
+173
View File
@@ -0,0 +1,173 @@
/** @fileoverview Add Case → Create New → "Create in a custom folder", end to end: real server, real Chromium, real folders. */
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
declare const PathPicker: any; // evaluated inside the page, where it is a global
const PORT = 3193;
describe('Create a case in a custom folder', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let parent: string;
beforeAll(async () => {
parent = mkdtempSync(join(homedir(), 'custom-case-'));
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(parent, { recursive: true, force: true });
}, 60000);
const open = async () => {
await page.evaluate(() => {
(window as any).app.showCreateCaseModal();
(window as any).app.switchCaseModalTab('case-create');
});
};
const toastText = () =>
page.evaluate(() => [...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'));
it('hides the parent-folder field until the box is ticked, and shows what it will create', async () => {
await open();
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(true);
await page.fill('#newCaseName', 'my-app');
await page.fill('#newCasePath', '~/projects/');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: ~/projects/my-app');
});
it('is exclusive with the Docker option, in both directions', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseDocker)');
expect(await page.isDisabled('#newCaseCustomPathToggle')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseDocker)');
});
it('Browse opens the folder picker for directories only and fills the field with the choice', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'picked');
const opts = await page.evaluate(() => {
// A top-level `const` in a classic script: a global binding, not a window property.
const picker = PathPicker;
let captured: any = null;
const original = picker.open;
picker.open = (o: any) => (captured = o);
(document.querySelector('#newCaseCustomPathRow .path-input-browse') as HTMLElement).click();
picker.open = original;
captured.onSelect('/srv/work');
return { directoriesOnly: captured.directoriesOnly };
});
expect(opts.directoriesOnly).toBe(true);
expect(await page.inputValue('#newCasePath')).toBe('/srv/work');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /srv/work/picked');
});
it('asks for a folder when the box is ticked and the field is empty, and creates nothing', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'no-folder');
await page.evaluate(() => (window as any).app.createCase());
expect(await toastText()).toMatch(/Choose the folder/);
});
it('creates the case in the chosen folder, scaffolds it, and lists it at that path', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'in-custom');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.submitCaseModal());
await page.waitForFunction(() =>
/created in/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
const target = join(parent, 'in-custom');
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('in-custom');
expect(existsSync(join(target, 'src'))).toBe(true);
const cases = await page.evaluate(async () => {
const body = await (await fetch('/api/cases')).json();
return Array.isArray(body) ? body : body.data;
});
expect(cases.find((c: { name: string }) => c.name === 'in-custom')).toMatchObject({ path: target });
expect(existsSync(join(homedir(), 'codeman-cases', 'in-custom'))).toBe(false);
expect(await page.isVisible('#createCaseModal.active')).toBe(false);
});
it('shows the server’s reason for a folder that already has files, and leaves it untouched', async () => {
const busy = join(parent, 'busy');
mkdirSync(busy);
writeFileSync(join(busy, 'keep.txt'), 'mine');
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'busy');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.createCase());
await page.waitForFunction(() =>
/Link Existing/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
expect(readFileSync(join(busy, 'keep.txt'), 'utf8')).toBe('mine');
expect(existsSync(join(busy, 'CLAUDE.md'))).toBe(false);
});
it('rewords the "under ~/codeman-cases" hints while a custom folder is picked', async () => {
await open();
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.textContent('#newCaseNameHint')).toMatch(/parent folder below/);
expect(await page.textContent('#newCaseBlurb')).toMatch(/a folder you choose/);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
expect(await page.textContent('#newCaseBlurb')).toMatch(/under ~\/codeman-cases/);
});
it('keeps / as the root parent instead of sending an empty path', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'at-root');
await page.fill('#newCasePath', '/');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /at-root');
expect(await page.evaluate(() => (window as any).app._newCaseTargetPath())).toBe('/at-root');
});
it('names the folder the server created in the success toast (~ expanded)', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'via-tilde');
await page.fill('#newCasePath', `~/${basename(parent)}`);
await page.evaluate(() => (window as any).app.submitCaseModal());
const target = join(realpathSync(parent), 'via-tilde');
await page.waitForFunction(
(t) => [...document.querySelectorAll('.toast')].some((el) => el.textContent?.includes(t)),
target
);
expect(await toastText()).not.toMatch(/created in ~\//);
});
it('starts unticked every time the modal opens', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCasePath', '/tmp');
await open();
expect(await page.isChecked('#newCaseCustomPathToggle')).toBe(false);
expect(await page.inputValue('#newCasePath')).toBe('');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* @fileoverview "Create in a custom folder" (#535) on a parent folder that does not
* answer (#516): `prepareNewCasePath` must ask the bounded path probe first, and give
* up with UNREACHABLE within the probe timeout instead of reaching the realpath / stat /
* lstat / readdir calls that would wait on a hard mount forever.
*
* Only the chosen dead paths hang; everything else is the real filesystem.
* Port: none.
*/
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const probe = vi.hoisted(() => {
// Short probe timeout so a stall costs ~100 ms, read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
return { dead: '/mnt/dead-nas-case-parent', releases: [] as Array<() => void>, touched: [] as string[] };
});
/** A call on the dead path never settles (a hard mount), until afterEach releases it. */
function hangOnDead<F extends (...a: never[]) => unknown>(name: string, real: F): F {
return ((path: string, ...rest: unknown[]) => {
if (String(path) === probe.dead || String(path).startsWith(probe.dead + '/')) {
probe.touched.push(`${name} ${String(path)}`);
return new Promise((_resolve, reject) => {
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
});
}
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
}
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = hangOnDead('stat', actual.stat);
return { ...actual, stat, default: { ...actual, stat } };
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const promises = {
...actual.promises,
realpath: hangOnDead('realpath', actual.promises.realpath),
stat: hangOnDead('stat', actual.promises.stat),
lstat: hangOnDead('lstat', actual.promises.lstat),
readdir: hangOnDead('readdir', actual.promises.readdir),
};
return { ...actual, promises, default: { ...actual, promises } };
});
import { prepareNewCasePath } from '../src/web/case-path.js';
const root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-unreachable-')));
const ctx = { home: join(root, 'home'), dataDir: join(root, 'home', '.codeman'), casesDirs: [join(root, 'cases')] };
afterEach(async () => {
probe.releases.splice(0).forEach((release) => release());
probe.touched.length = 0;
await new Promise((r) => setTimeout(r, 0));
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
describe('prepareNewCasePath on a parent folder that does not answer', () => {
it('answers UNREACHABLE within the probe timeout and never touches the path unbounded', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await Promise.race([
prepareNewCasePath(`${probe.dead}/new-case`, ctx),
new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000)),
]);
warn.mockRestore();
expect(result).toMatchObject({ ok: false, code: 'UNREACHABLE' });
expect((result as { reason: string }).reason).toBe(
`The parent folder is not responding or not readable: ${probe.dead}`
);
// Only the bounded probe's own stat reached the dead mount.
expect(probe.touched).toEqual([`stat ${probe.dead}`]);
});
it('still reports a parent that definitely does not exist as NOT_FOUND', async () => {
expect(await prepareNewCasePath(join(root, 'no-such-parent', 'new-case'), ctx)).toMatchObject({
ok: false,
code: 'NOT_FOUND',
});
});
});
+192
View File
@@ -0,0 +1,192 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { blockedReason, expandHome, prepareNewCasePath, type NewCasePathContext } from '../src/web/case-path.js';
let root: string;
let home: string;
let ctx: NewCasePathContext;
beforeEach(() => {
// Resolved: prepareNewCasePath answers with symlink-resolved paths (macOS temp is under /private).
root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-')));
home = join(root, 'home');
mkdirSync(join(home, 'code'), { recursive: true });
ctx = { home, dataDir: join(home, '.codeman') };
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
describe('expandHome', () => {
it('expands ~ and ~/x only', () => {
expect(expandHome('~', '/h')).toBe('/h');
expect(expandHome('~/code/app', '/h')).toBe('/h/code/app');
expect(expandHome('~other/x', '/h')).toBe('~other/x');
expect(expandHome('/abs/~/x', '/h')).toBe('/abs/~/x');
});
});
describe('blockedReason', () => {
const c = { home: '/home/u', dataDir: '/home/u/.codeman' };
it.each([
['/', /root/],
['/etc', /system directory/],
['/etc/cron.d/x', /system directory/],
['/usr/local/src', /system directory/],
['/proc/1', /system directory/],
['/home/u', /home folder itself/],
['/home/u/.ssh', /credentials/],
['/home/u/.ssh/proj', /credentials/],
['/home/u/.aws/x', /credentials/],
['/home/u/.claude/skills/x', /configuration/],
['/home/u/.codeman/cases/x', /data folder/],
['/home/u/.codeman-beta/x', /data folder/],
])('refuses %s', (p, why) => expect(blockedReason(p, c)).toMatch(why));
it.each(['/home/u/code/app', '/home/u/.config/app', '/srv/projects/x', '/opt/work', '/tmp/x', '/home/u/etc/app'])(
'allows %s (a name that merely contains a blocked word is fine)',
(p) => expect(blockedReason(p, c)).toBeNull()
);
it('does not treat /etcetera or /usrlocal as the system directories', () => {
expect(blockedReason('/etcetera/x', c)).toBeNull();
expect(blockedReason('/usrlocal', c)).toBeNull();
});
it('refuses a cases directory and anything inside it, when given', () => {
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
});
it('judges against the system roots it is given', () => {
expect(blockedReason('/private/etc/x', c)).toBeNull();
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
});
});
describe('prepareNewCasePath', () => {
it('accepts a new folder under an existing parent and reports it does not exist yet', async () => {
const r = await prepareNewCasePath(join(home, 'code', 'new-app'), ctx);
expect(r).toMatchObject({ ok: true, existedEmpty: false });
if (r.ok) expect(r.path).toMatch(/code\/new-app$/);
});
it('accepts an existing EMPTY folder and says so', async () => {
mkdirSync(join(home, 'code', 'empty'));
expect(await prepareNewCasePath(join(home, 'code', 'empty'), ctx)).toMatchObject({ ok: true, existedEmpty: true });
});
it('expands ~, tolerates a trailing slash and a ./ segment, and accepts spaces', async () => {
const a = await prepareNewCasePath('~/code/from-tilde', ctx);
expect(a.ok && a.path).toBe(join(home, 'code', 'from-tilde'));
// A "./" segment and a trailing slash normalise away; the folder name may contain a space.
const b = await prepareNewCasePath(`${join(home, 'code')}/./with space/`, ctx);
expect(b.ok && b.path).toBe(join(home, 'code', 'with space'));
// ...and a folder with a space in it can be the PARENT of the next one.
mkdirSync(join(home, 'code', 'with space'));
expect(await prepareNewCasePath(`${join(home, 'code', 'with space')}/proj/`, ctx)).toMatchObject({ ok: true });
});
it.each([
['empty', ''],
['whitespace', ' '],
['relative', 'code/app'],
['dot-relative', './app'],
['traversal', '/tmp/../etc/x'],
['shell metacharacters', '/tmp/a;rm -rf /'],
['command substitution', '/tmp/$(id)'],
['quotes', "/tmp/it's"],
['newline', '/tmp/a\nb'],
])('rejects %s as invalid', async (_label, raw) => {
expect(await prepareNewCasePath(raw, ctx)).toMatchObject({ ok: false, code: 'INVALID' });
});
it('refuses system, home, credential and Codeman folders as BLOCKED', async () => {
for (const raw of [
'/etc/proj',
'/usr/src/x',
home,
join(home, '.ssh', 'x'),
join(home, '.codeman', 'cases', 'x'),
]) {
expect(await prepareNewCasePath(raw, ctx), raw).toMatchObject({ ok: false, code: 'BLOCKED' });
}
});
it('judges the symlink-resolved path too: a link into a blocked tree is not a way around it', async () => {
symlinkSync('/etc', join(home, 'code', 'sneaky'));
expect(await prepareNewCasePath(join(home, 'code', 'sneaky', 'proj'), ctx)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
});
it('judges the resolved path against the resolved home too, when home is reached through a symlink', async () => {
const realHome = join(root, 'realhome');
mkdirSync(join(realHome, '.ssh'), { recursive: true });
mkdirSync(join(realHome, 'code'));
const linkHome = join(root, 'linkhome');
symlinkSync(realHome, linkHome);
// Typed, this reads as <link home>/code/innocent/x; resolved, it is <real home>/.ssh/x.
symlinkSync(join(realHome, '.ssh'), join(realHome, 'code', 'innocent'));
const viaLink = { home: linkHome, dataDir: join(linkHome, '.codeman') };
expect(await prepareNewCasePath(join(linkHome, 'code', 'innocent', 'x'), viaLink)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
// The same for Codeman's data dir given through the link.
mkdirSync(join(realHome, '.codeman'));
symlinkSync(join(realHome, '.codeman'), join(realHome, 'code', 'state'));
expect(await prepareNewCasePath(join(linkHome, 'code', 'state', 'x'), viaLink)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
});
it('refuses a link into a cases directory, judged on its resolved form', async () => {
const cases = join(home, 'codeman-cases');
mkdirSync(cases);
symlinkSync(cases, join(home, 'code', 'shortcut'));
const r = await prepareNewCasePath(join(home, 'code', 'shortcut', 'app'), { ...ctx, casesDirs: [cases] });
expect(r).toMatchObject({ ok: false, code: 'BLOCKED' });
if (!r.ok) expect(r.reason).toMatch(/plain Create New/);
});
it('reports a missing parent as NOT_FOUND and never makes a chain of folders', async () => {
const r = await prepareNewCasePath(join(home, 'code', 'nope', 'deeper', 'app'), ctx);
expect(r).toMatchObject({ ok: false, code: 'NOT_FOUND' });
});
it('refuses a parent that is a file', async () => {
writeFileSync(join(home, 'code', 'afile'), 'x');
expect(await prepareNewCasePath(join(home, 'code', 'afile', 'app'), ctx)).toMatchObject({ ok: false });
});
it('refuses a folder that already has files in it, pointing at Link Existing', async () => {
mkdirSync(join(home, 'code', 'mine'));
writeFileSync(join(home, 'code', 'mine', 'README.md'), 'hello');
const r = await prepareNewCasePath(join(home, 'code', 'mine'), ctx);
expect(r).toMatchObject({ ok: false, code: 'EXISTS' });
if (!r.ok) expect(r.reason).toMatch(/Link Existing/);
});
it('refuses a target that is a file or a symbolic link', async () => {
writeFileSync(join(home, 'code', 'plain'), 'x');
expect(await prepareNewCasePath(join(home, 'code', 'plain'), ctx)).toMatchObject({ ok: false, code: 'INVALID' });
mkdirSync(join(home, 'code', 'real'));
symlinkSync(join(home, 'code', 'real'), join(home, 'code', 'link'));
const r = await prepareNewCasePath(join(home, 'code', 'link'), ctx);
expect(r).toMatchObject({ ok: false, code: 'INVALID' });
if (!r.ok) expect(r.reason).toMatch(/symbolic link/);
});
it('never creates anything', async () => {
await prepareNewCasePath(join(home, 'code', 'dry-run'), ctx);
const { existsSync } = await import('node:fs');
expect(existsSync(join(home, 'code', 'dry-run'))).toBe(false);
});
});
+37
View File
@@ -0,0 +1,37 @@
// @vitest-environment node
// capabilities.newline: the bytes Shift+Enter types into a CLI's pane. Data in the registry, not
// a branch on the CLI id (test/cli-registry-no-id-branching.test.ts keeps the latter true).
import { describe, expect, it } from 'vitest';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
describe('capabilities.newline', () => {
it('no stock CLI declares a chord: every one keeps the line feed', () => {
// codex 0.147.0 takes a line feed (checked against a real tmux pane), so there is no CLI that
// needs esc-enter yet. The capability exists for a user clis.json override and the next CLI.
const declared = STOCK_CLIS.filter((e) => e.capabilities.newline).map((e) => e.id as string);
expect(declared).toEqual([]);
});
it.each(['line-feed', 'esc-enter'])('schema accepts %s', (value) => {
const e = claude();
(e.capabilities as Record<string, unknown>).newline = value;
expect(CliEntrySchema.safeParse(e).success).toBe(true);
});
it.each(['lf', 'crlf', '\x1b\r', '', 0])('schema rejects %j (no free-form byte strings in config)', (value) => {
const e = claude();
(e.capabilities as Record<string, unknown>).newline = value;
expect(CliEntrySchema.safeParse(e).success).toBe(false);
});
it('is optional, so an entry that declares nothing keeps the line feed', () => {
const e = claude();
delete (e.capabilities as Record<string, unknown>).newline;
expect(CliEntrySchema.safeParse(e).success).toBe(true);
});
});
+27
View File
@@ -24,6 +24,7 @@
import { describe, it, expect } from 'vitest';
import { getCli } from '../src/config/cli-registry/registry.js';
import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
/** A fixed session id, so `--session-id` is stable across runs. */
const SID = '0f9c2b14-1111-2222-3333-444455556666';
@@ -54,6 +55,20 @@ describe('claude', () => {
);
});
it('renders a model as the quoted value of --model, even one that opens with a dash', () => {
// POST /api/sessions refuses a leading '-' in `model`, but the registry's `model-claude`
// pattern still admits one, so the builder must stay safe on its own: the value lands
// quoted, and Claude's option parser takes the word after `--model` as its value whatever
// it starts with, so it can never become a flag of its own.
expect(claude({ model: 'claude-fable-5-1' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --model "claude-fable-5-1"'
);
expect(claude({ model: '--dangerously-skip-permissions' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--model "--dangerously-skip-permissions"'
);
});
it('resumes through a shell fallback to a fresh session', () => {
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
// text. This pin is what proves the fallback chain still renders as one command line.
@@ -131,6 +146,18 @@ describe('codex', () => {
it('resumes with a POSITIONAL subcommand, not a flag', () => {
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
});
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
for (const level of CODEX_REASONING_EFFORTS) {
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
}
});
it('keeps reasoning effort ahead of the resume subcommand', () => {
expect(cx({ model: 'gpt-5', reasoningEffort: 'high', resumeSessionId: 'roll_42' })).toBe(
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
);
});
});
describe('gemini', () => {
@@ -0,0 +1,41 @@
/**
* @fileoverview `codexConfig.reasoningEffort` on the create routes.
*
* The level becomes part of a `--config model_reasoning_effort=<level>` launch token, so the
* schema admits only the words codex knows; anything else fails the request rather than
* reaching the argv.
*/
import { describe, it, expect } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
describe('codexConfig.reasoningEffort', () => {
it('accepts every level codex knows on both create routes', () => {
for (const level of CODEX_REASONING_EFFORTS) {
const created = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'codex',
codexConfig: { reasoningEffort: level },
});
expect(created.codexConfig?.reasoningEffort).toBe(level);
const quick = QuickStartSchema.parse({
caseName: 'work',
mode: 'codex',
codexConfig: { reasoningEffort: level },
});
expect(quick.codexConfig?.reasoningEffort).toBe(level);
}
});
it('rejects a level codex does not know, and anything shaped like shell, on both create routes', () => {
for (const reasoningEffort of ['bogus', 'HIGH', 'high; rm -rf /', '']) {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'codex', codexConfig: { reasoningEffort } })
).toThrow();
expect(() =>
QuickStartSchema.parse({ caseName: 'work', mode: 'codex', codexConfig: { reasoningEffort } })
).toThrow();
}
});
});
+136 -1
View File
@@ -1,4 +1,7 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dependencyRegistry } from '../src/config/dependency-registry.js';
import {
detectEnvironment,
@@ -129,6 +132,7 @@ function fakeHost(env: ProbeEnvironment, over: Partial<ProbeHost> = {}): ProbeHo
environment: env,
which: () => null,
fileExists: () => false,
isExecutableFile: () => false,
runVersion: () => null,
windowsProgramRoots: () => [],
windowsFileVersion: () => null,
@@ -259,6 +263,121 @@ describe('checkTool with requireVersionMatch (generic binary names)', () => {
});
});
describe('checkTool with searchDirs (service PATH is minimal)', () => {
const claudeLike: ToolDependency = {
...tmuxTool,
id: 'claude',
label: 'Claude CLI',
resolvers: [
{
match: ['linux'],
resolver: { kind: 'path', bins: ['claude'], searchDirs: ['/home/u/.local/bin', '/opt/npm/bin/'] },
},
],
};
it('finds a CLI that only lives in a searchDirs entry and runs --version on the absolute path', () => {
const runVersion = vi.fn(() => 'claude 2.1.0');
const host = fakeHost('linux', { isExecutableFile: (p) => p === '/opt/npm/bin/claude', runVersion });
expect(checkTool(claudeLike, host)).toMatchObject({
status: 'ok',
path: '/opt/npm/bin/claude',
version: '2.1.0',
});
expect(runVersion).toHaveBeenCalledWith('/opt/npm/bin/claude', ['--version']);
});
it('still reports missing when neither PATH nor any search dir has it', () => {
expect(checkTool(claudeLike, fakeHost('linux'))).toMatchObject({ status: 'missing' });
});
it('prefers the PATH hit over a search dir', () => {
const host = fakeHost('linux', {
which: () => '/usr/bin/claude',
isExecutableFile: () => true,
runVersion: () => '1.0.0',
});
expect(checkTool(claudeLike, host)).toMatchObject({ path: '/usr/bin/claude' });
});
// The run mode's resolver (createCliExecutableResolver) accepts a search-dir candidate only
// as an absolute path to an executable regular file. A file that merely exists is not one.
it('skips a search-dir file that exists but is not executable', () => {
const runVersion = vi.fn(() => 'claude 2.1.0');
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => false, runVersion });
expect(checkTool(claudeLike, host)).toMatchObject({ status: 'missing' });
expect(runVersion).not.toHaveBeenCalled();
});
it('ignores a relative search dir (a custom clis.json entry) as the resolver does', () => {
const relative: ToolDependency = {
...claudeLike,
resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['claude'], searchDirs: ['tools/bin'] } }],
};
const runVersion = vi.fn(() => 'claude 2.1.0');
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => true, runVersion });
expect(checkTool(relative, host)).toMatchObject({ status: 'missing' });
expect(runVersion).not.toHaveBeenCalled();
});
// The grok case: an npm squatter answers on the PATH while the real CLI sits in ~/.grok/bin.
// The Run menu's resolver rejects the squatter and moves on to the search dirs; the doctor
// used to stop at the PATH hit and report MISSING.
const squatted: ToolDependency = {
...claudeLike,
id: 'pi',
label: 'Pi CLI',
resolvers: [
{
match: ['linux'],
resolver: {
kind: 'path',
bins: ['pi'],
versionRegex: PI_VERSION_REGEX,
requireVersionMatch: true,
searchDirs: ['/home/u/.local/bin', '/home/u/.npm-global/bin'],
},
},
],
};
it('finds the right binary in a search dir when a wrong one is on the PATH', () => {
const host = fakeHost('linux', {
which: () => '/usr/bin/pi',
isExecutableFile: (p) => p === '/home/u/.npm-global/bin/pi',
runVersion: (bin) => (bin === '/usr/bin/pi' ? 'Raspberry Pi utility\n' : '0.84.3\n'),
});
expect(checkTool(squatted, host)).toMatchObject({
status: 'ok',
path: '/home/u/.npm-global/bin/pi',
version: '0.84.3',
});
});
it('version-checks each search-dir candidate and moves past one that fails', () => {
const runVersion = vi.fn((bin: string) => (bin === '/home/u/.local/bin/pi' ? 'something else\n' : '0.84.3\n'));
const host = fakeHost('linux', { isExecutableFile: () => true, runVersion });
expect(checkTool(squatted, host)).toMatchObject({ status: 'ok', path: '/home/u/.npm-global/bin/pi' });
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
});
it('probes a search dir that is also on the PATH only once', () => {
const runVersion = vi.fn(() => 'Raspberry Pi utility\n');
const host = fakeHost('linux', { which: () => '/home/u/.local/bin/pi', isExecutableFile: () => true, runVersion });
expect(checkTool(squatted, host)).toMatchObject({ status: 'missing' });
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
});
it('carries each enabled CLI’s expanded discovery.searchDirs onto its registry row', () => {
const rows = dependencyRegistry().flatMap((t) => t.resolvers.map((r) => r.resolver));
const withDirs = rows.filter((r) => r.kind === 'path' && r.searchDirs?.length);
expect(withDirs.length).toBeGreaterThan(0);
for (const r of withDirs) {
if (r.kind === 'path') for (const d of r.searchDirs ?? []) expect(d.startsWith('~')).toBe(false);
}
});
});
describe('checkAll', () => {
it('maps every tool to a result', () => {
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
@@ -273,4 +392,20 @@ describe('createRealHost', () => {
expect(typeof host.which).toBe('function');
expect(Array.isArray(host.windowsProgramRoots())).toBe(true);
});
it('counts only an executable regular file as a search-dir candidate', () => {
const dir = mkdtempSync(join(tmpdir(), 'doctor-exec-'));
try {
const file = join(dir, 'tool');
writeFileSync(file, '#!/bin/sh\necho 1.0.0\n', { mode: 0o644 });
const host = createRealHost();
expect(host.isExecutableFile(file)).toBe(false);
chmodSync(file, 0o755);
expect(host.isExecutableFile(file)).toBe(true);
expect(host.isExecutableFile(dir)).toBe(false);
expect(host.isExecutableFile(join(dir, 'absent'))).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+109
View File
@@ -0,0 +1,109 @@
// @vitest-environment node
// The contract GET /api/doctor's default runner relies on: the same entry script, given
// `doctor --json`, prints a parseable DependencyReportJson on stdout, even when it exits
// non-zero because something required is missing.
//
// Hermetic: the doctor runs `--version` on every CLI it finds, and a suite must never execute
// whatever happens to be installed on the machine running it (cli-executable-resolver.ts
// @fileoverview). Each run gets a temp HOME and a PATH holding only `which` and `node`, and a
// clis.json in that HOME's data dir drops the registry's absolute search dirs
// (`/usr/local/bin`), so the only CLI the doctor can find is a fixture this file wrote.
import { execFile, execFileSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { isAbsolute, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const ROOT = join(import.meta.dirname, '..');
interface DoctorRun {
report: { tools: Array<{ id: string; status: string; path?: string; category: string }> } & Record<string, any>;
stderr: string;
}
function hermeticDoctorEnv(): { home: string; bare: string; env: NodeJS.ProcessEnv; cleanup: () => void } {
const home = mkdtempSync(join(tmpdir(), 'doctor-home-'));
const bare = mkdtempSync(join(tmpdir(), 'doctor-path-'));
symlinkSync(execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(), join(bare, 'which'));
symlinkSync(process.execPath, join(bare, 'node'));
// Overrides deep-merge by id and arrays replace wholesale, so this keeps every stock entry
// and only narrows its search dirs to the `~` ones, which resolve inside the temp HOME.
const clis = Object.fromEntries(
STOCK_CLIS.map((e) => [e.id, { discovery: { searchDirs: e.discovery.searchDirs.filter((d) => !isAbsolute(d)) } }])
);
mkdirSync(join(home, '.codeman'), { recursive: true });
// 0600 or the registry ignores the file (isUnsafePermissions).
writeFileSync(join(home, '.codeman', 'clis.json'), JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: bare };
delete env.CODEMAN_DATA_DIR;
delete env.CODEMAN_INSTANCE;
return {
home,
bare,
env,
cleanup: () => {
rmSync(home, { recursive: true, force: true });
rmSync(bare, { recursive: true, force: true });
},
};
}
function runDoctor(env: NodeJS.ProcessEnv): Promise<DoctorRun> {
return new Promise((resolve, reject) => {
execFile(
process.execPath,
[
join(ROOT, 'node_modules/tsx/dist/cli.mjs'),
join(ROOT, 'src/index.ts'),
'doctor',
'--json',
'--category',
'core',
],
{ timeout: 60_000, cwd: ROOT, env },
(err, out, stderr) => (out ? resolve({ report: JSON.parse(out), stderr }) : reject(err ?? new Error('no output')))
);
});
}
describe('codeman doctor --json', () => {
it('prints a report that includes Node and a summary, whatever the exit code', async () => {
const h = hermeticDoctorEnv();
try {
const { report, stderr } = await runDoctor(h.env);
expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/);
expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) }));
const node = report.tools.find((t) => t.id === 'node');
expect(node?.status).toBe('ok');
expect(report.tools.every((t) => t.category === 'core')).toBe(true);
// The override was accepted (an ignored or invalid clis.json warns on stderr), and nothing
// the doctor found, and so ran, lives outside this test's own temp dirs.
expect(stderr).not.toContain('[cli-registry]');
for (const t of report.tools.filter((t) => t.path)) {
expect(t.path!.startsWith(h.bare) || t.path!.startsWith(h.home)).toBe(true);
}
} finally {
h.cleanup();
}
}, 90_000);
// The report an operator got wrong in production: under systemd the PATH is minimal, so a CLI
// installed in ~/.local/bin read `missing` while the Run menu (which also searches the registry's
// searchDirs) found it.
it('finds a CLI that lives only in a registry searchDirs entry when the PATH is minimal', async () => {
const h = hermeticDoctorEnv();
try {
mkdirSync(join(h.home, '.local/bin'), { recursive: true });
const fake = join(h.home, '.local/bin/claude');
writeFileSync(fake, '#!/bin/sh\necho "2.1.0 (Claude Code)"\n');
chmodSync(fake, 0o755);
const { report } = await runDoctor(h.env);
const claude = report.tools.find((t) => t.id === 'claude');
expect(claude).toMatchObject({ status: 'ok', path: fake });
} finally {
h.cleanup();
}
}, 90_000);
});
+110
View File
@@ -0,0 +1,110 @@
/** @fileoverview Settings → System → Diagnostics in a real browser, with GET /api/doctor stubbed at the network layer. */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3196;
const REPORT = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
tools: [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
usedBy: [],
status: 'ok',
version: '22.1.0',
path: '/usr/bin/node',
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
usedBy: [],
status: 'missing',
installHint: 'apt install tmux',
},
// Host-supplied strings must be rendered as text, never as markup.
{
id: 'x',
label: '<img src=x onerror=window.__pwned=1>',
category: 'other',
required: false,
usedBy: [],
status: 'missing',
},
],
};
describe('Diagnostics panel in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
// A controlling service worker can swallow requests before page.route() sees them, letting the
// real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable.
page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
it('lists each tool with status, version, path and install hint, and renders host strings as text', async () => {
await page.route('**/api/doctor', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data: REPORT }) })
);
await page.click('#doctorRunBtn');
await page.waitForFunction(() => /1 ok/.test(document.getElementById('doctorResult')?.textContent ?? ''));
const text = await page.textContent('#doctorResult');
expect(text).toContain('1 ok · 1 required missing · 0 optional missing (linux)');
expect(text).toContain('✓ Node.js ok · 22.1.0');
expect(text).toContain('/usr/bin/node');
expect(text).toContain('✗ tmux missing · required');
// A missing OPTIONAL tool is not an error: ○, as the terminal doctor marks it.
expect(text).toContain('○ <img src=x onerror=window.__pwned=1> missing · optional');
expect(text).toContain('Install: apt install tmux');
expect(text).toContain('<img src=x onerror=window.__pwned=1>'); // shown literally
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
expect(await page.$('#doctorResult img')).toBeNull();
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
});
it('shows the server’s message when the check fails, and re-enables the button', async () => {
await page.unroute('**/api/doctor');
await page.route('**/api/doctor', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ success: false, errorCode: 'OPERATION_FAILED', error: 'doctor failed: boom' }),
})
);
await page.click('#doctorRunBtn');
await page.waitForFunction(() => /boom/.test(document.getElementById('doctorResult')?.textContent ?? ''));
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
});
it('hides the Diagnostics group from a non-admin in multi-user mode and shows it to an admin', async () => {
const visible = (user: Record<string, unknown>) =>
page.evaluate((u) => {
(window as any).__codemanUser = u;
document.dispatchEvent(new CustomEvent('codeman:me'));
return getComputedStyle(document.getElementById('doctorGroup')!).display !== 'none';
}, user);
expect(await visible({ multiUser: true, role: 'user' })).toBe(false);
expect(await visible({ multiUser: true, role: 'admin' })).toBe(true);
expect(await visible({ multiUser: false })).toBe(true);
});
});
+414
View File
@@ -0,0 +1,414 @@
/** @fileoverview Bottom-bar Git indicator and panel, end to end: real server, real Chromium, a real git repo with a remote. */
import { execFileSync } from 'node:child_process';
import { homedir } from 'node:os';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3192;
const ENV = {
...process.env,
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', ['-c', 'protocol.file.allow=always', ...args], { cwd, env: ENV, stdio: 'ignore' });
describe('Git status indicator in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let root: string;
let repo: string;
let plain: string;
let repoSession: string;
let plainSession: string;
const gitStatusRequests: string[] = [];
const settingsPutStatuses: number[] = [];
const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text);
const commitAll = (msg: string) => {
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', msg);
};
const label = () => page.evaluate(() => document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '');
const buttonVisible = () => page.evaluate(() => !document.getElementById('gitStatusBtn')!.hidden);
const refresh = () => page.evaluate(() => (window as any).app.refreshGitStatusNow());
const setSetting = async (on: boolean) => {
await page.evaluate(() => (window as any).app.openAppSettings());
if ((await page.isChecked('#appSettingsShowGitStatus')) !== on)
await page.click('label.switch:has(#appSettingsShowGitStatus)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
};
const createSession = (dir: string) =>
page.evaluate(async (workingDir) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'shell' }),
});
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id as string;
}, dir);
const select = (id: string) =>
page.evaluate(async (sid) => {
const app = (window as any).app;
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
await app.selectSession(sid);
}, id);
beforeAll(async () => {
root = mkdtempSync(join(homedir(), 'git-ui-'));
const bare = join(root, 'origin.git');
repo = join(root, 'repo');
plain = join(root, 'plain');
mkdirSync(repo);
mkdirSync(plain);
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
git(repo, 'init', '-q', '-b', 'main');
git(repo, 'remote', 'add', 'origin', bare);
write('a.txt', '1\n');
commitAll('base');
git(repo, 'push', '-q', '-u', 'origin', 'main');
// Dirty: a modified file, an untracked file, and one commit nobody has pushed.
write('b.txt', 'b\n');
commitAll('add b (not pushed)');
write('a.txt', '2\n');
write('new file.txt', 'n\n');
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('request', (r) => {
// The API route only: the page also loads /git-status-ui.js, whose URL contains the same words.
if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url());
});
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
});
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
repoSession = await createSession(repo);
plainSession = await createSession(plain);
await select(repoSession);
}, 120000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(root, { recursive: true, force: true });
}, 60000);
it('is off by default: no button and no request to the git-status route', async () => {
await page.waitForTimeout(2500); // longer than one tick
expect(await buttonVisible()).toBe(false);
expect(gitStatusRequests).toEqual([]);
});
it('turning it on through Settings saves cleanly (the key must not reach the strict PUT) and shows the counts', async () => {
await setSetting(true);
expect(settingsPutStatuses.length).toBeGreaterThan(0);
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
expect(await label()).toContain('● 2'); // a.txt modified + new file.txt untracked
expect(await label()).toContain('↑ 1'); // one commit not pushed
const title = await page.getAttribute('#gitStatusBtn', 'title');
expect(title).toMatch(/main/);
expect(title).toMatch(/2 uncommitted files/);
expect(title).toMatch(/1 commit not pushed/);
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--dirty/);
// The toolbar skin rules out-rank a bare class, so check the colour the user actually sees.
const colour = await page.evaluate(() => getComputedStyle(document.getElementById('gitStatusBtn')!).color);
expect(colour).toBe('rgb(224, 160, 48)');
});
it('sits at the right of the bottom bar, beside the version', async () => {
const inRight = await page.evaluate(() => !!document.querySelector('.toolbar-right #gitStatusBtn'));
expect(inRight).toBe(true);
const order = await page.evaluate(() => {
const right = document.querySelector('.toolbar-right')!;
return [...right.children].map((c) => c.id).filter(Boolean);
});
expect(order.indexOf('gitStatusBtn')).toBeLessThan(order.indexOf('versionDisplay'));
});
it('opens a panel listing the uncommitted files and the unpushed commit', async () => {
await page.click('#gitStatusBtn');
await page.waitForSelector('#gitStatusPanel.visible');
await page.waitForFunction(() => /Not staged/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
const body = (await page.textContent('#gitStatusBody')) ?? '';
expect(body).toContain('main → origin/main');
expect(body).toContain('Uncommitted changes (2)');
expect(body).toMatch(/Not staged \(1\)/);
expect(body).toMatch(/Untracked \(1\)/);
expect(body).toContain('a.txt');
expect(body).toContain('new file.txt');
expect(body).toContain('Not pushed (1)');
expect(body).toContain('add b (not pushed)');
expect(await page.textContent('#gitStatusBranch')).toBe('main');
expect(await page.textContent('#gitStatusFooter')).toMatch(/Read-only/);
expect(await page.getAttribute('#gitStatusBtn', 'aria-expanded')).toBe('true');
});
it('renders hostile file names as text, never as markup', async () => {
write('<img src=x onerror=window.__pwned=1>.txt');
await refresh();
await page.waitForFunction(() => /onerror/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
expect(await page.$('#gitStatusBody img')).toBeNull();
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
});
it('clicking a file shows its diff in the panel; Back returns to the list; Open file previews it', async () => {
await page.evaluate(() => {
(window as any).__previewed = [];
(window as any).app.openFilePreview = (p: string) => (window as any).__previewed.push(p);
});
await page.click('.git-status-file:has-text("a.txt")');
await page.waitForSelector('#gitStatusBody .git-diff');
expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('a.txt');
expect(await page.textContent('#gitStatusBody .git-diff-line--del')).toBe('-1\n');
expect(await page.textContent('#gitStatusBody .git-diff-line--add')).toBe('+2\n');
// The 15 s poll re-renders the panel; the diff must survive it.
await refresh();
expect(await page.$('#gitStatusBody .git-diff')).not.toBeNull();
await page.click('#gitStatusBody button:has-text("Open file")');
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([join(repo, 'a.txt')]);
await page.click('#gitStatusBody button:has-text("Back")');
await page.waitForSelector('.git-status-file:has-text("a.txt")');
expect(await page.$('#gitStatusBody .git-diff')).toBeNull();
});
it('an untracked file diffs as all additions, and a deleted file as all removals (with no Open file)', async () => {
await page.click('.git-status-file:has-text("new file.txt")');
await page.waitForSelector('#gitStatusBody .git-diff-line--add');
expect(await page.locator('#gitStatusBody .git-diff-line--del').count()).toBe(0);
await page.click('#gitStatusBody button:has-text("Back")');
rmSync(join(repo, 'b.txt'));
await refresh();
await page.waitForSelector('.git-status-file:has(.git-status-badge--D)');
await page.click('.git-status-file:has(.git-status-badge--D)');
await page.waitForSelector('#gitStatusBody .git-diff-line--del');
expect(await page.locator('#gitStatusBody .git-diff-line--add').count()).toBe(0);
expect(await page.locator('#gitStatusBody button:has-text("Open file")').count()).toBe(0);
await page.click('#gitStatusBody button:has-text("Back")');
});
it('keeps keyboard focus on the same file row across the 15 s re-render', async () => {
await page.focus('.git-status-file:has-text("a.txt")');
const key = () => page.evaluate(() => (document.activeElement as HTMLElement | null)?.dataset?.gitKey ?? null);
expect(await key()).toBe('unstaged|a.txt');
await refresh();
await page.waitForFunction(() => document.activeElement?.getAttribute('data-git-key') === 'unstaged|a.txt');
expect(await key()).toBe('unstaged|a.txt');
});
it('the panel never starts off-screen, even on a 650px-wide viewport', async () => {
const original = page.viewportSize()!;
await page.setViewportSize({ width: 650, height: original.height });
const left = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
await page.setViewportSize(original);
expect(left).toBeGreaterThanOrEqual(0);
});
it('drags by the header', async () => {
const before = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
const box = (await page.locator('.git-status-header').boundingBox())!;
await page.mouse.move(box.x + 40, box.y + 10);
await page.mouse.down();
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
await page.mouse.up();
const after = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
expect(after).toBeLessThan(before - 100);
});
it('groups files under folders that start collapsed and expand on click; the setting turns it off', async () => {
mkdirSync(join(repo, 'deep/er/still'), { recursive: true });
mkdirSync(join(repo, 'docs'));
write('deep/er/still/one.txt');
write('docs/a.md');
write('docs/b.md');
// git reports an all-untracked folder as ONE `dir/` entry, so commit these first and then edit them.
git(repo, 'add', 'deep', 'docs');
git(repo, 'commit', '-q', '-m', 'add folders');
write('deep/er/still/one.txt', 'changed\n');
write('docs/a.md', 'changed\n');
write('docs/b.md', 'changed\n');
await refresh();
await page.waitForSelector('.git-tree-dir');
// `deep/er/still` is a chain of single-child folders: one row, not three.
const names = await page.locator('.git-tree-name').allTextContents();
expect(names).toContain('deep/er/still/');
expect(names).toContain('docs/');
expect(await page.locator('.git-tree-dir[open]').count()).toBe(0);
expect(await page.locator('.git-status-file:has-text("one.txt")').isVisible()).toBe(false);
expect(await page.locator('.git-tree-dir:has(.git-tree-name:text-is("docs/")) .git-tree-count').textContent()).toBe(
'2'
);
await page.click('.git-tree-summary:has-text("docs/")');
expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true);
// The open folder survives the re-render a refresh causes.
await refresh();
await page.waitForSelector('.git-tree-dir[open]');
expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true);
// A file in a folder still opens its diff, and shows only its own name.
await page.click('.git-status-file:has-text("a.md")');
await page.waitForSelector('#gitStatusBody .git-diff-path');
expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('docs/a.md');
await page.click('#gitStatusBody button:has-text("Back")');
// Setting off: the flat list, every file by its full path.
await page.evaluate(() => (window as any).app.openAppSettings());
await page.click('label.switch:has(#appSettingsGitStatusTree)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
await page.evaluate(() => (window as any).app._renderGitStatusPanel());
expect(await page.locator('.git-tree-dir').count()).toBe(0);
expect(await page.locator('.git-status-path', { hasText: 'docs/a.md' }).count()).toBe(1);
// Back on for the rest of the file.
await page.evaluate(() => (window as any).app.openAppSettings());
await page.click('label.switch:has(#appSettingsGitStatusTree)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
// gitStatusTree is per-device: it must never reach the strict PUT /api/settings (a 400 there is
// what an unstripped key looks like), and it must round-trip through the saved settings.
expect(settingsPutStatuses.every((st) => st === 200)).toBe(true);
expect(await page.evaluate(() => (window as any).app.isGitStatusTree())).toBe(true);
rmSync(join(repo, 'deep'), { recursive: true });
rmSync(join(repo, 'docs'), { recursive: true });
}, 30000);
it('once everything is committed and pushed the button says so, and the panel agrees', async () => {
rmSync(join(repo, '<img src=x onerror=window.__pwned=1>.txt'));
git(repo, 'checkout', '-q', '--', '.');
git(repo, 'clean', '-fdq');
git(repo, 'push', '-q');
await refresh();
// A manual refresh bypasses the server's short cache, so this shows the push at once (not at the
// next 15 s poll, which is what a cached answer would mean).
await page.waitForFunction(
() => (document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '').includes('✓'),
null,
{ timeout: 5000 }
);
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--clean/);
// Toolbar buttons animate colour changes, so wait for the transition rather than racing it.
await page.waitForFunction(
() => getComputedStyle(document.getElementById('gitStatusBtn')!).color !== 'rgb(224, 160, 48)'
);
const body = (await page.textContent('#gitStatusBody')) ?? '';
expect(body).toContain('Nothing uncommitted.');
expect(body).toContain('Every commit on this branch is on a remote.');
});
it('shows nothing for a session whose folder is not a repository, and follows the active session', async () => {
await select(plainSession);
await page.waitForFunction(() => document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
// The open panel must stop showing the previous repo at once, then say why there is nothing.
await page.waitForFunction(
() => /No git repository here/.test(document.getElementById('gitStatusBody')!.textContent ?? ''),
null,
{
timeout: 15000,
}
);
expect(await page.textContent('#gitStatusBody')).not.toContain('a.txt');
await select(repoSession);
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
});
it('a folder that holds several repositories shows each one, and the indicator adds them up', async () => {
const parent = join(root, 'monorepo-ish');
mkdirSync(parent);
for (const name of ['api', 'web']) {
const r = join(parent, name);
mkdirSync(r);
git(r, 'init', '-q', '-b', 'main');
writeFileSync(join(r, 'f.txt'), '1\n');
git(r, 'add', '-A');
git(r, 'commit', '-q', '-m', 'init');
}
writeFileSync(join(parent, 'api', 'dirty.txt'), 'x');
writeFileSync(join(parent, 'api', 'dirty2.txt'), 'y');
const multiSession = await createSession(parent);
await select(multiSession);
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 2, null, {
timeout: 15000,
});
expect(await page.textContent('#gitStatusBranch')).toBe('2 repositories');
expect(await label()).toContain('● 2'); // only api is dirty, with two files
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(2 repositories\)/);
const names = await page.$$eval('.git-status-repo-name', (els) => els.map((e) => e.textContent));
expect(names).toEqual(['api', 'web']);
// Every repository starts collapsed (the summary line shows what is outstanding); one the user
// opens stays open when a refresh re-renders the panel.
const allClosed = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
expect(allClosed).toEqual([false, false]);
await page.click('.git-status-repo:nth-of-type(1) > summary');
await refresh();
await page.waitForSelector('.git-status-repo[open]');
const open = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
expect(open).toEqual([true, false]);
const apiBody = (await page.textContent('.git-status-repo:nth-of-type(1)')) ?? '';
expect(apiBody).toContain('dirty.txt');
expect(apiBody).toContain('dirty2.txt');
await select(repoSession);
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 0, null, {
timeout: 15000,
});
});
it('turning the setting off while a read is in flight, then on again, does not leave polling dead', async () => {
let slow = true;
await page.route('**/api/sessions/*/git-status*', async (route) => {
if (slow) await new Promise((r) => setTimeout(r, 1500));
await route.continue();
});
page.setDefaultTimeout(6000);
// A background poll may be mid-read: let it finish so OUR read is the one the slow route holds.
await page.waitForFunction(() => (window as any).app._gitStatusInFlight === false);
await page.evaluate(() => void (window as any).app.refreshGitStatus({ fresh: true }));
await page.waitForFunction(() => (window as any).app._gitStatusInFlight === true);
await setSetting(false);
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
slow = false;
await setSetting(true);
// Re-enabling starts its own read. With the flag stuck true that read is skipped for this session
// and the indicator never comes back.
await page.waitForFunction(() => !!(window as any).app._currentGitStatus());
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
page.setDefaultTimeout(30000);
// The first read may still be asleep in the handler: wait for it to continue, or its late
// route.continue() lands after the route is gone and fails the run as an unhandled rejection.
await page.unrouteAll({ behavior: 'wait' });
expect(await buttonVisible()).toBe(true);
// Turning the setting off closed the panel; reopen it for the tests that follow.
await page.click('#gitStatusBtn');
await page.waitForSelector('#gitStatusPanel.visible');
}, 30000);
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
await page.click('.git-status-actions button[aria-label="Close git status"]');
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
await page.click('#gitStatusBtn');
expect(await page.isVisible('#gitStatusPanel')).toBe(true);
await setSetting(false);
expect(await buttonVisible()).toBe(false);
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
expect(await page.evaluate(() => (window as any).app._gitStatusTimer)).toBeNull();
const before = gitStatusRequests.length;
await page.waitForTimeout(3000);
expect(gitStatusRequests.length).toBe(before);
}, 30000);
});
+898
View File
@@ -0,0 +1,898 @@
// @vitest-environment node
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearGitStatusCache,
getGitWorkspaceStatus,
MAX_FILES,
parseCommitLog,
parsePorcelainV2,
runGit,
type GitRunner,
} from '../src/git-workspace-status.js';
const NUL = '\0';
describe('parsePorcelainV2', () => {
const header = (extra: string[] = []) =>
['# branch.oid abc123', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +2 -1', ...extra].join(
NUL
) + NUL;
it('reads the branch, upstream and ahead/behind', () => {
const p = parsePorcelainV2(header());
expect(p).toMatchObject({
branch: 'main',
detached: false,
upstream: 'origin/main',
ahead: 2,
behind: 1,
files: [],
});
});
it('flags an upstream whose remote branch is gone: branch.upstream without branch.ab', () => {
expect(
parsePorcelainV2(['# branch.oid x', '# branch.head feature', '# branch.upstream origin/feature'].join(NUL) + NUL)
).toMatchObject({ upstream: 'origin/feature', upstreamGone: true });
expect(
parsePorcelainV2(
['# branch.oid x', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +0 -0'].join(NUL) + NUL
)
).toMatchObject({ upstreamGone: false });
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL).upstreamGone).toBe(false);
});
it('reads a detached HEAD and a branch with no upstream (no branch.ab line either)', () => {
expect(parsePorcelainV2(['# branch.oid x', '# branch.head (detached)'].join(NUL) + NUL)).toMatchObject({
branch: null,
detached: true,
upstream: null,
ahead: 0,
});
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL)).toMatchObject({
branch: 'feature',
upstream: null,
});
});
it('turns an entry that is staged AND modified in the tree into one row per kind', () => {
const line = '1 MM N... 100644 100644 100644 aaa bbb src/a.ts';
const files = parsePorcelainV2(header([line])).files;
expect(files.map((f) => [f.kind, f.index, f.worktree, f.path])).toEqual([
['staged', 'M', 'M', 'src/a.ts'],
['unstaged', 'M', 'M', 'src/a.ts'],
]);
});
it('classifies staged-only, unstaged-only, added and deleted', () => {
const lines = [
'1 M. N... 100644 100644 100644 a b staged.ts',
'1 .M N... 100644 100644 100644 a b unstaged.ts',
'1 A. N... 000000 100644 100644 0 b added.ts',
'1 .D N... 100644 100644 000000 a b gone.ts',
];
const files = parsePorcelainV2(header(lines)).files;
expect(files.map((f) => `${f.kind}:${f.index}${f.worktree}:${f.path}`)).toEqual([
'staged:M.:staged.ts',
'unstaged:.M:unstaged.ts',
'staged:A.:added.ts',
'unstaged:.D:gone.ts',
]);
});
it('reads a rename with its original path from the following token', () => {
const text = header(['2 R. N... 100644 100644 100644 a b R100 new name.ts' + NUL + 'old name.ts']);
expect(parsePorcelainV2(text).files).toEqual([
{ path: 'new name.ts', origPath: 'old name.ts', index: 'R', worktree: '.', kind: 'staged' },
]);
});
it('reads unmerged and untracked entries, and keeps odd names intact', () => {
const text = header([
'u UU N... 100644 100644 100644 100644 a b c conflict.ts',
'? with space.txt',
'? quote"and\'tick.txt',
'? new\nline.txt',
'? dir/',
]);
const files = parsePorcelainV2(text).files;
expect(files.map((f) => [f.kind, f.path])).toEqual([
['conflicted', 'conflict.ts'],
['untracked', 'with space.txt'],
['untracked', 'quote"and\'tick.txt'],
['untracked', 'new\nline.txt'],
['untracked', 'dir/'],
]);
});
it('skips unknown lines and survives empty input', () => {
expect(parsePorcelainV2('')).toMatchObject({ files: [], branch: null });
expect(parsePorcelainV2('! ignored.log' + NUL + 'weird line' + NUL).files).toEqual([]);
});
});
describe('parseCommitLog', () => {
it('reads hash, author, time and subject, including unicode and empty input', () => {
const text = 'abc1234\x1fAda\x1f1700000000\x1ffix: café\x1e\ndef5678\x1fBob\x1f1700000100\x1fsecond\x1e';
expect(parseCommitLog(text)).toEqual([
{ hash: 'abc1234', author: 'Ada', time: 1700000000, subject: 'fix: café' },
{ hash: 'def5678', author: 'Bob', time: 1700000100, subject: 'second' },
]);
expect(parseCommitLog('')).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Real git
// ---------------------------------------------------------------------------
const GIT_ENV = {
...process.env,
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]): string =>
execFileSync('git', ['-c', 'commit.gpgsign=false', '-c', 'protocol.file.allow=always', ...args], {
cwd,
env: GIT_ENV,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
let root: string;
let repo: string;
const write = (rel: string, text = 'x\n', dir = repo) => {
const f = join(dir, rel);
mkdirSync(join(f, '..'), { recursive: true });
writeFileSync(f, text);
};
const commit = (msg: string, dir = repo) => {
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', msg);
};
beforeEach(() => {
clearGitStatusCache();
root = mkdtempSync(join(tmpdir(), 'git-status-'));
repo = join(root, 'repo');
mkdirSync(repo);
git(repo, 'init', '-q', '-b', 'main');
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
describe('getGitWorkspaceStatus against a real repository', () => {
it('is not-a-repo outside a repository', async () => {
const plain = join(root, 'plain');
mkdirSync(plain);
expect((await getGitWorkspaceStatus(plain)).state).toBe('not-a-repo');
});
it('reports a folder that no longer exists as not-a-repo or an error, never a crash', async () => {
const s = await getGitWorkspaceStatus(join(root, 'gone'));
expect(['not-a-repo', 'error']).toContain(s.state);
});
it('a fresh repo: on its branch, untracked files, no remote, nothing "unpushed"', async () => {
write('a.txt');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ state: 'ok', branch: 'main', upstream: null, hasRemote: false, unpushedCount: 0 });
expect(s.counts).toMatchObject({ untracked: 1, uncommitted: 1, staged: 0, unstaged: 0 });
});
it('a clean repo with commits but no remote has nothing to push (not "every commit")', async () => {
write('a.txt');
commit('one');
write('b.txt');
commit('two');
const s = await getGitWorkspaceStatus(repo);
expect(s.counts.uncommitted).toBe(0);
expect(s).toMatchObject({ hasRemote: false, unpushedCount: 0, unpushed: [] });
});
it('separates staged, unstaged and untracked, and counts a staged+modified file once as uncommitted', async () => {
write('tracked.txt', '1\n');
write('both.txt', '1\n');
write('removed.txt', '1\n');
commit('base');
write('both.txt', '2\n');
git(repo, 'add', 'both.txt');
write('both.txt', '3\n'); // staged AND modified again
write('tracked.txt', '2\n'); // unstaged only
git(repo, 'rm', '-q', 'removed.txt'); // staged delete
write('new.txt'); // untracked
const s = await getGitWorkspaceStatus(repo);
expect(s.counts).toMatchObject({ staged: 2, unstaged: 2, untracked: 1, conflicted: 0, uncommitted: 4 });
const row = (kind: string, path: string) => s.files.find((f) => f.kind === kind && f.path === path);
expect(row('staged', 'both.txt')).toBeTruthy();
expect(row('unstaged', 'both.txt')).toBeTruthy();
expect(row('staged', 'removed.txt')?.index).toBe('D');
expect(row('untracked', 'new.txt')).toBeTruthy();
});
it('reports a staged rename with its original path', async () => {
write('old.txt', 'content that is long enough to be detected as a rename\n'.repeat(5));
commit('base');
git(repo, 'mv', 'old.txt', 'new.txt');
const s = await getGitWorkspaceStatus(repo);
expect(s.files).toContainEqual(
expect.objectContaining({ path: 'new.txt', origPath: 'old.txt', index: 'R', kind: 'staged' })
);
});
it('reports merge conflicts', async () => {
write('c.txt', 'base\n');
commit('base');
git(repo, 'checkout', '-q', '-b', 'other');
write('c.txt', 'other\n');
commit('other');
git(repo, 'checkout', '-q', 'main');
write('c.txt', 'main\n');
commit('main');
expect(() => git(repo, 'merge', 'other')).toThrow();
const s = await getGitWorkspaceStatus(repo);
expect(s.counts.conflicted).toBe(1);
expect(s.files.find((f) => f.kind === 'conflicted')?.path).toBe('c.txt');
});
it('handles file names with spaces, quotes and unicode', async () => {
write('with space.txt');
write('quote"d.txt');
write('café ☕.txt');
const s = await getGitWorkspaceStatus(repo);
expect(s.files.map((f) => f.path).sort()).toEqual(['café ☕.txt', 'quote"d.txt', 'with space.txt']);
});
it('caps the file list but keeps the counts exact', async () => {
for (let i = 0; i < MAX_FILES + 20; i++) write(`f${i}.txt`);
const s = await getGitWorkspaceStatus(repo);
expect(s.files).toHaveLength(MAX_FILES);
expect(s.filesTruncated).toBe(true);
expect(s.counts.untracked).toBe(MAX_FILES + 20);
expect(s.counts.uncommitted).toBe(MAX_FILES + 20);
});
it('counts stashes', async () => {
write('a.txt', '1\n');
commit('base');
write('a.txt', '2\n');
git(repo, 'stash', '-q');
expect((await getGitWorkspaceStatus(repo)).counts.stashes).toBe(1);
});
it('reports a detached HEAD', async () => {
write('a.txt');
commit('one');
git(repo, 'checkout', '-q', '--detach');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ detached: true, branch: null });
});
describe('with a remote', () => {
let bare: string;
beforeEach(() => {
bare = join(root, 'origin.git');
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
git(repo, 'remote', 'add', 'origin', bare);
write('a.txt', '1\n');
commit('first');
git(repo, 'push', '-q', '-u', 'origin', 'main');
});
it('in sync: nothing ahead, nothing unpushed', async () => {
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ upstream: 'origin/main', ahead: 0, behind: 0, hasRemote: true, unpushedCount: 0 });
});
it('lists commits that are ahead of the upstream, newest first, with the subject and author', async () => {
write('b.txt');
commit('second: add b');
write('c.txt');
commit('third: add c');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ ahead: 2, unpushedCount: 2 });
expect(s.unpushed.map((c) => c.subject)).toEqual(['third: add c', 'second: add b']);
expect(s.unpushed[0]).toMatchObject({ author: 'T' });
expect(s.unpushed[0].hash).toMatch(/^[0-9a-f]{7,}$/);
expect(s.unpushed[0].time).toBeGreaterThan(1_600_000_000);
});
it('behind reflects the last fetch only: it never fetches on its own', async () => {
const other = join(root, 'other');
git(root, 'clone', '-q', bare, other);
write('theirs.txt', 'x\n', other);
commit('theirs', other);
git(other, 'push', '-q', 'origin', 'main');
expect((await getGitWorkspaceStatus(repo)).behind).toBe(0); // not fetched yet
clearGitStatusCache();
git(repo, 'fetch', '-q');
expect((await getGitWorkspaceStatus(repo)).behind).toBe(1);
});
it('a branch with no upstream lists what no remote has', async () => {
git(repo, 'checkout', '-q', '-b', 'feature');
write('f1.txt');
commit('f1');
write('f2.txt');
commit('f2');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ branch: 'feature', upstream: null, hasRemote: true, unpushedCount: 2 });
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
});
it('a branch whose upstream was deleted and pruned is NOT reported as everything pushed', async () => {
git(repo, 'checkout', '-q', '-b', 'feature');
write('f1.txt');
commit('f1');
git(repo, 'push', '-q', '-u', 'origin', 'feature');
git(repo, 'push', '-q', 'origin', '--delete', 'feature');
git(repo, 'fetch', '-q', '--prune');
write('f2.txt');
commit('f2');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ branch: 'feature', upstream: 'origin/feature', upstreamGone: true });
expect(s.unpushedCount).toBe(2);
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
});
it('a pushed branch is not reported as unpushed once it has an upstream', async () => {
git(repo, 'checkout', '-q', '-b', 'feature');
write('f1.txt');
commit('f1');
git(repo, 'push', '-q', '-u', 'origin', 'feature');
expect((await getGitWorkspaceStatus(repo)).unpushedCount).toBe(0);
});
});
describe('it only reads', () => {
it('does not rewrite the index or leave a lock, even when stat data is stale', async () => {
write('a.txt', '1\n');
commit('base');
const before = readFileSync(join(repo, '.git', 'index'));
// Touching a tracked file makes a plain `git status` want to refresh the index.
const now = new Date();
const { utimesSync } = await import('node:fs');
utimesSync(join(repo, 'a.txt'), now, new Date(now.getTime() + 5000));
await getGitWorkspaceStatus(repo);
expect(readFileSync(join(repo, '.git', 'index')).equals(before)).toBe(true);
expect(existsSync(join(repo, '.git', 'index.lock'))).toBe(false);
});
it('does not run a repository-configured fsmonitor hook (and a plain git status would have)', async () => {
write('a.txt', '1\n');
commit('base');
const hook = join(root, 'fsmonitor.sh');
const marker = join(root, 'fsmonitor-ran');
writeFileSync(hook, `#!/bin/sh\necho ran >> '${marker}'\nprintf ''\n`);
chmodSync(hook, 0o755);
git(repo, 'config', 'core.fsmonitor', hook);
// Control: git itself runs it for a plain status, so the assertion below is not vacuous.
git(repo, 'status', '--short');
expect(existsSync(marker)).toBe(true);
rmSync(marker);
await getGitWorkspaceStatus(repo);
expect(existsSync(marker)).toBe(false);
});
});
});
// ---------------------------------------------------------------------------
// Cache and error mapping (fake runner)
// ---------------------------------------------------------------------------
describe('caching and failures', () => {
const okRunner = (calls: string[][] = []): GitRunner =>
vi.fn(async (_cwd, args) => {
calls.push(args);
if (args[0] === 'status') return ['# branch.oid x', '# branch.head main'].join(NUL) + NUL;
return '';
});
it('never runs a git command that writes or touches the network', async () => {
const calls: string[][] = [];
await getGitWorkspaceStatus('/w/never-network', { git: okRunner(calls) });
const verbs = new Set(calls.map((a) => a[0]));
for (const forbidden of ['fetch', 'pull', 'push', 'commit', 'add', 'checkout', 'reset', 'clean', 'gc']) {
expect(verbs.has(forbidden), forbidden).toBe(false);
}
expect([...verbs].sort()).toEqual(['log', 'remote', 'rev-list', 'rev-parse', 'stash', 'status']);
});
it('shares one in-flight computation between concurrent callers', async () => {
const calls: string[][] = [];
const git = okRunner(calls);
const [a, b, c] = await Promise.all([1, 2, 3].map(() => getGitWorkspaceStatus('/w/shared', { git })));
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
expect(a).toBe(b);
expect(b).toBe(c);
});
it('reuses a fresh result, recomputes after the TTL, and keeps folders apart', async () => {
let t = 1_000_000;
const calls: string[][] = [];
const git = okRunner(calls);
const get = (cwd: string) => getGitWorkspaceStatus(cwd, { git, now: () => t });
await get('/w/a');
t += 1000;
await get('/w/a');
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
await get('/w/b');
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
t += 10_000;
await get('/w/a');
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(3);
});
it('`fresh` skips the reuse of a recent result, but still joins a computation already running', async () => {
let t = 1_000_000;
const calls: string[][] = [];
const git = okRunner(calls);
const get = (fresh: boolean) => getGitWorkspaceStatus('/w/fresh', { git, now: () => t, fresh });
await get(false);
t += 500;
await get(false); // reused
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
await get(true); // a person pressed Refresh
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
clearGitStatusCache();
calls.length = 0;
await Promise.all([get(true), get(true), get(true)]);
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
});
it('maps "not a git repository" to not-a-repo', async () => {
const git: GitRunner = async () => {
throw Object.assign(new Error('x'), {
stderr: 'fatal: not a git repository (or any of the parent directories): .git',
});
};
expect((await getGitWorkspaceStatus('/w/none', { git })).state).toBe('not-a-repo');
});
it('reports a missing git binary and a timeout as short errors', async () => {
const enoent: GitRunner = async () => {
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
};
expect(await getGitWorkspaceStatus('/w/e1', { git: enoent })).toMatchObject({
state: 'error',
error: expect.stringMatching(/not installed/),
});
const slow: GitRunner = async () => {
throw Object.assign(new Error('timed out'), { killed: true });
};
expect(await getGitWorkspaceStatus('/w/e2', { git: slow })).toMatchObject({
state: 'error',
error: 'git timed out',
});
});
it('redacts credentials embedded in a remote URL from an error message', async () => {
const git: GitRunner = async () => {
throw Object.assign(new Error('x'), {
stderr: "fatal: unable to access 'https://user:ghp_SECRET@github.com/o/r.git/'",
});
};
const s = await getGitWorkspaceStatus('/w/redact', { git });
expect(s.state).toBe('error');
expect(s.error).not.toContain('ghp_SECRET');
expect(s.error).toContain('***:***@');
});
it('survives the secondary calls failing: status still comes back', async () => {
const git: GitRunner = async (_cwd, args) => {
if (args[0] === 'status')
return (
['# branch.oid x', '# branch.head main', '# branch.upstream o/main', '# branch.ab +3 -0'].join(NUL) + NUL
);
throw new Error('boom');
};
const s = await getGitWorkspaceStatus('/w/partial', { git });
expect(s).toMatchObject({ state: 'ok', branch: 'main', ahead: 3, unpushedCount: 0, unpushed: [] });
});
});
// ---------------------------------------------------------------------------
// Which repositories: getGitWorkspaceOverview
// ---------------------------------------------------------------------------
import { mkdirSync as mkdir, symlinkSync as symlink } from 'node:fs';
import {
discoverChildRepos,
getGitWorkspaceOverview,
isUnrelatedAncestor,
MAX_REPOS,
} from '../src/git-workspace-status.js';
describe('getGitWorkspaceOverview', () => {
let top: string;
let home: string;
const repoAt = (p: string): string => {
mkdir(p, { recursive: true });
git(p, 'init', '-q', '-b', 'main');
writeFileSync(join(p, 'f.txt'), '1\n');
git(p, 'add', '-A');
git(p, 'commit', '-q', '-m', 'c');
return p;
};
const names = (o: { repos: { path: string }[] }) => o.repos.map((r) => r.path);
beforeEach(() => {
top = mkdtempSync(join(tmpdir(), 'git-overview-'));
home = join(top, 'home');
mkdir(home, { recursive: true });
clearGitStatusCache();
});
afterEach(() => rmSync(top, { recursive: true, force: true }));
it('a folder that holds several repositories reports each, alphabetically, with its own status', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'web'));
repoAt(join(ws, 'api'));
writeFileSync(join(ws, 'api', 'dirty.txt'), 'x');
const o = await getGitWorkspaceOverview(ws, { home });
expect(o.state).toBe('ok');
expect(names(o)).toEqual(['api', 'web']);
expect(o.repos.map((r) => r.name)).toEqual(['api', 'web']);
expect(o.repos[0].status.counts.uncommitted).toBe(1);
expect(o.repos[1].status.counts.uncommitted).toBe(0);
expect(o.reposTruncated).toBe(false);
});
it('finds repositories two levels down but not three', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'apps', 'web'));
repoAt(join(ws, 'a', 'b', 'too-deep'));
const o = await getGitWorkspaceOverview(ws, { home });
expect(names(o)).toEqual([join('apps', 'web')]);
});
it('a subfolder of a repository reports the whole enclosing repository, naming where it is', async () => {
const r = repoAt(join(home, 'proj'));
mkdir(join(r, 'src', 'deep'), { recursive: true });
writeFileSync(join(r, 'top.txt'), 'x');
const o = await getGitWorkspaceOverview(join(r, 'src', 'deep'), { home });
expect(o.repos).toHaveLength(1);
expect(o.repos[0]).toMatchObject({ name: 'proj', path: join('..', '..') });
expect(o.repos[0].status.files.map((f) => f.path)).toContain('top.txt');
});
it('inside a repository it does not go looking for nested ones (they are just an untracked folder to the outer repo)', async () => {
const r = repoAt(join(home, 'outer'));
repoAt(join(r, 'vendor-ish', 'inner'));
const o = await getGitWorkspaceOverview(r, { home });
expect(names(o)).toEqual(['.']);
expect(o.repos[0].status.files.map((f) => f.path)).toEqual(['vendor-ish/']);
});
it('a session started inside the nested repository reports that one', async () => {
const r = repoAt(join(home, 'outer'));
const inner = repoAt(join(r, 'vendor-ish', 'inner'));
const o = await getGitWorkspaceOverview(inner, { home });
expect(o.repos[0].name).toBe('inner');
});
it('ignores a repository that merely sits above the workspace and is the home folder (a dotfiles repo)', async () => {
repoAt(home);
writeFileSync(join(home, 'zshrc'), 'x'); // dirty, and nothing to do with this session
const ws = join(home, 'codeman-cases', 'my-case');
mkdir(ws, { recursive: true });
const none = await getGitWorkspaceOverview(ws, { home });
expect(none.state).toBe('not-a-repo');
// ...but a project below the workspace is still found.
repoAt(join(ws, 'real-project'));
clearGitStatusCache();
const some = await getGitWorkspaceOverview(ws, { home, fresh: true });
expect(names(some)).toEqual(['real-project']);
});
it('identifies an unrelated repository above the workspace before its status runs, so a failure there cannot hide the repositories below', async () => {
repoAt(home);
const ws = join(home, 'case');
repoAt(join(ws, 'proj'));
const calls: Array<[string, string]> = [];
const spy: GitRunner = async (cwd, args) => {
calls.push([cwd, args[0]]);
if (cwd === ws && args[0] === 'status') throw Object.assign(new Error('timed out'), { killed: true });
return runGit(cwd, args);
};
const o = await getGitWorkspaceOverview(ws, { home, git: spy });
expect(o.state).toBe('ok');
expect(names(o)).toEqual(['proj']);
// The dotfiles repository costs one rev-parse, never a full status.
expect(calls.filter(([cwd]) => cwd === ws).map(([, verb]) => verb)).toEqual(['rev-parse']);
});
it('ignores a repository above the home folder (including a repo at the very top)', async () => {
repoAt(top); // contains `home`, so it is above it
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
});
it('but a workspace that IS the repository root is the session’s repository, even when that is the home folder', async () => {
repoAt(home);
const o = await getGitWorkspaceOverview(home, { home });
expect(o.state).toBe('ok');
expect(o.repos[0].path).toBe('.');
});
it('accepts an enclosing repository that is below the home folder, and one outside the home folder entirely', async () => {
const r = repoAt(join(home, 'proj'));
mkdir(join(r, 'sub'), { recursive: true });
expect((await getGitWorkspaceOverview(join(r, 'sub'), { home })).state).toBe('ok');
const elsewhere = repoAt(join(top, 'elsewhere', 'proj'));
clearGitStatusCache();
expect((await getGitWorkspaceOverview(elsewhere, { home })).state).toBe('ok');
});
it('skips node_modules, dot-folders and symbolic links when looking for repositories', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'node_modules', 'pkg'));
repoAt(join(ws, '.hidden', 'secret'));
const outside = repoAt(join(top, 'outside'));
mkdir(ws, { recursive: true });
symlink(outside, join(ws, 'linked'));
repoAt(join(ws, 'real'));
const o = await getGitWorkspaceOverview(ws, { home });
expect(names(o)).toEqual(['real']);
});
it('counts a worktree (its .git is a file) as a repository', async () => {
const main = repoAt(join(top, 'main-repo'));
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
git(main, 'worktree', 'add', '-q', '-b', 'feature', join(ws, 'wt'));
const o = await getGitWorkspaceOverview(ws, { home });
expect(names(o)).toEqual(['wt']);
expect(o.repos[0].status.branch).toBe('feature');
});
it('caps how many repositories it reports and says so', async () => {
const ws = join(home, 'case');
for (let i = 0; i < MAX_REPOS + 3; i++) repoAt(join(ws, `p${String(i).padStart(2, '0')}`));
const o = await getGitWorkspaceOverview(ws, { home });
expect(o.repos).toHaveLength(MAX_REPOS);
expect(o.reposTruncated).toBe(true);
expect(names(o)[0]).toBe('p00');
});
it('is not-a-repo when there is no repository here or below, and reports a git failure as an error', async () => {
const ws = join(home, 'empty');
mkdir(join(ws, 'a'), { recursive: true });
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
clearGitStatusCache(); // the answer above is cached for this folder
const broken: GitRunner = async () => {
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
};
expect(await getGitWorkspaceOverview(ws, { home, git: broken })).toMatchObject({ state: 'error', repos: [] });
});
it('re-scans for repositories only every so often, unless a person asked for fresh', async () => {
let t = 1_000_000;
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
repoAt(join(ws, 'one'));
const get = (fresh = false) => getGitWorkspaceOverview(ws, { home, now: () => t, fresh });
expect(names(await get())).toEqual(['one']);
repoAt(join(ws, 'two'));
t += 5000;
expect(names(await get())).toEqual(['one']); // list reused
expect(names(await get(true))).toEqual(['one', 'two']); // Refresh sees it
t += 60_000;
expect(names(await get())).toEqual(['one', 'two']); // and so does the next scan
});
it('discoverChildRepos never reads below a repository it found', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'outer'));
repoAt(join(ws, 'outer', 'inner'));
expect((await discoverChildRepos(ws)).dirs.map((d) => d.slice(ws.length + 1))).toEqual(['outer']);
});
it('isUnrelatedAncestor: home or above, but never the workspace root itself', async () => {
mkdir(join(home, 'proj'), { recursive: true });
expect(await isUnrelatedAncestor(home, join(home, 'proj'), home)).toBe(true);
expect(await isUnrelatedAncestor(top, join(home, 'proj'), home)).toBe(true);
expect(await isUnrelatedAncestor('/', join(home, 'proj'), home)).toBe(true);
expect(await isUnrelatedAncestor(join(home, 'proj'), join(home, 'proj', 'x'), home)).toBe(false);
expect(await isUnrelatedAncestor(home, home, home)).toBe(false);
});
});
import { MAX_DIFF_BYTES, getGitFileDiff, isSafeRepoRelativePath } from '../src/git-workspace-status.js';
describe('isSafeRepoRelativePath', () => {
it.each([
['a.txt', true],
['src/deep/x.ts', true],
['', false],
['-rf', true], // every operand follows `--`, so a leading dash is just a name
['-', true],
['/etc/passwd', false],
['../x', false],
['a/../../x', false],
['a\0b', false],
])('%j -> %s', (p, ok) => expect(isSafeRepoRelativePath(p)).toBe(ok));
});
describe('getGitFileDiff', () => {
it('refuses an unsafe path without running git', async () => {
const git = vi.fn(async () => '');
await expect(getGitFileDiff('/r', { path: '../x', kind: 'unstaged' }, { git })).rejects.toThrow('Invalid path');
expect(git).not.toHaveBeenCalled();
});
it('builds read-only, option-injection-safe commands per kind', async () => {
const git = vi.fn(async (_cwd: string, _args: string[]) => '');
await getGitFileDiff('/r', { path: 'a.txt', kind: 'unstaged' }, { git });
await getGitFileDiff('/r', { path: 'b.txt', origPath: 'old.txt', kind: 'staged' }, { git });
const [unstaged, staged] = git.mock.calls.map((c) => c[1]);
for (const args of [unstaged, staged]) {
expect(args).toContain('--no-ext-diff');
expect(args).toContain('--no-textconv');
expect(args.indexOf('--')).toBeGreaterThan(0);
}
expect(unstaged.slice(-2)).toEqual(['--', 'a.txt']);
expect(staged).toContain('--cached');
expect(staged.slice(-3)).toEqual(['--', 'old.txt', 'b.txt']);
});
it('treats --no-index exit 1 as the normal untracked result', async () => {
const git = vi.fn(async () => {
throw Object.assign(new Error('exit 1'), { code: 1, stdout: '+hello\n' });
});
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git })).resolves.toMatchObject({
diff: '+hello\n',
});
const boom = vi.fn(async () => {
throw Object.assign(new Error('exit 128'), { code: 128, stdout: '' });
});
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git: boom })).rejects.toThrow();
});
it('flags binary output and cuts an oversized diff at a line boundary', async () => {
const bin = await getGitFileDiff(
'/r',
{ path: 'x.png', kind: 'unstaged' },
{ git: async () => 'Binary files a/x.png and b/x.png differ\n' }
);
expect(bin.binary).toBe(true);
const big = ('+' + 'x'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) + 50);
const cut = await getGitFileDiff('/r', { path: 'big', kind: 'unstaged' }, { git: async () => big });
expect(cut.truncated).toBe(true);
expect(cut.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(cut.diff.endsWith('x')).toBe(true);
});
it('cuts a diff that overflowed the output bound instead of failing it', async () => {
const partial = ('+' + 'y'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) * 2);
const git = vi.fn(async () => {
throw Object.assign(new RangeError('stdout maxBuffer length exceeded'), {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout: partial,
});
});
for (const kind of ['unstaged', 'untracked'] as const) {
const d = await getGitFileDiff('/r', { path: 'huge.txt', kind }, { git });
expect(d.truncated).toBe(true);
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(d.diff.endsWith('y')).toBe(true);
}
});
it('a real untracked file past the output bound comes back cut short, not as an error', async () => {
writeFileSync(join(repo, 'huge.txt'), ('z'.repeat(99) + '\n').repeat(100_000)); // ~10 MB, over runGit's 8 MB
const d = await getGitFileDiff(repo, { path: 'huge.txt', kind: 'untracked' });
expect(d.truncated).toBe(true);
expect(d.diff.startsWith('diff --git')).toBe(true);
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
}, 30_000);
});
describe('Docker case workspaces are never inspected', () => {
let top: string;
let home: string;
const repoAt = (p: string): string => {
mkdir(p, { recursive: true });
git(p, 'init', '-q', '-b', 'main');
writeFileSync(join(p, 'f.txt'), '1\n');
git(p, 'add', '-A');
git(p, 'commit', '-q', '-m', 'c');
return p;
};
/** A repository whose clean filter drops a marker file: proof that git ran on it. */
const booby = (p: string): string => {
repoAt(p);
git(p, 'config', 'filter.mark.clean', 'touch RAN; cat');
writeFileSync(join(p, '.gitattributes'), 'f.txt filter=mark\n');
// Same size as the committed '1\n', so git must read the content (running the filter) to see the change.
writeFileSync(join(p, 'f.txt'), 'x\n');
return p;
};
beforeEach(() => {
top = mkdtempSync(join(tmpdir(), 'git-docker-'));
home = join(top, 'home');
mkdir(home, { recursive: true });
clearGitStatusCache();
});
afterEach(() => rmSync(top, { recursive: true, force: true }));
it('control: without the exclusion git does run the repository’s clean filter', async () => {
const ws = join(home, 'case');
booby(join(ws, 'proj'));
await getGitWorkspaceOverview(ws, { home, git: undefined });
expect(existsSync(join(ws, 'proj', 'RAN'))).toBe(true);
});
it('drops a Docker workspace found below the folder, and runs nothing in it', async () => {
const ws = join(home, 'case');
booby(join(ws, 'sandbox'));
repoAt(join(ws, 'plain'));
const o = await getGitWorkspaceOverview(ws, { home, dockerWorkspaces: [join(ws, 'sandbox')] });
expect(o.repos.map((r) => r.path)).toEqual(['plain']);
expect(existsSync(join(ws, 'sandbox', 'RAN'))).toBe(false);
});
it('answers unsupported/docker for a folder at or inside a Docker workspace, before any git runs', async () => {
const dock = booby(join(home, 'dock'));
mkdir(join(dock, 'sub'));
for (const cwd of [dock, join(dock, 'sub')]) {
clearGitStatusCache();
const git = vi.fn(async () => '');
const o = await getGitWorkspaceOverview(cwd, { home, git, dockerWorkspaces: [dock] });
expect(o).toMatchObject({ state: 'unsupported', reason: 'docker', repos: [] });
expect(git).not.toHaveBeenCalled();
}
expect(existsSync(join(dock, 'RAN'))).toBe(false);
});
it('re-checks a cached list of repositories against the Docker workspaces as they are now', async () => {
let t = 1_000_000;
const ws = join(home, 'case');
repoAt(join(ws, 'a'));
repoAt(join(ws, 'b'));
const cwds: string[] = [];
const spy: GitRunner = (cwd, args) => {
cwds.push(cwd);
return runGit(cwd, args);
};
const first = await getGitWorkspaceOverview(ws, { home, git: spy, now: () => t });
expect(first.repos.map((r) => r.path)).toEqual(['a', 'b']);
t += 5000; // past the status cache, well inside the list's 30 s
cwds.length = 0;
const o = await getGitWorkspaceOverview(ws, {
home,
git: spy,
now: () => t,
dockerWorkspaces: [join(ws, 'a')],
});
expect(o.repos.map((r) => r.path)).toEqual(['b']);
expect(cwds).not.toContain(join(ws, 'a'));
});
it('sees through a symlink to the workspace', async () => {
const dock = booby(join(home, 'dock'));
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
symlink(dock, join(ws, 'link'));
const o = await getGitWorkspaceOverview(join(ws, 'link'), { home, dockerWorkspaces: [dock] });
expect(o.state).toBe('unsupported');
expect(existsSync(join(dock, 'RAN'))).toBe(false);
});
it('a folder next to the workspace, and one whose name merely starts the same, are not excluded', async () => {
const dock = join(home, 'dock');
const sibling = repoAt(join(home, 'dock-two'));
mkdir(dock, { recursive: true });
const o = await getGitWorkspaceOverview(sibling, { home, dockerWorkspaces: [dock] });
expect(o.state).toBe('ok');
});
});
+22 -1
View File
@@ -5,7 +5,7 @@
* hook definitions for desktop notifications.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
import {
chmodSync,
closeSync,
@@ -26,6 +26,7 @@ import { spawn } from 'node:child_process';
import {
applyStatusLineConfig,
ensureCodemanHooks,
ensureStatusLineExporterScript,
findEffectiveUserStatusLineCommand,
generateBackgroundWakeScript,
generateHooksConfig,
@@ -1418,6 +1419,26 @@ describe('resolveStatusLineCliCommand', () => {
expect(siblings).toEqual([]);
});
it('survives concurrent refreshes in the same millisecond (sessions created at once)', async () => {
const scriptPath = (await resolveStatusLineCliCommand(testDir, true))!;
writeFileSync(scriptPath, '#!/bin/sh\n# CODEMAN_STATUSLINE_EXPORTER_V0\necho stale\n');
// A frozen clock makes every writer agree on the timestamp, which is what two
// session creates in one millisecond do. A temp name built from pid + Date.now()
// is then shared: the first rename consumes it, every later rename fails ENOENT,
// and that session's tmux create fell back to a direct PTY.
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
try {
const paths = await Promise.all(Array.from({ length: 8 }, () => ensureStatusLineExporterScript()));
expect(new Set(paths)).toEqual(new Set([scriptPath]));
} finally {
clock.mockRestore();
}
expect(readFileSync(scriptPath, 'utf-8')).not.toContain('echo stale');
expect(statSync(scriptPath).mode & 0o111).not.toBe(0);
const siblings = readdirSync(join(scriptPath, '..')).filter((f) => f.startsWith('statusline-exporter.sh.'));
expect(siblings).toEqual([]);
});
it('never overrides a real, hand-authored statusLine', async () => {
const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true });
+12
View File
@@ -91,6 +91,18 @@ describe('custom display name and browser localization', () => {
dom.window.close();
});
it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => {
const dom = makeDom('');
const api = dom.window.CodemanI18n;
api.configure({ language: 'zh-CN' });
const labels = ['Move to "New group"', 'Move to new group', 'Move to "ungrouped"', 'Move to Ungrouped'].map((label) =>
api.t(label)
);
expect(labels).toEqual(['移到“New group”', '移到新分组', '移到“ungrouped”', '移到未分组']);
expect(new Set(labels).size).toBe(4);
dom.window.close();
});
it('renders hostile-looking names as text rather than HTML', () => {
const dom = makeDom('<span class="logo">Codeman</span>');
const api = dom.window.CodemanI18n;
+471 -7
View File
@@ -13,7 +13,8 @@
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
*
* Port: 3164 (per MEMORY.md, ports 3150+ for tests)
* Ports: 3164, plus 3165 and 3192 for the two server-backed describes below
* (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -21,6 +22,8 @@ import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3164;
const ORDERING_PORT = 3165;
const LONG_PREFIX_PORT = 3192;
const BASE_URL = `http://localhost:${PORT}`;
describe('Inline rename input', () => {
@@ -581,11 +584,20 @@ describe('Inline rename input', () => {
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
});
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
// The rail has two row variants, and the detailed one clamps the name to
// three lines instead of two: the editor must come out unclamped in both,
// and cancelling must put back the clamp of the variant it was opened in.
it.each([
{ detail: 'simple', restoredClamp: '2' },
{ detail: 'rich', restoredClamp: '3' },
])(
'Vertical rail ($detail rows) paints typing in an unclamped editor and restores the clamp on cancel',
async ({ detail, restoredClamp }) => {
await resetState();
const id = 'vertical-live-input';
const id = `vertical-live-input-${detail}`;
await page.evaluate((sessionId) => {
await page.evaluate(
({ sessionId, detail }) => {
const app = (
window as unknown as {
app: {
@@ -595,6 +607,7 @@ describe('Inline rename input', () => {
}
).app;
document.documentElement.dataset.tabOrientation = 'vertical';
document.documentElement.dataset.tabRailDetail = detail;
const rail = document.getElementById('tabRail') as HTMLElement;
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
@@ -605,7 +618,9 @@ describe('Inline rename input', () => {
rail.appendChild(tab);
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
app.startInlineRename(sessionId);
}, id);
},
{ sessionId: id, detail }
);
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
const input = label.locator('input.tab-rename-input');
@@ -622,13 +637,462 @@ describe('Inline rename input', () => {
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
app._activeRename?.cancel();
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
return {
const result = {
classActive: label.classList.contains('tab-name-renaming'),
inputPresent: !!label.querySelector('input.tab-rename-input'),
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
};
document.documentElement.dataset.tabOrientation = 'horizontal';
return result;
}, id);
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: restoredClamp });
}
);
});
/**
* Two renames of one session can be in flight at once: commit, reopen the
* editor before the PUT answers, then commit or cancel again (or start a group
* rename, which cancels the session editor). The writes go out one at a time
* in the order they were made, and a confirmed write is applied locally even
* if the editor that made it has since been cancelled, so the tab never shows
* a name the server no longer holds.
*/
describe('Inline rename write ordering', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
type Pending = { body: string; resolve: (response: Response) => void };
beforeAll(async () => {
server = new WebServer(ORDERING_PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
typeof (window as { app?: unknown }).app !== 'undefined' &&
!!(window as { app?: { sessions?: Map<string, unknown> } }).app?.sessions
);
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** Mount a header-strip row for `id`, hold every PUT open, and open its editor. */
async function mount(id: string, name: string): Promise<void> {
await page.evaluate(
({ id, name }) => {
const w = window as unknown as {
app: {
_activeRename: { cancel: () => void } | null;
sessions: Map<string, { id: string; name: string; status: string }>;
sessionOrder: string[];
};
__pending: Array<{ body: string; resolve: (response: Response) => void }>;
__origFetch?: typeof window.fetch;
};
w.app._activeRename?.cancel();
w.app.sessions.clear();
document.querySelectorAll('[data-test-tab]').forEach((n) => n.remove());
w.app.sessions.set(id, { id, name, status: 'idle' });
w.app.sessionOrder = [id];
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.dataset.id = id;
tab.innerHTML = `<span class="tab-info"><span class="tab-name" data-session-id="${id}">${name}</span></span>`;
(document.getElementById('sessionTabs') as HTMLElement).appendChild(tab);
w.__pending = [];
w.__origFetch ??= window.fetch;
const passThrough = w.__origFetch;
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method !== 'PUT' || !String(input).endsWith('/name')) return passThrough(input, init);
return new Promise<Response>((resolve) => {
w.__pending.push({ body: String(init?.body ?? ''), resolve });
});
}) as typeof window.fetch;
},
{ id, name }
);
}
async function restoreFetch(): Promise<void> {
await page.evaluate(() => {
const w = window as unknown as { __origFetch?: typeof window.fetch };
if (w.__origFetch) window.fetch = w.__origFetch;
});
}
async function commit(id: string, value: string | null): Promise<void> {
await page.evaluate(
async ({ id, value }) => {
const app = (window as unknown as { app: { startInlineRename: (id: string) => void } }).app;
if (!document.querySelector(`.tab-name[data-session-id="${id}"] input.tab-rename-input`)) {
app.startInlineRename(id);
}
const input = document.querySelector(
`.tab-name[data-session-id="${id}"] input.tab-rename-input`
) as HTMLInputElement;
if (value !== null) input.value = value;
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
},
{ id, value }
);
}
async function answer(index: number, name: string): Promise<void> {
await page.evaluate(
async ({ index, body }) => {
const w = window as unknown as { __pending: Pending[] };
w.__pending[index]?.resolve(
new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } })
);
await new Promise((resolve) => setTimeout(resolve, 30));
},
{ index, body: JSON.stringify({ success: true, data: { name } }) }
);
}
async function state(id: string) {
return page.evaluate((id) => {
const w = window as unknown as {
app: { sessions: Map<string, { name: string }>; _activeRename: unknown };
__pending: Pending[];
};
return {
bodies: w.__pending.map(({ body }) => JSON.parse(body).name),
mapName: w.app.sessions.get(id)?.name ?? null,
renameActive: !!w.app._activeRename,
};
}, id);
}
it('sends successive renames of one session one at a time, in the order they were made', async () => {
await mount('order', 'Old');
await commit('order', 'First');
await commit('order', 'Second');
expect((await state('order')).bodies).toEqual(['First']);
await answer(0, 'First');
expect((await state('order')).bodies).toEqual(['First', 'Second']);
await answer(1, 'Second');
await restoreFetch();
expect(await state('order')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false });
});
it('keeps a confirmed rename when the editor reopened over it is cancelled', async () => {
await mount('reopen', 'Old');
await commit('reopen', 'First');
await page.evaluate(() => {
const app = (
window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } }
).app;
app.startInlineRename('reopen');
app._activeRename.cancel();
});
await answer(0, 'First');
await restoreFetch();
expect(await state('reopen')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
});
it('reopens the editor on the name still in flight, so confirming it unchanged keeps the rename', async () => {
await mount('stale', 'Old');
await commit('stale', 'First');
// The PUT for "First" has not answered, so app.sessions still says "Old".
// The reopened editor must show "First", the user's last word, and an
// untouched confirm must not queue "Old" behind it.
const reopenedValue = await page.evaluate(() => {
(window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('stale');
return (document.querySelector('.tab-name[data-session-id="stale"] input.tab-rename-input') as HTMLInputElement)
.value;
});
expect(reopenedValue).toBe('First');
await commit('stale', null);
await answer(0, 'First');
await restoreFetch();
expect(await state('stale')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
expect(
await page.evaluate(
() =>
(window as unknown as { app: { _inlineRenamePending?: Map<string, string> } }).app._inlineRenamePending?.has(
'stale'
) ?? false
)
).toBe(false);
});
it('shows the in-flight name when a reopened editor is confirmed unchanged, before the PUT lands', async () => {
await mount('shown', 'Old');
await commit('shown', 'First');
// Reopen while the PUT for "First" is held, then confirm it untouched. The
// label must read "First" now, not the "Old" the cancelled editor
// repainted from app.sessions.
await page.evaluate(() =>
(window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('shown')
);
await commit('shown', null);
const label = await page.evaluate(
() => (document.querySelector('.tab-name[data-session-id="shown"]') as HTMLElement).textContent
);
expect((await state('shown')).bodies).toEqual(['First']);
expect(label).toBe('First');
await answer(0, 'First');
await restoreFetch();
expect(await state('shown')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
});
it('reports a failed write even after its editor is gone', async () => {
await mount('fail-late', 'Old');
await page.evaluate(() => {
const w = window as unknown as {
app: { showToast: (message: string, type?: string) => void };
__toasts: string[];
__origToast?: (message: string, type?: string) => void;
};
w.__toasts = [];
w.__origToast = w.app.showToast;
w.app.showToast = (message: string) => {
w.__toasts.push(message);
};
});
await commit('fail-late', 'First');
// Reopen and dismiss: the editor that made the write is gone.
await page.evaluate(() => {
const app = (
window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } }
).app;
app.startInlineRename('fail-late');
app._activeRename.cancel();
});
await page.evaluate(async () => {
const w = window as unknown as { __pending: Pending[] };
w.__pending[0]?.resolve(
new Response(JSON.stringify({ success: false, error: 'boom' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
);
await new Promise((resolve) => setTimeout(resolve, 30));
});
await restoreFetch();
const toasts = await page.evaluate(() => {
const w = window as unknown as {
app: { showToast: unknown };
__toasts: string[];
__origToast?: unknown;
};
w.app.showToast = w.__origToast;
return w.__toasts;
});
expect(toasts).toEqual(['Failed to rename']);
expect(await state('fail-late')).toEqual({ bodies: ['First'], mapName: 'Old', renameActive: false });
});
it('keeps sending a session renames after the work following a PUT throws', async () => {
await mount('throws', 'Old');
await page.evaluate(() => {
const w = window as unknown as {
app: { updateSubagentParentNames?: (id: string) => void };
__origParentNames?: (id: string) => void;
__throwOnce: boolean;
};
w.__origParentNames = w.app.updateSubagentParentNames;
w.__throwOnce = true;
w.app.updateSubagentParentNames = (id: string) => {
if (w.__throwOnce) {
w.__throwOnce = false;
throw new Error('forced');
}
w.__origParentNames?.call(w.app, id);
};
window.addEventListener('unhandledrejection', (event) => event.preventDefault(), { once: true });
});
await commit('throws', 'First');
await answer(0, 'First');
await commit('throws', 'Second');
await answer(1, 'Second');
await restoreFetch();
const leftover = await page.evaluate(() => {
const w = window as unknown as {
app: { updateSubagentParentNames?: unknown; _inlineRenameWrites?: Map<string, unknown> };
__origParentNames?: unknown;
};
w.app.updateSubagentParentNames = w.__origParentNames;
return w.app._inlineRenameWrites?.has('throws') ?? false;
});
expect(await state('throws')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false });
expect(leftover).toBe(false);
});
it('lets the header strip editor shrink (inline min-width 0)', async () => {
await mount('header-width', 'Old');
const minWidth = await page.evaluate(() => {
const app = (
window as unknown as {
app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } | null };
}
).app;
app.startInlineRename('header-width');
const input = document.querySelector(
'.tab-name[data-session-id="header-width"] input.tab-rename-input'
) as HTMLInputElement;
const value = input.style.minWidth;
app._activeRename?.cancel();
return value;
});
await restoreFetch();
expect(minWidth).toBe('0px');
});
it('keeps a confirmed session rename when a group rename takes over the editor', async () => {
await mount('to-group', 'Old');
await commit('to-group', 'Saved');
const groupStarted = await page.evaluate(() => {
const app = (
window as unknown as {
app: {
tabLayout: unknown;
startTabGroupRename: (groupId: string) => boolean;
};
}
).app;
const section = document.createElement('section');
section.setAttribute('data-test-tab', '1');
section.innerHTML =
'<div class="tab-layout-group-header" data-tab-group-header="g1">' +
'<span class="tab-layout-group-name">Group</span></div>';
(document.getElementById('sessionTabs') as HTMLElement).appendChild(section);
(window as unknown as { __origLayout: unknown }).__origLayout = app.tabLayout;
app.tabLayout = { version: 1, groups: [{ id: 'g1', name: 'Group', refs: [] }], ungrouped: [] };
return app.startTabGroupRename('g1');
});
expect(groupStarted).toBe(true);
await answer(0, 'Saved');
const after = await page.evaluate(() => {
const w = window as unknown as {
app: {
sessions: Map<string, { name: string }>;
_inlineRenameActive: boolean;
_activeRename: { cancel: () => void } | null;
tabLayout: unknown;
};
__origLayout: unknown;
};
const groupInput = document.querySelector('.tab-layout-group-rename-input');
const result = {
mapName: w.app.sessions.get('to-group')?.name ?? null,
groupEditorOpen: !!groupInput?.isConnected,
guardHeld: w.app._inlineRenameActive,
};
w.app._activeRename?.cancel();
w.app.tabLayout = w.__origLayout;
return result;
});
await restoreFetch();
expect(after).toEqual({ mapName: 'Saved', groupEditorOpen: true, guardHeld: true });
});
});
/**
* Real rows, rendered by the app from a live session: a long `w<n>-<case>`
* prefix must not push the editor (or the prefix itself) out of the row in any
* rail variant. The prefix gives way first, with an ellipsis, and the input
* always keeps a usable width.
*/
describe('Vertical rail rename editor with a long prefix', () => {
let server: WebServer;
let browser: Browser;
const port = LONG_PREFIX_PORT;
const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie';
let sessionId = '';
beforeAll(async () => {
server = new WebServer(port, false, true);
await server.start();
const res = await fetch(`http://localhost:${port}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: NAME, mode: 'shell' }),
});
expect(res.ok).toBe(true);
const created = (await res.json()) as { data?: { id?: string; session?: { id?: string } } };
sessionId = created.data?.session?.id ?? created.data?.id ?? '';
expect(sessionId).not.toBe('');
browser = await chromium.launch({ headless: true });
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
it.each([
{ variant: 'simple rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'simple' }, compact: false },
{ variant: 'detailed rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'rich' }, compact: false },
{ variant: 'compact rail', settings: { tabOrientation: 'vertical', tabRailWidth: 208 }, compact: true },
{ variant: 'sidebar', settings: { sessionListLayout: 'sidebar' }, compact: false },
{ variant: 'detailed sidebar', settings: { sessionListLayout: 'sidebar-rich' }, compact: false },
])('keeps the prefix and a usable input inside the row ($variant)', async ({ settings, compact }) => {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
try {
await context.addInitScript(
(value) => localStorage.setItem('codeman-app-settings', JSON.stringify(value)),
settings
);
const page = await context.newPage();
await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
// One #sessionTabs list, moved into the rail or the sidebar by the layout.
const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`);
await row.waitFor({ state: 'visible', timeout: 15000 });
expect(await page.evaluate(() => document.documentElement.classList.contains('tab-rail-compact'))).toBe(compact);
await row.click({ button: 'right' });
const input = row.locator('input.tab-rename-input');
await input.press('Control+A');
await page.keyboard.type('typed live text');
expect(await input.inputValue()).toBe('typed live text');
const geometry = await row.evaluate((tab) => {
const box = (el: Element) => el.getBoundingClientRect();
const within = (inner: DOMRect, outer: DOMRect) =>
inner.left >= outer.left - 0.5 &&
inner.right <= outer.right + 0.5 &&
inner.top >= outer.top - 0.5 &&
inner.bottom <= outer.bottom + 0.5;
const input = tab.querySelector('input.tab-rename-input') as HTMLInputElement;
const prefix = tab.querySelector('.tab-rename-prefix') as HTMLElement;
const info = tab.querySelector('.tab-info') as HTMLElement;
return {
focused: document.activeElement === input,
inputWidth: box(input).width,
prefixWidth: box(prefix).width,
inputInsideRow: within(box(input), box(info)),
prefixInsideRow: within(box(prefix), box(info)),
prefixEllipsis: getComputedStyle(prefix).textOverflow,
inlineMinWidth: input.style.minWidth,
};
});
expect(geometry.focused).toBe(true);
expect(geometry.inputWidth).toBeGreaterThanOrEqual(64);
expect(geometry.prefixWidth).toBeGreaterThanOrEqual(24);
expect(geometry.inputInsideRow).toBe(true);
expect(geometry.prefixInsideRow).toBe(true);
expect(geometry.prefixEllipsis).toBe('ellipsis');
// The floor is the editor's own inline style, not a stylesheet override.
expect(geometry.inlineMinWidth).toBe('4rem');
await input.press('Escape');
expect(await row.locator('input.tab-rename-input').count()).toBe(0);
} finally {
await context.close();
}
});
});
+117
View File
@@ -0,0 +1,117 @@
/** @fileoverview Settings → Terminal & Input → Key tester, driven with real keystrokes in Chromium. */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3194;
describe('Key tester in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
await page.focus('#keyTesterInput');
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
const log = () => page.evaluate(() => document.getElementById('keyTesterLog')!.textContent ?? '');
it('shows keydown, keypress and keyup for Shift+Enter, with the modifier and charCode', async () => {
await page.keyboard.press('Shift+Enter');
const text = await log();
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=shift/);
// The keypress is the event that used to leak a bare \r to the PTY.
expect(text).toMatch(/keypress\s+key="Enter" code=Enter mods=shift charCode=13/);
expect(text).toMatch(/keyup\s+key="Enter" code=Enter mods=shift/);
});
it('shows Ctrl+Enter without a keypress, as xterm would never see one for Ctrl', async () => {
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
await page.keyboard.press('Control+Enter');
const text = await log();
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=ctrl/);
expect(text).toMatch(/keyup/);
// Chromium emits no keypress for a Ctrl chord, which is why only Shift+Enter ever leaked a \r.
expect(text).not.toMatch(/keypress/);
});
it('lets no app shortcut fire for keys pressed in the field (Ctrl+W, Ctrl+L, Escape, Alt+1, Ctrl+K)', async () => {
// The shortcut dispatcher is a capture-phase document listener, so without a guard it ran before
// the field's own handler: Ctrl+W killed the active session, Ctrl+L cleared the terminal and
// Escape closed Settings, while this row says nothing is sent to a session.
await page.evaluate(() => {
const app = (window as any).app;
const calls: string[] = [];
(window as any).__calls = calls;
for (const name of ['killActiveSession', 'clearTerminal', 'openCommandPalette', 'closeAllPanels']) {
app[name] = (...args: unknown[]) => void calls.push(name + args.length);
}
});
await page.focus('#keyTesterInput');
// [chord, what the tester must report for it]; checked one at a time because the log keeps 14 lines.
const chords: [string, RegExp][] = [
['Control+W', /key="w" code=KeyW mods=ctrl/i],
['Control+L', /key="l" code=KeyL mods=ctrl/i],
['Escape', /key="Escape" code=Escape/],
['Alt+1', /code=Digit1 mods=alt/],
['Control+K', /key="k" code=KeyK mods=ctrl/i],
];
for (const [chord, seen] of chords) {
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
await page.keyboard.press(chord);
expect(await log(), chord).toMatch(seen);
expect(await page.evaluate(() => (window as any).__calls), chord).toEqual([]);
}
expect(await page.evaluate(() => document.getElementById('appSettingsModal')!.classList.contains('active'))).toBe(
true
);
});
it('still lets the shortcut fire anywhere else (the guard is scoped to data-raw-keys)', async () => {
await page.evaluate(() => {
(window as any).__calls.length = 0;
(document.activeElement as HTMLElement | null)?.blur();
});
await page.keyboard.press('Escape');
expect(await page.evaluate(() => (window as any).__calls)).toContain('closeAllPanels0');
});
it('keeps only the last 14 lines and never types into the field', async () => {
// The test above blurred the field, so focus it again: without this the presses land on
// <body>, the log keeps whatever the earlier tests left, and the cap is never exercised.
await page.focus('#keyTesterInput');
expect(await page.evaluate(() => document.activeElement?.id)).toBe('keyTesterInput');
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
const lines = async () => (await log()).split('\n');
// A printable key fires keydown, keypress and keyup, so 2 presses are 6 lines: under the cap
// the log accumulates rather than showing only the latest event.
for (let i = 0; i < 2; i++) await page.keyboard.press('a');
expect(await lines()).toHaveLength(6);
// A different key, so eviction is visible: 4 x 3 = 12 more lines makes 18, capped to 14. The
// oldest 4 go (all of the first 'a' press and the second one's keydown), the newest stay in order.
for (let i = 0; i < 4; i++) await page.keyboard.press('b');
const capped = await lines();
expect(capped).toHaveLength(14);
expect(capped.filter((l) => l.includes('key="a"'))).toHaveLength(2);
expect(capped.filter((l) => l.includes('key="b"'))).toHaveLength(12);
expect(capped[0]).toMatch(/^keypress\s+key="a" code=KeyA mods=none charCode=97$/);
expect(capped[13]).toMatch(/^keyup\s+key="b" code=KeyB mods=none$/);
// Readonly: none of those presses typed anything into the field itself.
expect(await page.inputValue('#keyTesterInput')).toBe('');
});
});
+76
View File
@@ -0,0 +1,76 @@
// @vitest-environment node
// The registry half of MCP sync: which CLIs declare an MCP config file, and that the schema
// guards the path (sync writes to it) so a user clis.json cannot aim a write outside $HOME.
import { describe, expect, it } from 'vitest';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
function withMcp(mcpConfig: unknown) {
const e = claude();
(e.capabilities as Record<string, unknown>).mcpConfig = mcpConfig;
return CliEntrySchema.safeParse(e);
}
describe('capabilities.mcpConfig', () => {
it('is declared by exactly the CLIs whose format is verified', () => {
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']);
});
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
for (const e of STOCK_CLIS) expect(CliEntrySchema.safeParse(e).success, e.id as string).toBe(true);
const paths = STOCK_CLIS.flatMap((e) => (e.capabilities.mcpConfig ? [e.capabilities.mcpConfig.path] : []));
expect(new Set(paths).size).toBe(paths.length);
});
it('accepts a home-relative path with a known format', () => {
expect(withMcp({ path: '.tool/mcp.json', format: 'claude-json' }).success).toBe(true);
});
it("declares each CLI's own relocation env var, and none for antigravity (HOME only)", () => {
const reloc = Object.fromEntries(
STOCK_CLIS.flatMap((e) =>
e.capabilities.mcpConfig ? [[e.id as string, e.capabilities.mcpConfig.relocation]] : []
)
);
expect(reloc).toEqual({
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
antigravity: undefined,
});
});
it('accepts a relocation with an env var name and a relative path', () => {
const value = { path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: 'mcp.json' } };
expect(withMcp(value).success).toBe(true);
});
it.each([
['parent traversal', { path: '../evil.json', format: 'claude-json' }],
['nested traversal', { path: '.a/../../evil.json', format: 'claude-json' }],
['absolute path', { path: '/etc/cron.d/x', format: 'claude-json' }],
['shell metacharacters', { path: '.a;rm -rf', format: 'claude-json' }],
['unknown format', { path: '.a/mcp.json', format: 'yaml' }],
['extra key', { path: '.a/mcp.json', format: 'claude-json', mode: 'rw' }],
[
'relocation path traversal',
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '../x.json' } },
],
[
'relocation absolute path',
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '/etc/x.json' } },
],
[
'relocation env var that is not a name',
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'a-home', path: 'x.json' } },
],
])('rejects %s', (_label, value) => {
expect(withMcp(value).success).toBe(false);
});
});
+587
View File
@@ -0,0 +1,587 @@
// @vitest-environment node
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parse as parseToml } from 'smol-toml';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { addServers, McpSyncBusyError, parseServers, syncMcpServers, type McpSyncTarget } from '../src/mcp-sync.js';
const TARGETS: McpSyncTarget[] = [
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json', installed: true },
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json', installed: true },
{ id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml', installed: true },
{
id: 'antigravity',
label: 'Antigravity',
path: '.gemini/config/mcp_config.json',
format: 'antigravity-json',
installed: true,
},
{
id: 'opencode',
label: 'OpenCode',
path: '.config/opencode/opencode.json',
format: 'opencode-json',
installed: true,
},
];
let home: string;
const put = (rel: string, text: string) => {
const file = join(home, rel);
mkdirSync(join(file, '..'), { recursive: true });
writeFileSync(file, text);
};
const get = (rel: string) => readFileSync(join(home, rel), 'utf8');
const target = (id: string, patch: Partial<McpSyncTarget> = {}) => ({
...TARGETS.find((t) => t.id === id)!,
...patch,
});
const only = (...ids: string[]) => TARGETS.filter((t) => ids.includes(t.id));
const result = (r: Awaited<ReturnType<typeof syncMcpServers>>, id: string) => r.targets.find((t) => t.id === id)!;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mcp-sync-'));
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
describe('dialect parsing', () => {
it('reads codex TOML tables, inline tables and multi-line arrays', () => {
const servers = parseServers(
'codex-toml',
[
'model = "gpt-5"',
'',
'[mcp_servers.fs]',
'command = "npx"',
'args = [',
' "-y", # comment',
' "@mcp/fs",',
']',
'env = { TOKEN = "abc" }',
'',
'[mcp_servers."a.b".env]',
'K = "v"',
'',
'[mcp_servers."a.b"]',
'command = "x"',
'',
'[mcp_servers.web]',
'url = "https://x.test/mcp"',
'[mcp_servers.web.http_headers]',
'Authorization = "Bearer t"',
].join('\n')
);
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { TOKEN: 'abc' } });
expect(servers['a.b']).toEqual({ transport: 'stdio', command: 'x', env: { K: 'v' } });
expect(servers.web).toEqual({
transport: 'http',
url: 'https://x.test/mcp',
headers: { Authorization: 'Bearer t' },
});
});
it('reads CRLF codex files (the old offset math saw no servers at all)', () => {
const servers = parseServers('codex-toml', '[mcp_servers.fs]\r\ncommand = "npx"\r\nargs = ["-y"]\r\n');
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y'] });
});
it('reads gemini url (sse) vs httpUrl / type http, and opencode local/remote', () => {
const g = parseServers(
'gemini-json',
JSON.stringify({
mcpServers: {
a: { url: 'https://a' },
b: { httpUrl: 'https://b' },
c: { command: 'c', args: ['1'] },
d: { url: 'https://d', type: 'http' },
},
})
);
expect(g.a.transport).toBe('sse');
expect(g.b.transport).toBe('http');
expect(g.c).toEqual({ transport: 'stdio', command: 'c', args: ['1'] });
expect(g.d.transport).toBe('http');
const o = parseServers(
'opencode-json',
JSON.stringify({
mcp: {
l: { type: 'local', command: ['npx', '-y', 'x'], environment: { A: '1' } },
r: { type: 'remote', url: 'https://r' },
},
})
);
expect(o.l).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'x'], env: { A: '1' } });
expect(o.r).toEqual({ transport: 'http', url: 'https://r' });
});
it('throws on unparseable files so they are never written', () => {
expect(() => parseServers('opencode-json', '{ // jsonc\n}')).toThrow();
expect(() => parseServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\ncommand="y"\n')).toThrow();
});
});
describe('real CLI output (captured from `agy`/`gemini`/`codex mcp add`)', () => {
it('reads and writes the antigravity dialect', () => {
const real = JSON.stringify({
mcpServers: {
fs: { args: ['-y', '@mcp/fs'], command: 'npx', disabled: false, env: { K: 'v' } },
web: { disabled: false, headers: { Authorization: 'Bearer T' }, serverUrl: 'https://x.test/mcp' },
},
});
const servers = parseServers('antigravity-json', real);
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
expect(servers.web).toEqual({
transport: 'http',
url: 'https://x.test/mcp',
headers: { Authorization: 'Bearer T' },
});
const out = JSON.parse(
addServers('antigravity-json', null, { ...servers, s: { transport: 'sse', url: 'https://s' } })
);
expect(out.mcpServers.web.serverUrl).toBe('https://x.test/mcp');
expect(out.mcpServers.fs.disabled).toBe(false);
expect(out.mcpServers.s).toBeUndefined();
});
it('writes gemini http/sse as url + type, as `gemini mcp add` does', () => {
const out = JSON.parse(
addServers('gemini-json', null, {
web: { transport: 'http', url: 'https://x.test/mcp', headers: { A: 'b' } },
s: { transport: 'sse', url: 'https://x.test/sse' },
})
);
expect(out.mcpServers.web).toEqual({ url: 'https://x.test/mcp', type: 'http', headers: { A: 'b' } });
expect(out.mcpServers.s).toEqual({ url: 'https://x.test/sse', type: 'sse' });
expect(parseServers('gemini-json', JSON.stringify(out)).web.transport).toBe('http');
});
it('reads codex output as written by `codex mcp add`', () => {
const real =
'[mcp_servers.fs]\ncommand = "npx"\nargs = ["-y", "@mcp/fs"]\n\n[mcp_servers.fs.env]\nK = "v"\n\n[mcp_servers.web]\nurl = "https://x.test/mcp"\n';
const servers = parseServers('codex-toml', real);
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
expect(servers.web).toEqual({ transport: 'http', url: 'https://x.test/mcp' });
});
});
describe('hostile config files', () => {
it('never lets a server name or a sub-table key reach Object.prototype', () => {
const toml = parseServers(
'codex-toml',
[
'[mcp_servers.__proto__]',
'command = "x"',
'polluted = "yes"',
'[mcp_servers.fs.__proto__]',
'polluted = "yes"',
'[mcp_servers.fs]',
'command = "y"',
'[mcp_servers.toString]',
'command = "t"',
'call = "x"',
].join('\n')
);
expect(Object.keys(toml).sort()).toEqual(['fs', 'toString']);
const json = parseServers(
'claude-json',
'{"mcpServers":{"__proto__":{"command":"x"},"constructor":{"command":"x"},"hasOwnProperty":{"command":"h"},"ok":{"command":"y"}}}'
);
expect(Object.keys(json).sort()).toEqual(['hasOwnProperty', 'ok']);
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
expect(({} as Record<string, unknown>).command).toBeUndefined();
expect(typeof Object.prototype.toString.call).toBe('function');
});
it('treats servers named like Object.prototype members as ordinary names across CLIs', async () => {
put(
'.claude.json',
JSON.stringify({ mcpServers: { toString: { command: 'a' }, hasOwnProperty: { command: 'b' } } })
);
put('.gemini/settings.json', JSON.stringify({ mcpServers: {} }));
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(r.conflicts).toEqual([]);
expect(result(r, 'gemini').added.sort()).toEqual(['hasOwnProperty', 'toString']);
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual([
'hasOwnProperty',
'toString',
]);
});
it('rejects a non-object server table instead of overwriting it', () => {
expect(() => parseServers('claude-json', '{"mcpServers":[]}')).toThrow();
expect(() => parseServers('claude-json', '[]')).toThrow();
});
});
describe('addServers', () => {
it('preserves other keys and existing servers', () => {
const out = JSON.parse(
addServers('claude-json', JSON.stringify({ theme: 'dark', mcpServers: { keep: { command: 'k' } } }), {
keep: { transport: 'stdio', command: 'OVERWRITE' },
n: { transport: 'stdio', command: 'n' },
})
);
expect(out.theme).toBe('dark');
expect(out.mcpServers.keep).toEqual({ command: 'k' });
expect(out.mcpServers.n.command).toBe('n');
});
it('appends codex tables without touching the rest, and quotes odd names', () => {
const toml = addServers('codex-toml', 'model = "x"\n', {
'we ird': { transport: 'stdio', command: 'c', args: ['a"b'], env: { K: 'v' } },
});
expect(toml.startsWith('model = "x"\n')).toBe(true);
expect(parseServers('codex-toml', toml)['we ird']).toEqual({
transport: 'stdio',
command: 'c',
args: ['a"b'],
env: { K: 'v' },
});
});
it.each([
['CRLF line endings', '[mcp_servers.fs]\r\ncommand = "npx"\r\n'],
['an [mcp_servers] table with inline tables', '[mcp_servers]\nfs = { command = "npx" }\n'],
['a table with neither command nor url', '[mcp_servers.fs]\nstartup_timeout_sec = 30\n'],
])('never appends a second [mcp_servers.fs] to a codex file with %s', (_label, existing) => {
const out = addServers('codex-toml', existing, {
fs: { transport: 'stdio', command: 'other' },
extra: { transport: 'stdio', command: 'e' },
});
// Still valid TOML (a duplicate header would throw), fs untouched, extra added.
const doc = parseToml(out) as { mcp_servers: Record<string, Record<string, unknown>> };
expect(Object.keys(doc.mcp_servers).sort()).toEqual(['extra', 'fs']);
expect(doc.mcp_servers.fs.command === 'other').toBe(false);
expect(out.startsWith(existing)).toBe(true);
if (existing.includes('\r\n')) expect(out.replace(/\r\n/g, '')).not.toContain('\n');
});
it('refuses to write when the result would not read back as intended', () => {
// A name TOML cannot carry as a bare key still round-trips (quoted); a duplicate cannot.
expect(() => addServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\n', {})).toThrow();
});
});
describe('disabled servers are not propagated', () => {
it('codex enabled=false, opencode enabled:false and antigravity disabled:true stay where they are', async () => {
put('.codex/config.toml', '[mcp_servers.off_codex]\ncommand = "a"\nenabled = false\n');
put(
'.config/opencode/opencode.json',
JSON.stringify({ mcp: { off_oc: { type: 'local', command: ['b'], enabled: false } } })
);
put(
'.gemini/config/mcp_config.json',
JSON.stringify({ mcpServers: { off_agy: { command: 'c', disabled: true } } })
);
put('.claude.json', JSON.stringify({ mcpServers: { live: { type: 'stdio', command: 'l' } } }));
mkdirSync(join(home, '.gemini'), { recursive: true });
put('.gemini/settings.json', '{}');
const r = await syncMcpServers(TARGETS, { apply: true, home });
expect(r.disabled).toEqual(['off_agy', 'off_codex', 'off_oc']);
expect(Object.keys(JSON.parse(get('.claude.json')).mcpServers)).toEqual(['live']);
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers)).toEqual(['live']);
// ...and each CLI still gets the live one.
expect(get('.codex/config.toml')).toContain('[mcp_servers.live]');
expect(get('.codex/config.toml')).not.toContain('off_oc');
// The switched-off entry itself is left as it was (still disabled).
expect(get('.codex/config.toml')).toContain('enabled = false');
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.off_oc.enabled).toBe(false);
expect(JSON.parse(get('.gemini/config/mcp_config.json')).mcpServers.off_agy.disabled).toBe(true);
});
it('a name switched off in one CLI and live in another is still synced from the live one', async () => {
put('.codex/config.toml', '[mcp_servers.fs]\ncommand = "a"\nenabled = false\n');
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'a' } } }));
put('.gemini/settings.json', '{}');
const r = await syncMcpServers(only('claude', 'codex', 'gemini'), { apply: true, home });
expect(r.disabled).toEqual([]);
expect(result(r, 'gemini').added).toEqual(['fs']);
});
});
describe('syncMcpServers', () => {
const claudeFile = JSON.stringify({
numStartups: 3,
mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } },
});
const setUpAll = () => {
for (const d of ['.gemini/config', '.codex', '.config/opencode']) mkdirSync(join(home, d), { recursive: true });
put('.gemini/settings.json', '{}');
put('.codex/config.toml', '');
put('.config/opencode/opencode.json', '{}');
put('.gemini/config/mcp_config.json', '{}');
};
it('previews without writing and never leaks env values', async () => {
setUpAll();
put('.claude.json', claudeFile);
const before = get('.gemini/settings.json');
const r = await syncMcpServers(TARGETS, { apply: false, home });
expect(r.applied).toBe(false);
expect(result(r, 'gemini').added).toEqual(['fs']);
expect(get('.gemini/settings.json')).toBe(before);
expect(JSON.stringify(r)).not.toContain('s3cret');
});
it('adds missing servers to every other CLI, keeps a backup, is idempotent', async () => {
setUpAll();
put('.claude.json', claudeFile);
put('.codex/config.toml', 'model = "gpt-5"\n[mcp_servers.web]\nurl = "https://w"\n');
const r = await syncMcpServers(TARGETS, { apply: true, home });
expect(result(r, 'claude').added).toEqual(['web']);
expect(result(r, 'codex').added).toEqual(['fs']);
expect(JSON.parse(get('.claude.json')).numStartups).toBe(3);
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual(['fs', 'web']);
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.fs.command).toEqual(['npx', '-y', 'fs']);
expect(get('.codex/config.toml')).toContain('model = "gpt-5"');
expect(existsSync(join(home, '.claude.json.codeman-bak'))).toBe(true);
const again = await syncMcpServers(TARGETS, { apply: true, home });
expect(again.targets.every((t) => t.added.length === 0)).toBe(true);
});
it('reports conflicts without overwriting, skips what a dialect cannot express, leaves unreadable files alone', async () => {
setUpAll();
put(
'.claude.json',
JSON.stringify({ mcpServers: { x: { command: 'one' }, sse: { type: 'sse', url: 'https://s' } } })
);
put('.gemini/settings.json', JSON.stringify({ mcpServers: { x: { command: 'two' } } }));
const broken = '{ // jsonc\n "mcp": {} }';
put('.config/opencode/opencode.json', broken);
const r = await syncMcpServers(TARGETS, { apply: true, home });
expect(r.conflicts).toEqual(['x']);
expect(JSON.parse(get('.gemini/settings.json')).mcpServers.x.command).toBe('two');
expect(result(r, 'codex').skipped).toEqual(['sse']);
expect(result(r, 'opencode').status).toBe('unreadable');
expect(get('.config/opencode/opencode.json')).toBe(broken);
});
it('passes the unsupported list through to the result', async () => {
const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']);
expect(r.unsupported).toEqual(['Pi']);
});
describe('only CLIs that are installed or already have a config file take part', () => {
it('never creates config for a CLI that is neither installed nor configured', async () => {
put('.claude.json', claudeFile);
const notInstalled = TARGETS.map((t) => (t.id === 'claude' ? t : { ...t, installed: false }));
const r = await syncMcpServers(notInstalled, { apply: true, home });
for (const id of ['gemini', 'codex', 'antigravity', 'opencode']) expect(result(r, id).status).toBe('absent');
expect(existsSync(join(home, '.codex'))).toBe(false);
expect(existsSync(join(home, '.gemini'))).toBe(false);
expect(existsSync(join(home, '.config'))).toBe(false);
});
it('a CLI that is not detected as installed still takes part if its config file exists', async () => {
put('.claude.json', claudeFile);
put('.gemini/settings.json', '{}');
const r = await syncMcpServers([target('claude'), target('gemini', { installed: false })], { apply: true, home });
expect(result(r, 'gemini').added).toEqual(['fs']);
});
it('an installed CLI with no config yet gets one created', async () => {
put('.claude.json', claudeFile);
const r = await syncMcpServers([target('claude'), target('codex')], { apply: true, home });
expect(result(r, 'codex').added).toEqual(['fs']);
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
});
});
describe('file safety', () => {
it('leaves a file that receives env values or headers readable by its owner only', async () => {
put('.claude.json', claudeFile);
put('.gemini/settings.json', '{}');
chmodSync(join(home, '.gemini/settings.json'), 0o664);
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o600);
});
it('keeps the existing mode when nothing secret is copied', async () => {
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
put('.gemini/settings.json', '{}');
chmodSync(join(home, '.gemini/settings.json'), 0o664);
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o664);
});
it('writes through a symlinked config instead of replacing the link', async () => {
put('.claude.json', claudeFile);
mkdirSync(join(home, 'dotfiles'), { recursive: true });
writeFileSync(join(home, 'dotfiles/gemini-settings.json'), '{}');
mkdirSync(join(home, '.gemini'), { recursive: true });
symlinkSync(join(home, 'dotfiles/gemini-settings.json'), join(home, '.gemini/settings.json'));
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(lstatSync(join(home, '.gemini/settings.json')).isSymbolicLink()).toBe(true);
expect(JSON.parse(readFileSync(join(home, 'dotfiles/gemini-settings.json'), 'utf8')).mcpServers.fs.command).toBe(
'npx'
);
expect(existsSync(join(home, 'dotfiles/gemini-settings.json.codeman-bak'))).toBe(true);
});
it('reports a dangling symlink as failed and writes nothing', async () => {
put('.claude.json', claudeFile);
mkdirSync(join(home, '.gemini'), { recursive: true });
symlinkSync(join(home, 'nowhere.json'), join(home, '.gemini/settings.json'));
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(result(r, 'gemini').status).toBe('failed');
expect(existsSync(join(home, 'nowhere.json'))).toBe(false);
});
it('refuses a second apply while one is running, and leaves no temp files behind', async () => {
put('.claude.json', claudeFile);
put('.gemini/settings.json', '{}');
const first = syncMcpServers(only('claude', 'gemini'), { apply: true, home });
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).rejects.toBeInstanceOf(
McpSyncBusyError
);
await first;
// A preview is read-only and is never refused.
await expect(syncMcpServers(only('claude', 'gemini'), { apply: false, home })).resolves.toBeDefined();
// ...and the lock is released afterwards.
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).resolves.toBeDefined();
const leftovers = readdirSync(join(home, '.gemini')).filter((f) => f.includes('codeman-tmp'));
expect(leftovers).toEqual([]);
});
});
});
describe('error messages never quote the config file (it holds env values and headers)', () => {
const claudeWithSecret = JSON.stringify({
mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sk-COPIED-SECRET' } } },
});
it('reports an unparseable TOML file by line and column only', async () => {
put('.claude.json', claudeWithSecret);
put(
'.codex/config.toml',
'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n'
);
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
const codex = result(r, 'codex');
expect(codex.status).toBe('unreadable');
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
expect(JSON.stringify(r)).not.toContain('lin_SECRET_abc');
expect(JSON.stringify(r)).not.toContain('LINEAR_API_KEY');
});
it('reports an unparseable JSON file by position, or by category when V8 quotes source instead', async () => {
put('.claude.json', claudeWithSecret);
// V8: `Unexpected token 's', ..."TOKEN":sk-GEMINI-SECRET}"... is not valid JSON` (no position).
put('.gemini/settings.json', '{"mcpServers":{"g":{"command":"x","env":{"TOKEN":sk-GEMINI-SECRET}}}}');
// V8: `Expected ',' or '}' after property value in JSON at position N (line 2 column M)`.
put('.gemini/config/mcp_config.json', '{\n "mcpServers": {"a": {"env": {"K": "sk-AGY-SECRET" "x"}}}\n}');
const r = await syncMcpServers(only('claude', 'gemini', 'antigravity'), { apply: false, home });
expect(result(r, 'gemini').status).toBe('unreadable');
expect(result(r, 'gemini').error).toBe('not valid JSON');
expect(result(r, 'antigravity').error).toMatch(/^not valid JSON \(line 2, column \d+\)$/);
const body = JSON.stringify(r);
for (const secret of ['sk-GEMINI-SECRET', 'sk-AGY-SECRET', 'TOKEN']) expect(body).not.toContain(secret);
});
it('a write refused at the re-parse quotes neither the file nor the copied server', async () => {
put('.claude.json', claudeWithSecret);
// An inline top-level table parses, but appending `[mcp_servers.fs]` to it does not.
const inline = 'mcp_servers = { a = { command = "x", env = { K = "sk-FILE-SECRET" } } }\n';
put('.codex/config.toml', inline);
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
const codex = result(r, 'codex');
expect(codex.status).toBe('failed');
expect(codex.error).toMatch(/^not valid TOML \(line \d+, column \d+\)$/);
expect(get('.codex/config.toml')).toBe(inline);
const body = JSON.stringify(r);
expect(body).not.toContain('sk-FILE-SECRET');
expect(body).not.toContain('sk-COPIED-SECRET');
});
});
describe('relocated config dirs (the CLI reads its file somewhere else)', () => {
const claudeFile = JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } });
const CODEX_RELOC = { relocation: { envVar: 'CODEX_HOME', path: 'config.toml' } };
const CLAUDE_RELOC = { relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' } };
const OPENCODE_RELOC = { relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' } };
it('writes $CODEX_HOME/config.toml, never the default ~/.codex/config.toml', async () => {
put('.claude.json', claudeFile);
const codexHome = join(home, 'elsewhere/codex');
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
apply: true,
home,
env: { CODEX_HOME: codexHome },
});
expect(result(r, 'codex').file).toBe(join(codexHome, 'config.toml'));
expect(result(r, 'codex').added).toEqual(['fs']);
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
expect(existsSync(join(home, '.codex'))).toBe(false);
});
it('reads the source from $CLAUDE_CONFIG_DIR and writes $XDG_CONFIG_HOME/opencode', async () => {
const claudeDir = join(home, 'accounts/work');
mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(claudeDir, '.claude.json'), claudeFile);
// A default-location file that claude does NOT read under CLAUDE_CONFIG_DIR: its server must not spread.
put('.claude.json', JSON.stringify({ mcpServers: { stray: { type: 'stdio', command: 'nope' } } }));
const xdg = join(home, 'xdg');
const r = await syncMcpServers([target('claude', CLAUDE_RELOC), target('opencode', OPENCODE_RELOC)], {
apply: true,
home,
env: { CLAUDE_CONFIG_DIR: claudeDir, XDG_CONFIG_HOME: xdg },
});
expect(result(r, 'claude').servers).toEqual(['fs']);
expect(Object.keys(JSON.parse(readFileSync(join(xdg, 'opencode/opencode.json'), 'utf8')).mcp)).toEqual(['fs']);
expect(existsSync(join(home, '.config'))).toBe(false);
});
it('reports a relative relocation value as skipped and writes nothing anywhere', async () => {
put('.claude.json', claudeFile);
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
apply: true,
home,
env: { CODEX_HOME: 'relative/codex' },
});
const codex = result(r, 'codex');
expect(codex.status).toBe('skipped');
expect(codex.error).toMatch(/CODEX_HOME is set to a relative path/);
expect(codex.added).toEqual([]);
expect(existsSync(join(home, '.codex'))).toBe(false);
expect(existsSync(join(process.cwd(), 'relative'))).toBe(false);
});
it('an empty value means unset, as it does for the CLI', async () => {
put('.claude.json', claudeFile);
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
apply: true,
home,
env: { CODEX_HOME: '' },
});
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
});
it("ignores the caller's own env when home is overridden and no env is passed", async () => {
put('.claude.json', claudeFile);
const saved = process.env.CODEX_HOME;
process.env.CODEX_HOME = join(home, 'from-process-env');
try {
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], { apply: true, home });
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
expect(existsSync(join(home, 'from-process-env'))).toBe(false);
} finally {
if (saved === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = saved;
}
});
});
+722
View File
@@ -0,0 +1,722 @@
/**
* @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js).
*
* The controller itself is covered by test/mobile-ime-preview.test.ts. These
* pin how terminal-ui.js and the delivery graph consume it: script order,
* build registration and CSS, the _init/_destroyMobileImePreview lifecycle,
* the onData routing of an IME commit, and the rule that only output accepted
* AFTER a commit may clear the committed preview.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
const indexSource = read('src/web/public/index.html');
const buildSource = read('scripts/build.mjs');
const terminalSource = read('src/web/public/terminal-ui.js');
const cssSource = read('src/web/public/styles.css');
type Fn = ReturnType<typeof vi.fn>;
type App = Record<string, any>;
function loadMixin(globals: Record<string, unknown> = {}) {
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
const windowStub = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
...(globals.window as object),
} as Record<string, unknown>;
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
cancelAnimationFrame: vi.fn(),
URLSearchParams,
location: { search: '' },
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
document: { addEventListener: vi.fn(), createElement: vi.fn() },
MobileDetection: { isTouchDevice: () => false },
CodemanApp: FakeCodemanApp,
_crashDiag: { log: vi.fn() },
...globals,
window: windowStub,
});
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
return { mixin: FakeCodemanApp.prototype, context, windowStub };
}
function fakeClassList() {
const values = new Set<string>();
return {
add: vi.fn((value: string) => values.add(value)),
remove: vi.fn((value: string) => values.delete(value)),
toggle: vi.fn((value: string, force?: boolean) => {
const enabled = force === undefined ? !values.has(value) : force;
if (enabled) values.add(value);
else values.delete(value);
return enabled;
}),
contains: (value: string) => values.has(value),
};
}
function createPreviewHarness(
options: {
eligible?: boolean;
createThrows?: boolean;
omitGlobal?: boolean;
themeForeground?: string;
themeBackground?: string;
themeGetterThrows?: boolean;
} = {}
) {
const compositionView = {
style: {
fontFamily: '"Fira Code"',
fontSize: '10px',
fontWeight: '500',
fontStyle: 'italic',
lineHeight: '12px',
height: '12px',
color: 'rgb(255, 255, 255)',
},
};
const helpers = {
classList: fakeClassList(),
children: [] as Array<Record<string, unknown>>,
querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
appendChild(node: Record<string, unknown>) {
this.children.push(node);
},
};
const createdControllers: Array<Record<string, any>> = [];
const previewNodes: Array<Record<string, any>> = [];
const documentStub = {
addEventListener: vi.fn(),
createElement: vi.fn(() => {
const node = {
className: '',
hidden: false,
textContent: '',
dataset: {} as Record<string, string>,
style: {} as Record<string, string>,
attributes: {} as Record<string, string>,
setAttribute(name: string, value: string) {
this.attributes[name] = value;
},
remove: vi.fn(),
};
previewNodes.push(node);
return node;
}),
};
const mobileImePreview = options.omitGlobal
? undefined
: {
isIosWebKitTouch: vi.fn(() => options.eligible ?? true),
create: vi.fn((callbacks: Record<string, unknown>) => {
if (options.createThrows) throw new Error('controller unavailable');
const controller = {
destroy: vi.fn(),
reset: vi.fn(),
consumeTerminalData: vi.fn(() => false),
completeCommit: vi.fn(),
noteAuthoritativeOutput: vi.fn(),
callbacks,
};
createdControllers.push(controller);
return controller;
}),
};
const { mixin, windowStub } = loadMixin({
document: documentStub,
MobileImePreview: mobileImePreview,
getComputedStyle: (node: { style: Record<string, string> }) => node.style,
});
windowStub.codemanCurrentXtermTheme = () => {
if (options.themeGetterThrows) throw new Error('theme unavailable');
return { foreground: '#334455', background: '#223344' };
};
const app: App = Object.assign(Object.create(mixin), {
terminal: {
textarea: {},
options: {
theme:
options.themeForeground || options.themeBackground
? { foreground: options.themeForeground, background: options.themeBackground }
: undefined,
},
element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) },
},
activeSessionId: 'session-a',
});
return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub };
}
describe('mobile IME preview delivery graph', () => {
it('loads the controller after xterm and before terminal wiring', () => {
const at = indexSource.indexOf('<script defer src="mobile-ime-preview.js"></script>');
expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="terminal-ui.js"></script>'));
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="app.js"></script>'));
});
it('is minified and content-hashed by the build', () => {
expect(buildSource).toContain("run('minify mobile-ime-preview.js'");
expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/);
});
it('scopes preview presentation and native composition suppression to touch ownership', () => {
expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {');
expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {");
expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {');
const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {'));
expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)');
expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)');
});
it('initializes the preview right after the terminal opens', () => {
expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/);
});
});
describe('mobile IME preview lifecycle', () => {
it('fails open when the global is absent or create throws', () => {
for (const options of [{ omitGlobal: true }, { createThrows: true }]) {
const { app } = createPreviewHarness(options);
expect(() => app._initMobileImePreview()).not.toThrow();
expect(app._mobileImePreview).toBeNull();
}
});
it('does not create a controller on unsupported input platforms', () => {
const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false });
app._initMobileImePreview();
expect(mobileImePreview?.create).not.toHaveBeenCalled();
expect(previewNodes).toHaveLength(0);
expect(app._mobileImePreview).toBeNull();
});
it('creates one controller bound to the terminal textarea and one hidden preview node', () => {
const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness();
app._initMobileImePreview();
expect(mobileImePreview?.create).toHaveBeenCalledOnce();
expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea);
// Keydown is observed on the terminal element (an ancestor of the
// textarea), so it runs before xterm's own capture listener finalizes.
expect(mobileImePreview?.create.mock.calls[0][0].keydownTarget).toBe(app.terminal.element);
expect(helpers.children).toEqual([previewNodes[0]]);
expect(previewNodes[0]).toMatchObject({ className: 'codeman-ime-preview', hidden: true });
expect(previewNodes[0].attributes['aria-hidden']).toBe('true');
});
it('destroys prior ownership on repeated initialization and keeps one active controller', () => {
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
app._initMobileImePreview();
const first = createdControllers[0];
app._initMobileImePreview();
expect(first.destroy).toHaveBeenCalledOnce();
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
expect(createdControllers).toHaveLength(2);
expect(app._mobileImePreview).toBe(createdControllers[1]);
// Window listeners are released with the controller that owned them.
expect((windowStub.removeEventListener as Fn).mock.calls.map((call) => call[0]).sort()).toEqual([
'offline',
'pagehide',
]);
});
it('destroy releases the controller, the node and the listeners', () => {
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
app._initMobileImePreview();
app._destroyMobileImePreview();
expect(createdControllers[0].destroy).toHaveBeenCalledOnce();
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
expect(app._mobileImePreview).toBeNull();
expect(windowStub.removeEventListener).toHaveBeenCalledTimes(2);
});
it('pagehide resets the controller instead of destroying it, so a bfcache restore keeps the preview', () => {
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
app._initMobileImePreview();
const pagehide = (windowStub.addEventListener as Fn).mock.calls.find((call) => call[0] === 'pagehide')?.[1];
expect(pagehide).toBeTypeOf('function');
pagehide();
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
expect(createdControllers[0].destroy).not.toHaveBeenCalled();
expect(previewNodes[0].remove).not.toHaveBeenCalled();
expect(app._mobileImePreview).toBe(createdControllers[0]);
// A second hide after the page came back from the cache still works.
pagehide();
expect(createdControllers[0].reset).toHaveBeenCalledTimes(2);
});
it('resets the controller exactly once when the active session changes', () => {
const { app, createdControllers } = createPreviewHarness();
app._initMobileImePreview();
app.activeSessionId = 'session-b';
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
app.sessions = new Map();
app._updateLocalEchoState();
app._updateLocalEchoState();
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
});
it('renders and clears owned preview state', () => {
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
app._initMobileImePreview();
const callbacks = createdControllers[0].callbacks;
callbacks.render({ text: '你好', phase: 'provisional' });
expect(previewNodes[0]).toMatchObject({ textContent: '你好', hidden: false, dataset: { phase: 'provisional' } });
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
callbacks.clear();
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true, dataset: {} });
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
});
// With local echo on, typed text sits in the overlay (z-index 7) and the PTY
// cursor that places the span stays at the prompt start, under that text. The
// overlay draws the composition instead. Real-xterm proof of the covering:
// test/mobile-ime-preview.browser.test.ts.
function withOverlay(app: App, visible = true) {
const overlay = {
composition: '',
setComposition: vi.fn(function (this: { composition: string }, text: string) {
this.composition = text;
}),
state: { visible },
};
Object.assign(app, { _localEchoEnabled: true, _localEchoOverlay: overlay });
return overlay;
}
it('routes the preview into the local echo overlay when local echo is on', () => {
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
const overlay = withOverlay(app);
app._initMobileImePreview();
const callbacks = createdControllers[0].callbacks;
callbacks.render({ text: '天気', phase: 'provisional' });
expect(overlay.setComposition).toHaveBeenLastCalledWith('天気');
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true });
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
callbacks.clear();
expect(overlay.setComposition).toHaveBeenLastCalledWith('');
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
});
it('uses the span when the overlay cannot place the composition (no prompt found)', () => {
const { app, previewNodes, createdControllers } = createPreviewHarness();
const overlay = withOverlay(app, false);
app._initMobileImePreview();
createdControllers[0].callbacks.render({ text: '天気', phase: 'provisional' });
expect(overlay.composition).toBe('');
expect(previewNodes[0]).toMatchObject({ textContent: '天気', hidden: false });
});
it('uses the span, not the overlay, when local echo is off or handed back to PTY echo', () => {
const off = createPreviewHarness();
const offOverlay = withOverlay(off.app);
Object.assign(off.app, { _localEchoEnabled: false });
off.app._initMobileImePreview();
off.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
expect(offOverlay.setComposition).not.toHaveBeenCalled();
expect(off.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
const passthrough = createPreviewHarness();
const passOverlay = withOverlay(passthrough.app);
Object.assign(passthrough.app, { _echoPassthroughSessions: new Set(['session-a']) });
passthrough.app._initMobileImePreview();
passthrough.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
expect(passOverlay.setComposition).not.toHaveBeenCalled();
expect(passthrough.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
});
it('uses the terminal foreground and opaque background while mirroring native composition font metrics', () => {
const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({
themeForeground: '#1f2328',
themeBackground: '#fafafa',
});
app._initMobileImePreview();
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
expect(previewNodes[0].style).toMatchObject({
fontFamily: compositionView.style.fontFamily,
fontSize: compositionView.style.fontSize,
fontWeight: compositionView.style.fontWeight,
fontStyle: compositionView.style.fontStyle,
lineHeight: compositionView.style.lineHeight,
height: compositionView.style.height,
color: '#1f2328',
backgroundColor: '#fafafa',
});
});
it('falls back to the current skin theme when the terminal options carry no theme', () => {
const { app, previewNodes, createdControllers } = createPreviewHarness();
app._initMobileImePreview();
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
expect(previewNodes[0].style).toMatchObject({ color: '#334455', backgroundColor: '#223344' });
});
it('keeps rendering with a safe foreground when the theme getter throws', () => {
const { app, previewNodes, createdControllers } = createPreviewHarness({ themeGetterThrows: true });
app._initMobileImePreview();
expect(() => createdControllers[0].callbacks.render({ text: '安全', phase: 'provisional' })).not.toThrow();
expect(previewNodes[0].textContent).toBe('安全');
expect(previewNodes[0].style.color).toBe('#e0e0e0');
expect(previewNodes[0].style.backgroundColor).toBe('#0d0d0d');
});
it.each(['query', 'create', 'append', 'className', 'hidden'] as const)(
'removes partial DOM ownership when %s fails',
(failure) => {
const removed = vi.fn();
const owner = fakeClassList();
const preview = new Proxy(
{ dataset: {}, remove: removed, setAttribute: vi.fn() },
{
set(target, property, value) {
if (property === failure) throw new Error(`${failure} failed`);
return Reflect.set(target, property, value);
},
}
);
const helpers = {
classList: owner,
appendChild:
failure === 'append'
? () => {
throw new Error('append failed');
}
: vi.fn(),
};
const documentStub = {
addEventListener: vi.fn(),
createElement:
failure === 'create'
? () => {
throw new Error('create failed');
}
: () => preview,
};
const MobileImePreview = { isIosWebKitTouch: () => true, create: vi.fn() };
const { mixin } = loadMixin({ document: documentStub, MobileImePreview });
const app: App = Object.assign(Object.create(mixin), {
terminal: {
textarea: {},
element: {
querySelector:
failure === 'query'
? () => {
throw new Error('query failed');
}
: () => helpers,
},
},
});
expect(() => app._initMobileImePreview()).not.toThrow();
expect(app._mobileImePreview).toBeNull();
expect(MobileImePreview.create).not.toHaveBeenCalled();
expect(owner.contains('codeman-ime-preview-owned')).toBe(false);
if (!['query', 'create'].includes(failure)) expect(removed).toHaveBeenCalled();
}
);
});
/**
* Rebuilds the real `handleTerminalData` closure from initTerminal's source,
* so these cases exercise the shipped routing rather than a copy of it.
*/
function loadHandleTerminalData(app: App, sent: string[]) {
const marker = 'const handleTerminalData = (data) => {';
const start = terminalSource.indexOf(marker);
if (start < 0) throw new Error('handleTerminalData definition not found');
const bodyStart = start + marker.length;
const end = terminalSource.indexOf('\n };', bodyStart);
if (end < 0) throw new Error('handleTerminalData boundary not found');
const timers: Array<() => void> = [];
app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
const context = vm.createContext({
console,
performance,
setTimeout: (callback: () => void) => {
timers.push(callback);
return timers.length;
},
clearTimeout: vi.fn(),
document: { activeElement: null, getElementById: vi.fn(() => null) },
window: {
cjkActive: false,
CodemanTerminalInput: {
BRACKETED_PASTE_START: '\x1b[200~',
shouldSuppressTerminalQueryResponse: () => false,
isTerminalFocusOrMouseReport: () => false,
isComposerNavKey: () => false,
},
},
_crashDiag: { log: vi.fn() },
flushInput: () => {
app._inputFlushTimeout = null;
if (app._pendingInput && app.activeSessionId) {
const input = app._pendingInput;
app._pendingInput = '';
app._sendInputAsync(app.activeSessionId, input);
}
},
});
const handler = vm.runInContext(`(function (data) {${terminalSource.slice(bodyStart, end)}\n})`, context) as (
this: App,
data: string
) => void;
return { handle: (data: string) => handler.call(app, data), timers };
}
describe('mobile IME commit onData routing', () => {
function onDataApp(options: {
localEcho: boolean;
tagged?: boolean;
overlayMissing?: boolean;
addThrows?: boolean;
appendThrows?: boolean;
consumeThrows?: boolean;
}) {
const { mixin } = loadMixin();
const sent: string[] = [];
const controller = {
consumeTerminalData: options.consumeThrows
? vi.fn(() => {
throw new Error('consume failed');
})
: vi
.fn()
.mockReturnValueOnce(options.tagged ?? true)
.mockReturnValue(false),
completeCommit: vi.fn(),
noteAuthoritativeOutput: vi.fn(),
};
const overlay = {
pendingText: '',
appendText: vi.fn((data: string) => {
if (options.appendThrows) throw new Error('overlay failed');
overlay.pendingText += data;
}),
addChar: vi.fn((data: string) => {
if (options.addThrows) throw new Error('overlay failed');
overlay.pendingText += data;
}),
clear: vi.fn(() => {
overlay.pendingText = '';
}),
suppressBufferDetection: vi.fn(),
};
const app: App = Object.assign(Object.create(mixin), {
activeSessionId: 'session-a',
_localEchoEnabled: options.localEcho,
_localEchoOverlay: options.overlayMissing ? null : overlay,
_echoPassthroughSessions: new Set(),
_flushedOffsets: new Map(),
_flushedTexts: new Map(),
_pendingInput: '',
_inputFlushTimeout: null,
_lastKeystrokeTime: 0,
_terminalOutputSeq: 5,
_mobileImePreview: controller,
});
return { app, controller, overlay, sent, ...loadHandleTerminalData(app, sent) };
}
it('moves a multi-character commit into local echo and submits it only on Enter', () => {
const { app, controller, overlay, sent, handle, timers } = onDataApp({ localEcho: true });
handle('你好');
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
expect(overlay.appendText).toHaveBeenCalledWith('你好');
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
expect(app._mobileImeCommitOutputSeq).toBeNull();
expect(sent).toEqual([]);
handle('\r');
expect(sent).toEqual(['你好']);
timers.shift()?.();
expect(sent).toEqual(['你好', '\r']);
});
it('moves a single-character commit into local echo through addChar', () => {
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true });
handle('界');
expect(overlay.addChar).toHaveBeenCalledWith('界');
expect(overlay.appendText).not.toHaveBeenCalled();
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
expect(sent).toEqual([]);
});
it.each([
['appendText throws', { appendThrows: true }, '失敗'],
['addChar throws', { addThrows: true }, '字'],
])('never sends a commit the overlay may already hold when %s', (_label, extra, text) => {
// One code path with typed text: no send-on-throw fallback, which would
// double-send if the overlay threw after appending.
const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra });
expect(() => handle(text)).toThrow('overlay failed');
expect(sent).toEqual([]);
// Nothing shows the text, so the preview keeps it until its fallback.
expect(controller.completeCommit).not.toHaveBeenCalled();
});
it('keeps the preview when the overlay is missing, exactly like typed text', () => {
const { controller, sent, handle } = onDataApp({ localEcho: true, overlayMissing: true });
expect(() => handle('日本')).not.toThrow();
expect(sent).toEqual([]);
expect(controller.completeCommit).not.toHaveBeenCalled();
});
it('completes the commit only after the printable branch has put it in the overlay', () => {
const { controller, overlay, handle } = onDataApp({ localEcho: true });
controller.completeCommit.mockImplementation(() => {
expect(overlay.pendingText).toBe('界');
});
handle('界');
expect(controller.completeCommit).toHaveBeenCalledOnce();
});
it('keeps an untagged paste on the existing local echo path', () => {
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false });
handle('plain paste');
expect(overlay.pendingText).toBe('plain paste');
expect(controller.completeCommit).not.toHaveBeenCalled();
expect(sent).toEqual([]);
});
it('sends a commit once without local echo and holds the preview until output arrives', () => {
const { app, controller, sent, handle } = onDataApp({ localEcho: false });
handle('日本語');
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
expect(sent).toEqual(['日本語']);
expect(controller.completeCommit).not.toHaveBeenCalled();
expect(app._mobileImeCommitOutputSeq).toBe(5);
});
it('sends the original bytes exactly once when the controller throws', () => {
const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true });
expect(() => handle('你好')).not.toThrow();
expect(sent).toEqual(['你好']);
});
});
describe('mobile IME commit and authoritative terminal output', () => {
function outputHarness(mode = 'claude') {
const { mixin } = loadMixin();
const parses: Array<() => void> = [];
const written: string[] = [];
const controller = {
consumeTerminalData: vi.fn(() => true),
noteAuthoritativeOutput: vi.fn(),
};
const app: App = Object.assign(Object.create(mixin), {
pendingWrites: [],
terminal: {
rows: 24,
write: vi.fn((data: string, callback?: () => void) => {
written.push(data);
if (callback) parses.push(callback);
}),
buffer: { active: { viewportY: 0 } },
scrollToBottom: vi.fn(),
},
activeSessionId: 'session-a',
sessions: new Map([['session-a', { mode }]]),
isTerminalAtBottom: () => true,
_hasRecentUserScrollUp: () => false,
_safeYield: vi.fn(),
_localEchoOverlay: null,
_mobileImePreview: controller,
});
const flush = () => {
app.writeFrameScheduled = false;
app.flushPendingWrites();
};
const parseNext = () => parses.shift()?.();
return { app, controller, written, flush, parseNext };
}
it('clears the committed preview once output accepted after the commit is parsed', () => {
const { app, controller, flush, parseNext } = outputHarness();
app._consumeMobileImeTerminalData('你好');
app.batchTerminalWrite('echo');
flush();
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
parseNext();
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
});
it('does not let output queued before the commit clear it, even when it parses after', () => {
const { app, controller, flush, parseNext } = outputHarness();
app.batchTerminalWrite('before');
flush();
app._consumeMobileImeTerminalData('你好');
parseNext();
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
app.batchTerminalWrite('after');
flush();
parseNext();
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
});
it('does not let a split chunk clear the commit before its remainder is written', () => {
const { app, controller, written, flush, parseNext } = outputHarness('codex');
app._consumeMobileImeTerminalData('你好');
app.batchTerminalWrite('x'.repeat(40000));
flush();
parseNext();
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
flush();
parseNext();
expect(written.join('')).toBe('x'.repeat(40000));
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
});
it('does not let output parsed after a session switch clear the new session preview', () => {
const { app, controller, flush, parseNext } = outputHarness();
app._consumeMobileImeTerminalData('你好');
app.batchTerminalWrite('echo');
flush();
app.activeSessionId = 'session-b';
parseNext();
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
});
it('re-places an overlay that holds only a composition after output (hasPending is false there)', () => {
const { app, flush } = outputHarness();
const overlay = { hasPending: false, pendingText: '', composition: '今日', rerender: vi.fn() };
app._localEchoOverlay = overlay;
app.batchTerminalWrite('output that moves the prompt');
flush();
expect(overlay.rerender).toHaveBeenCalledOnce();
});
it('re-places it after a resize too: no rerender() site is gated on hasPending', () => {
// The resize observer is a closure inside initTerminal(), so it is pinned
// by source; the post-write follow runs against real xterm in
// test/mobile-ime-preview.browser.test.ts.
expect(terminalSource).toMatch(
/this\.updateConnectionLines\(\);\s*(?:\/\/[^\n]*\n\s*)*this\._localEchoOverlay\?\.rerender\(\);/
);
expect(terminalSource).not.toMatch(/hasPending\)\s*\{?\s*this\._localEchoOverlay\.rerender\(\)/);
});
it('notifies once per commit, never for later output', () => {
const { app, controller, flush, parseNext } = outputHarness();
app._consumeMobileImeTerminalData('你好');
for (const chunk of ['a', 'b']) {
app.batchTerminalWrite(chunk);
flush();
parseNext();
}
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
});
});
+392
View File
@@ -0,0 +1,392 @@
/**
* The iOS IME preview controller against a REAL xterm 6 instance.
*
* The controller's logic is unit-tested in test/mobile-ime-preview.test.ts
* with a stand-in for xterm. What only real xterm proves is the event ORDER:
* `terminal.open()` registers xterm's keydown listener in the capture phase on
* the helper textarea, and CompositionHelper.keydown finalizes a composition
* there and emits the commit through onData synchronously. The controller must
* observe that keydown first (capture phase on `terminal.element`), and must
* finalize on exactly the keys xterm does.
*
* No server: a blank page loads the vendored xterm bundle and the controller.
* Browser-driven, so it is excluded from `npm run test:ci` like the other
* Playwright suites. Run locally:
* npm run test:browser -- test/mobile-ime-preview.browser.test.ts
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { build } from 'esbuild';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const root = resolve(import.meta.dirname, '..');
type Step =
| ['start']
| ['update', string]
| ['end', string]
| ['key', number, string, boolean]
| ['wait', number]
| ['consume', string];
describe('mobile IME preview with real xterm', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.setContent('<div id="t" style="width:600px;height:300px"></div>');
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
});
async function drive(steps: Step[]) {
return page.evaluate(async (steps: Step[]) => {
const w = window as any;
const host = document.getElementById('t') as HTMLElement;
host.innerHTML = '';
const term = new w.Terminal();
term.open(host);
const textarea = term.textarea as HTMLTextAreaElement;
const renders: Array<{ text: string; phase: string }> = [];
const onData: Array<{ data: string; consumed: boolean }> = [];
const controller = w.MobileImePreview.create({
textarea,
keydownTarget: term.element,
render: (r: { text: string; phase: string }) => renders.push(r),
clear: () => {},
});
term.onData((data: string) => onData.push({ data, consumed: controller.consumeTerminalData(data) }));
textarea.focus();
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
for (const step of steps) {
if (step[0] === 'start') textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
if (step[0] === 'update') {
textarea.value = step[1];
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: step[1] }));
}
if (step[0] === 'end') textarea.dispatchEvent(new CompositionEvent('compositionend', { data: step[1] }));
if (step[0] === 'key') {
const [, keyCode, key, isComposing] = step;
const event = new KeyboardEvent('keydown', { key, isComposing, bubbles: true, cancelable: true });
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
textarea.dispatchEvent(event);
}
if (step[0] === 'wait') await tick(step[1]);
if (step[0] === 'consume') onData.push({ data: step[1], consumed: controller.consumeTerminalData(step[1]) });
}
await tick(20);
const { composing, awaitingCommit, committed, latest } = controller.state;
const result = { onData, state: { composing, awaitingCommit, committed, latest }, lastRender: renders.at(-1) };
controller.destroy();
term.dispose();
return result;
}, steps);
}
it('Enter mid-composition: xterm emits the commit and the controller takes it as committed', async () => {
// compositionupdate's textarea end offset is recorded by xterm on a 0 ms timer.
const result = await drive([['start'], ['update', '確定'], ['wait', 10], ['key', 13, 'Enter', false]]);
expect(result.onData).toEqual([
{ data: '確定', consumed: true },
{ data: '\r', consumed: false },
]);
expect(result.state).toMatchObject({ awaitingCommit: false, committed: true, latest: '確定' });
expect(result.lastRender).toEqual({ text: '確定', phase: 'committed' });
});
it('keyCode 229 with isComposing false: xterm keeps composing, so the preview keeps following', async () => {
const result = await drive([
['start'],
['update', 'か'],
['wait', 10],
['key', 229, 'k', false],
['update', 'かな'],
]);
expect(result.onData).toEqual([]);
expect(result.state).toMatchObject({ composing: true, awaitingCommit: false, latest: 'かな' });
expect(result.lastRender).toEqual({ text: 'かな', phase: 'provisional' });
});
it('a deleted composition stops waiting after 2 s, so a later paste is not taken as its commit', async () => {
const result = await drive([
['start'],
['update', 'abc'],
['update', ''],
['end', ''],
['wait', 2100],
['consume', 'pasted'],
]);
expect(result.onData).toEqual([{ data: 'pasted', consumed: false }]);
expect(result.state).toMatchObject({ awaitingCommit: false, committed: false });
});
});
/**
* The preview with local echo ON, the default for Claude sessions on phones.
* Committed text then sits in the LocalEchoOverlay (a z-index 7 layer in
* `.xterm-screen`) and never reaches the PTY before Enter, so the PTY cursor,
* which is where the helper span sits, stays at the prompt start: under the
* overlay's own opaque text. So a composition that follows text already in the
* overlay must be drawn by the overlay itself, after that text.
*
* Loads the real pieces: xterm 6, the overlay bundled from its package source
* exactly as scripts/postinstall.js bundles it (plus the same LocalEchoOverlay
* alias), styles.css, mobile-ime-preview.js, and terminal-ui.js's own
* `_initMobileImePreview` on a bare CodemanApp prototype.
*/
describe('mobile IME preview over the local echo overlay', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
const bundled = await build({
entryPoints: [resolve(root, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts')],
bundle: true,
format: 'iife',
globalName: 'XtermZerolagInput',
write: false,
logLevel: 'silent',
});
const overlayBundle =
bundled.outputFiles[0].text +
'\nwindow.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
'constructor(terminal){super({prompt:{type:"character",char:"\\u276f",offset:2}});this.activate(terminal);}};\n';
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 1 });
await page.setContent(
'<html class="touch-device"><body><div id="t" style="width:600px;height:240px"></div></body></html>'
);
await page.addStyleTag({ path: resolve(root, 'node_modules/@xterm/xterm/css/xterm.css') });
await page.addStyleTag({ content: readFileSync(resolve(root, 'src/web/public/styles.css'), 'utf8') });
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
await page.addScriptTag({ content: overlayBundle });
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' });
await page.addScriptTag({ path: resolve(root, 'src/web/public/terminal-ui.js') });
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
});
/**
* Types `pending` into the overlay (as the printable/paste branch does), then
* composes `composing` and reports what is PAINTED at the cell right after
* the pending text and at the PTY cursor. Painted = topmost by hit-testing
* with pointer-events forced on, since the overlay and the preview are
* pointer-events:none.
*/
async function composeAfter(pending: string, composing: string, commit: boolean) {
return page.evaluate(
async ({ pending, composing, commit }) => {
const w = window as any;
const host = document.getElementById('t') as HTMLElement;
host.innerHTML = '';
const term = new w.Terminal({
cols: 40,
rows: 8,
fontSize: 14,
fontFamily: 'monospace',
allowProposedApi: true,
});
term.open(host);
await new Promise<void>((r) => term.write('\u276f ', () => r()));
const app = new w.CodemanApp();
app.terminal = term;
app._localEchoEnabled = true;
app._localEchoOverlay = new w.LocalEchoOverlay(term);
w.MobileImePreview.isIosWebKitTouch = () => true;
app._initMobileImePreview();
// The helper textarea and span follow the PTY cursor (col 2, row 0), as
// _syncMobileHelperTextareaToCursor places them.
const screen = term.element.querySelector('.xterm-screen') as HTMLElement;
const dims = term._core._renderService.dimensions.css.cell;
term.element.style.setProperty('--xterm-helper-left', 2 * dims.width + 'px');
term.element.style.setProperty('--xterm-helper-top', '0px');
if (pending) app._localEchoOverlay.appendText(pending);
const textarea = term.textarea as HTMLTextAreaElement;
textarea.focus();
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
textarea.value = composing;
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
const force = document.createElement('style');
force.textContent = '.xterm * { pointer-events: auto !important; }';
document.head.appendChild(force);
const rect = screen.getBoundingClientRect();
const widthOf = (s: string) => term._core.unicodeService.getStringCellWidth(s);
const paintedAt = (col: number) => {
const el = document.elementFromPoint(
rect.left + (col + 0.5) * dims.width,
rect.top + 0.5 * dims.height
) as HTMLElement | null;
return {
text: el?.textContent ?? null,
composition: !!el?.closest?.('[data-zerolag-composition]'),
preview: !!el?.closest?.('.codeman-ime-preview'),
};
};
const afterPending = paintedAt(2 + widthOf(pending));
force.remove();
let afterCommit = null;
if (commit) {
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: composing }));
// What the printable/paste branch of terminal-ui.js's onData does.
if (app._consumeMobileImeTerminalData(composing)) {
app._localEchoOverlay.appendText(composing);
app._transferMobileImeCommitToLocalEcho();
}
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
afterCommit = {
pendingText: app._localEchoOverlay.pendingText,
compositionSpans: term.element.querySelectorAll('[data-zerolag-composition]').length,
overlayText: app._localEchoOverlay._overlay?.textContent,
};
}
const result = {
afterPending,
pendingText: app._localEchoOverlay.pendingText,
afterCommit,
};
app._destroyMobileImePreview();
app._localEchoOverlay.dispose();
term.dispose();
return result;
},
{ pending, composing, commit }
);
}
it('first composition on an empty prompt: the overlay draws it at the prompt', async () => {
const result = await composeAfter('', '今日は', false);
expect(result.afterPending).toEqual({ text: '今', composition: true, preview: false });
expect(result.pendingText).toBe('');
});
it('a second composition is painted after the text already in the overlay, not under it', async () => {
const result = await composeAfter('今日は', '天気', false);
expect(result.afterPending.text).toBe('天');
expect(result.afterPending.composition).toBe(true);
// Provisional text is never taken into the overlay's pending (unsent) text.
expect(result.pendingText).toBe('今日は');
});
it('the commit lands once in the overlay and the composition tail is gone', async () => {
const result = await composeAfter('今日は', '天気', true);
expect(result.afterCommit).toEqual({ pendingText: '今日は天気', compositionSpans: 0, overlayText: '今日は天気' });
});
/**
* Composes `composing` after `pending`, then streams output through the REAL
* write path (batchTerminalWrite, the scheduled flushPendingWrites, xterm's
* async parse) that moves the ❯ row from 0 to 3, then one more frame that
* leaves the prompt where it is (a status-line repaint). Reports the overlay
* row after each frame.
*
* The post-write re-place runs right after terminal.write() returns, before
* xterm parses that chunk, so it sees the buffer as of the previous frame: the
* overlay reaches the new row on the frame after the move. That timing is the
* same for pending text; the composition-only overlay used to never get there
* because the re-place was gated on hasPending, which excludes it.
*/
async function composeThenMovePrompt(pending: string, composing: string) {
return page.evaluate(
async ({ pending, composing }) => {
const w = window as any;
const host = document.getElementById('t') as HTMLElement;
host.innerHTML = '';
const term = new w.Terminal({
cols: 40,
rows: 8,
fontSize: 14,
fontFamily: 'monospace',
allowProposedApi: true,
});
term.open(host);
await new Promise<void>((r) => term.write('❯ ', () => r()));
const app = new w.CodemanApp();
Object.assign(app, {
terminal: term,
_localEchoEnabled: true,
_localEchoOverlay: new w.LocalEchoOverlay(term),
pendingWrites: [],
activeSessionId: 'session-a',
sessions: new Map([['session-a', { mode: 'claude' }]]),
});
w.MobileImePreview.isIosWebKitTouch = () => true;
app._initMobileImePreview();
if (pending) app._localEchoOverlay.appendText(pending);
const textarea = term.textarea as HTMLTextAreaElement;
textarea.focus();
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
textarea.value = composing;
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
const cellH = term._core._renderService.dimensions.css.cell.height;
const overlayEl = app._localEchoOverlay._overlay as HTMLElement;
const overlayRow = () =>
overlayEl.style.display === 'none' ? null : Math.round(parseFloat(overlayEl.style.top) / cellH);
// Output goes through the app's own scheduler; wait until it has been
// flushed and parsed.
const stream = async (data: string) => {
app.batchTerminalWrite(data);
for (let i = 0; i < 200; i++) {
if (!app.writeFrameScheduled && !app._terminalWriteInFlight && app.pendingWrites.length === 0) break;
await new Promise((r) => setTimeout(r, 10));
}
await new Promise<void>((r) => term.write('', () => r()));
};
const before = overlayRow();
await stream('\r\x1b[2Kline 1\r\nline 2\r\nline 3\r\n❯ ');
const promptRow = app._localEchoOverlay.findPrompt()?.row ?? null;
await stream('\x1b7\x1b[8;1Hworking\x1b8');
const result = {
before,
promptRow,
after: overlayRow(),
composition: Array.from(term.element.querySelectorAll('[data-zerolag-composition]'))
.map((el) => (el as HTMLElement).textContent)
.join(''),
hasPending: app._localEchoOverlay.hasPending,
};
app._destroyMobileImePreview();
app._localEchoOverlay.dispose();
term.dispose();
return result;
},
{ pending, composing }
);
}
it('a composition on an empty prompt follows the prompt when output moves it', async () => {
const result = await composeThenMovePrompt('', '今日');
expect(result.before).toBe(0);
expect(result.promptRow).toBe(3);
// Nothing is pending: before the fix this stayed on row 0, over "line 1".
expect(result.hasPending).toBe(false);
expect(result.after).toBe(3);
expect(result.composition).toBe('今日');
});
it('a composition after pending text follows it the same way', async () => {
const result = await composeThenMovePrompt('abc', '今日');
expect(result).toEqual({ before: 0, promptRow: 3, after: 3, composition: '今日', hasPending: true });
});
});
+672
View File
@@ -0,0 +1,672 @@
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
type Listener = (event: Record<string, unknown>) => void;
type ListenerOptions = boolean | { capture?: boolean };
type RegisteredListener = { listener: Listener; capture: boolean };
/**
* A DOM node with just enough event dispatch to reproduce listener ORDER: an
* ancestor's capture listeners, then the target's capture listeners, then the
* target's bubble listeners (at-target capture-first, as in Chromium 89+ and
* WebKit), then the ancestor's bubble listeners.
*/
class FakeNode {
parent: FakeNode | null = null;
protected listeners = new Map<string, RegisteredListener[]>();
addEventListener(type: string, listener: Listener, options?: ListenerOptions) {
const listeners = this.listeners.get(type) ?? [];
listeners.push({ listener, capture: options === true || options?.capture === true });
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener, options?: ListenerOptions) {
const capture = options === true || options?.capture === true;
const listeners = this.listeners.get(type) ?? [];
const index = listeners.findIndex(
(registered) => registered.listener === listener && registered.capture === capture
);
if (index >= 0) listeners.splice(index, 1);
}
run(type: string, capture: boolean, event: Record<string, unknown>) {
for (const registered of [...(this.listeners.get(type) ?? [])]) {
if (registered.capture === capture) registered.listener(event);
}
}
dispatch(type: string, event: Record<string, unknown> = {}) {
const full = { type, target: this, ...event };
const ancestors: FakeNode[] = [];
for (let node = this.parent; node; node = node.parent) ancestors.unshift(node);
for (const ancestor of ancestors) ancestor.run(type, true, full);
this.run(type, true, full);
this.run(type, false, full);
for (const ancestor of [...ancestors].reverse()) ancestor.run(type, false, full);
}
listenerCount() {
return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0);
}
}
class FakeTextarea extends FakeNode {
value = 'unchanged';
}
type Scheduled = { id: number; callback: () => void; delay?: number };
function harness(
overrides: Record<string, unknown> = {},
beforeCreate?: (textarea: FakeTextarea, getController: () => Record<string, any> | undefined) => void
) {
const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8');
const context = vm.createContext({ navigator: {} });
vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' });
const api = vm.runInContext('MobileImePreview', context);
// The terminal element: an ancestor of the helper textarea, like xterm's
// `.xterm` root is of `.xterm-helper-textarea`.
const element = new FakeNode();
const textarea = new FakeTextarea();
textarea.parent = element;
const frames: Scheduled[] = [];
const timers: Scheduled[] = [];
let nextId = 1;
const render = vi.fn();
const clear = vi.fn();
const onCommit = vi.fn();
const scheduleFrame = vi.fn((callback: () => void) => {
const id = nextId++;
frames.push({ id, callback });
return id;
});
const cancelFrame = vi.fn((id: number) => {
const index = frames.findIndex((frame) => frame.id === id);
if (index >= 0) frames.splice(index, 1);
});
const setTimer = vi.fn((callback: () => void, delay: number) => {
const id = nextId++;
timers.push({ id, callback, delay });
return id;
});
const clearTimer = vi.fn((id: number) => {
const index = timers.findIndex((timer) => timer.id === id);
if (index >= 0) timers.splice(index, 1);
});
let controller: Record<string, unknown> | undefined;
beforeCreate?.(textarea, () => controller);
controller = api.create({
textarea,
keydownTarget: element,
render,
clear,
onCommit,
scheduleFrame,
cancelFrame,
setTimer,
clearTimer,
...overrides,
});
const flushFrame = () => frames.shift()?.callback();
const flushTimer = () => timers.shift()?.callback();
return {
api,
element,
textarea,
frames,
timers,
render,
clear,
onCommit,
scheduleFrame,
cancelFrame,
setTimer,
clearTimer,
controller,
flushFrame,
flushTimer,
};
}
describe('MobileImePreview', () => {
beforeEach(() => vi.restoreAllMocks());
test('collapses 500 composition updates into one latest-state frame', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
for (let i = 0; i < 500; i += 1) h.textarea.dispatch('compositionupdate', { data: `value-${i}` });
expect(h.scheduleFrame).toHaveBeenCalledTimes(1);
expect(h.render).not.toHaveBeenCalled();
h.flushFrame();
expect(h.render).toHaveBeenCalledOnce();
expect(h.render).toHaveBeenLastCalledWith({ text: 'value-499', phase: 'provisional' });
expect(h.onCommit).not.toHaveBeenCalled();
expect(h.textarea.value).toBe('unchanged');
});
test('replaces provisional text for replacement, backspace, and composing input', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'abcdef' });
h.textarea.dispatch('compositionupdate', { data: 'xy' });
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: 'xy', phase: 'provisional' });
h.textarea.dispatch('input', { data: '', isComposing: true });
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: '', phase: 'provisional' });
expect(h.onCommit).not.toHaveBeenCalled();
});
test('caps the preview without changing the terminal handoff value', () => {
const h = harness();
const value = '界'.repeat(2050);
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: value });
h.flushFrame();
expect(h.render.mock.calls[0][0].text).toHaveLength(2048);
h.textarea.dispatch('compositionend', { data: value });
expect(h.controller.consumeTerminalData(value)).toBe(true);
expect(h.onCommit).toHaveBeenCalledWith(value);
});
test('hands off only the first safe xterm onData value after finalization', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: '日本語' });
h.textarea.dispatch('compositionend', { data: '日本語' });
expect(h.controller.consumeTerminalData('日本語')).toBe(true);
expect(h.controller.consumeTerminalData('日本語')).toBe(false);
expect(h.onCommit).toHaveBeenCalledOnce();
expect(h.render).not.toHaveBeenCalled();
expect(h.frames).toHaveLength(1);
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: '日本語', phase: 'committed' });
expect(h.setTimer).toHaveBeenCalledWith(expect.any(Function), 2000);
});
test('defers finalization rendering and updates the queued frame phase to committed', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'draft' });
h.textarea.dispatch('compositionend');
expect(h.render).not.toHaveBeenCalled();
expect(h.frames).toHaveLength(1);
expect(h.controller.consumeTerminalData('final')).toBe(true);
expect(h.frames).toHaveLength(1);
expect(h.render).not.toHaveBeenCalled();
h.flushFrame();
expect(h.render).toHaveBeenCalledOnce();
expect(h.render).toHaveBeenCalledWith({ text: 'final', phase: 'committed' });
});
test('treats the first safe xterm onData value as authoritative over stale provisional data', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'teh' });
h.textarea.dispatch('compositionend');
expect(h.controller.consumeTerminalData('the')).toBe(true);
expect(h.onCommit).toHaveBeenCalledOnce();
expect(h.onCommit).toHaveBeenCalledWith('the');
expect(h.controller.consumeTerminalData('teh')).toBe(false);
});
test.each(['', '\n', 'line\rbreak', 'two\nlines', '\u0003', '\u007f'])(
'rejects non-printable or multiline terminal data %j without consuming the pending value',
(rejected) => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionend', { data: rejected });
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
expect(h.onCommit).not.toHaveBeenCalled();
}
);
test.each(['line\u2028break', 'line\u2029break'])(
'rejects Unicode line separator terminal data %j without consuming the finalization fence',
(rejected) => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionend');
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
expect(h.controller.consumeTerminalData('safe')).toBe(true);
}
);
test('keydown can finalize composition before a late compositionend', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: '確定' });
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
expect(h.controller.consumeTerminalData('確定')).toBe(true);
h.textarea.dispatch('compositionend', { data: 'stale' });
expect(h.controller.consumeTerminalData('stale')).toBe(false);
});
/**
* Stand-in for xterm 6.0: `terminal.open()` registers a CAPTURE keydown
* listener on the helper textarea (CoreBrowserTerminal.ts:379), and
* CompositionHelper.keydown (CompositionHelper.ts:94-108) finalizes the
* composition there, emitting the commit through onData synchronously, for
* every keyCode except 20/229 and 16/17/18. It is registered BEFORE the
* controller is created, exactly as terminal.open() precedes
* _initMobileImePreview().
*/
function withXtermStandIn() {
const emitted: Array<{ data: string; consumed: boolean }> = [];
let composing = false;
let composition = '';
const h = harness({}, (textarea, getController) => {
const emit = (data: string) => emitted.push({ data, consumed: getController()?.consumeTerminalData(data) });
textarea.addEventListener('compositionstart', () => {
composing = true;
composition = '';
});
textarea.addEventListener('compositionupdate', (event) => {
composition = String(event.data ?? '');
});
textarea.addEventListener(
'keydown',
(event) => {
if (composing && ![20, 229, 16, 17, 18].includes(event.keyCode as number)) {
composing = false;
emit(composition);
}
if (event.keyCode === 13) emit('\r');
},
true
);
});
return { ...h, emitted, isXtermComposing: () => composing };
}
test('Enter mid-composition hands the commit xterm emits in its capture keydown to the preview', () => {
const h = withXtermStandIn();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: '確定' });
h.flushFrame();
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
expect(h.emitted).toEqual([
{ data: '確定', consumed: true },
{ data: '\r', consumed: false },
]);
expect(h.onCommit).toHaveBeenCalledWith('確定');
expect(h.controller.state).toMatchObject({ composing: false, awaitingCommit: false, committed: true });
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: '確定', phase: 'committed' });
// The next unrelated keystroke is ordinary input, not an IME commit.
expect(h.controller.consumeTerminalData('x')).toBe(false);
});
test.each([
// keyCode 229 with isComposing:false and a real key identity: xterm keeps
// composing, so the controller must too.
['the IME composition character', 'k', 229],
['CapsLock', 'CapsLock', 20],
['Shift', 'Shift', 16],
['Control', 'Control', 17],
['Alt', 'Alt', 18],
])('a keydown for %s keeps tracking the composition xterm is still composing', (_label, key, keyCode) => {
const h = withXtermStandIn();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'か' });
h.textarea.dispatch('keydown', { key, keyCode, isComposing: false });
expect(h.isXtermComposing()).toBe(true);
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
// The preview follows the composition instead of freezing on the old value.
h.textarea.dispatch('compositionupdate', { data: 'かな' });
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: 'かな', phase: 'provisional' });
expect(h.emitted).toEqual([]);
});
test('ignores keydowns that did not target the helper textarea', () => {
const h = harness();
const sibling = new FakeNode();
sibling.parent = h.element;
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: '漢字' });
sibling.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
});
test('destroy stops the controller observing keydown on the terminal element', () => {
const h = withXtermStandIn();
h.controller.destroy();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'later' });
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
expect(h.emitted).toEqual([
{ data: 'later', consumed: false },
{ data: '\r', consumed: false },
]);
expect(h.onCommit).not.toHaveBeenCalled();
expect(h.element.listenerCount()).toBe(0);
});
describe('a commit that never reaches onData', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
function realTimerHarness() {
return harness({
setTimer: (callback: () => void, delay: number) => setTimeout(callback, delay),
clearTimer: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
});
}
test.each([
['compositionend', (h: ReturnType<typeof harness>) => h.textarea.dispatch('compositionend', { data: '' })],
[
'a finalizing keydown',
(h: ReturnType<typeof harness>) => h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13 }),
],
])('stops waiting after the same 2 s bound when finalized by %s', (_label, finalize) => {
const h = realTimerHarness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'deleted' });
finalize(h);
h.clear.mockClear();
expect(h.controller.state).toMatchObject({ awaitingCommit: true, timerPending: true });
vi.advanceTimersByTime(1999);
expect(h.controller.state.awaitingCommit).toBe(true);
vi.advanceTimersByTime(1);
expect(h.controller.state).toMatchObject({ awaitingCommit: false, latest: '', timerPending: false });
expect(h.clear).toHaveBeenCalledOnce();
// The next unrelated keystroke or paste is not adopted as the IME commit.
expect(h.controller.consumeTerminalData('x')).toBe(false);
expect(h.controller.consumeTerminalData('pasted line')).toBe(false);
expect(h.onCommit).not.toHaveBeenCalled();
});
test('a commit that arrives in time replaces the wait bound with the committed one', () => {
const h = realTimerHarness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionend', { data: '日本' });
vi.advanceTimersByTime(1500);
expect(h.controller.consumeTerminalData('日本')).toBe(true);
// The wait bound would have fired at 2000 ms; the committed bound runs
// a full 2 s from the commit instead.
vi.advanceTimersByTime(1000);
expect(h.controller.state.committed).toBe(true);
vi.advanceTimersByTime(1000);
expect(h.controller.state.committed).toBe(false);
});
test('a new composition cancels the previous wait bound', () => {
const h = realTimerHarness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionend', { data: '' });
vi.advanceTimersByTime(1500);
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'next' });
vi.advanceTimersByTime(1000);
expect(h.controller.state).toMatchObject({ composing: true, latest: 'next' });
});
});
test('generation fences stale frames and timers', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'old' });
const staleFrame = h.frames[0].callback;
h.textarea.dispatch('compositionstart');
staleFrame();
expect(h.render).not.toHaveBeenCalled();
h.textarea.dispatch('compositionend', { data: 'first' });
h.controller.consumeTerminalData('first');
const staleTimer = h.timers[0].callback;
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'new' });
h.flushFrame();
staleTimer();
expect(h.render).toHaveBeenLastCalledWith({ text: 'new', phase: 'provisional' });
});
test('predicted completion clears immediately while fallback waits for output or TTL', () => {
const predicted = harness();
predicted.textarea.dispatch('compositionstart');
predicted.clear.mockClear();
predicted.textarea.dispatch('compositionend', { data: 'one' });
predicted.controller.consumeTerminalData('one');
expect(predicted.frames).toHaveLength(1);
predicted.controller.completeCommit({ predicted: true });
expect(predicted.clear).toHaveBeenCalledOnce();
expect(predicted.frames).toHaveLength(0);
expect(predicted.timers).toHaveLength(0);
expect(predicted.controller.state.latest).toBe('');
expect(predicted.controller.state.committed).toBe(false);
const fallback = harness();
fallback.textarea.dispatch('compositionstart');
fallback.clear.mockClear();
fallback.textarea.dispatch('compositionend', { data: 'two' });
fallback.controller.consumeTerminalData('two');
fallback.controller.completeCommit({ predicted: false });
expect(fallback.clear).not.toHaveBeenCalled();
fallback.controller.noteAuthoritativeOutput();
expect(fallback.clear).toHaveBeenCalledOnce();
expect(fallback.frames).toHaveLength(0);
expect(fallback.timers).toHaveLength(0);
expect(fallback.controller.state.latest).toBe('');
expect(fallback.controller.state.committed).toBe(false);
const ttl = harness();
ttl.textarea.dispatch('compositionstart');
ttl.clear.mockClear();
ttl.textarea.dispatch('compositionend', { data: 'three' });
ttl.controller.consumeTerminalData('three');
ttl.controller.completeCommit({ predicted: false });
ttl.flushTimer();
expect(ttl.clear).toHaveBeenCalledOnce();
expect(ttl.frames).toHaveLength(0);
expect(ttl.timers).toHaveLength(0);
expect(ttl.controller.state.latest).toBe('');
expect(ttl.controller.state.committed).toBe(false);
});
test('contains re-entrant reset and destroy from commit callbacks without resurrecting work', () => {
let resetController: { reset(): void };
const reset = harness({ onCommit: () => resetController.reset() });
resetController = reset.controller;
reset.textarea.dispatch('compositionstart');
reset.textarea.dispatch('compositionupdate', { data: 'draft' });
reset.textarea.dispatch('compositionend');
expect(reset.controller.consumeTerminalData('final')).toBe(true);
expect(reset.frames).toHaveLength(0);
expect(reset.timers).toHaveLength(0);
expect(reset.controller.state.latest).toBe('');
let destroyController: { destroy(): void };
const destroy = harness({ onCommit: () => destroyController.destroy() });
destroyController = destroy.controller;
destroy.textarea.dispatch('compositionstart');
destroy.textarea.dispatch('compositionend');
expect(destroy.controller.consumeTerminalData('final')).toBe(true);
expect(destroy.textarea.listenerCount()).toBe(0);
expect(destroy.element.listenerCount()).toBe(0);
expect(destroy.frames).toHaveLength(0);
expect(destroy.timers).toHaveLength(0);
expect(destroy.controller.state.latest).toBe('');
});
test('contains re-entrant render and clear callbacks', () => {
let renderController: { reset(): void };
const render = harness({ render: () => renderController.reset() });
renderController = render.controller;
render.textarea.dispatch('compositionstart');
render.textarea.dispatch('compositionupdate', { data: 'draft' });
expect(() => render.flushFrame()).not.toThrow();
expect(render.frames).toHaveLength(0);
expect(render.timers).toHaveLength(0);
expect(render.controller.state.latest).toBe('');
let clearController: { destroy(): void };
const clear = harness({ clear: () => clearController?.destroy() });
clearController = clear.controller;
expect(() => clear.textarea.dispatch('compositionstart')).not.toThrow();
expect(clear.textarea.listenerCount()).toBe(0);
expect(clear.element.listenerCount()).toBe(0);
expect(clear.frames).toHaveLength(0);
expect(clear.timers).toHaveLength(0);
});
test('fails open when frame or timer schedulers throw', () => {
const frame = harness({
scheduleFrame: () => {
throw new Error('frame scheduler');
},
});
frame.textarea.dispatch('compositionstart');
expect(() => frame.textarea.dispatch('compositionupdate', { data: 'safe' })).not.toThrow();
expect(frame.controller.state.framePending).toBe(false);
let lateFrame: (() => void) | undefined;
const timer = harness({
scheduleFrame: (callback: () => void) => {
lateFrame = callback;
return 1;
},
cancelFrame: () => {},
setTimer: () => {
throw new Error('timer scheduler');
},
});
timer.textarea.dispatch('compositionstart');
timer.clear.mockClear();
timer.textarea.dispatch('compositionend');
expect(() => timer.controller.consumeTerminalData('safe')).not.toThrow();
expect(timer.controller.state.committed).toBe(false);
expect(timer.controller.state.latest).toBe('');
expect(timer.controller.state.framePending).toBe(false);
expect(timer.controller.state.timerPending).toBe(false);
expect(timer.clear).toHaveBeenCalledOnce();
lateFrame?.();
expect(timer.render).not.toHaveBeenCalled();
const tokenlessTimer = harness({ setTimer: () => undefined });
tokenlessTimer.textarea.dispatch('compositionstart');
tokenlessTimer.clear.mockClear();
tokenlessTimer.textarea.dispatch('compositionend');
expect(tokenlessTimer.controller.consumeTerminalData('safe')).toBe(true);
expect(tokenlessTimer.controller.state.committed).toBe(false);
expect(tokenlessTimer.controller.state.latest).toBe('');
expect(tokenlessTimer.controller.state.framePending).toBe(false);
expect(tokenlessTimer.controller.state.timerPending).toBe(false);
expect(tokenlessTimer.frames).toHaveLength(0);
expect(tokenlessTimer.timers).toHaveLength(0);
expect(tokenlessTimer.clear).toHaveBeenCalledOnce();
const cancellation = harness({
cancelFrame: () => {
throw new Error('frame cancellation');
},
clearTimer: () => {
throw new Error('timer cancellation');
},
});
cancellation.textarea.dispatch('compositionstart');
cancellation.textarea.dispatch('compositionupdate', { data: 'draft' });
cancellation.textarea.dispatch('compositionend');
cancellation.controller.consumeTerminalData('final');
expect(() => cancellation.controller.reset()).not.toThrow();
expect(cancellation.controller.state.framePending).toBe(false);
expect(cancellation.controller.state.timerPending).toBe(false);
});
test('authoritative output never clears active provisional composition', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.clear.mockClear();
h.textarea.dispatch('compositionupdate', { data: 'active' });
h.flushFrame();
h.controller.noteAuthoritativeOutput();
expect(h.clear).not.toHaveBeenCalled();
});
test('blur and reset cancel scheduled work and clear visual state', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.clear.mockClear();
h.textarea.dispatch('compositionupdate', { data: 'pending' });
h.textarea.dispatch('blur');
expect(h.frames).toHaveLength(0);
expect(h.clear).toHaveBeenCalledOnce();
h.textarea.dispatch('compositionstart');
h.clear.mockClear();
h.textarea.dispatch('compositionupdate', { data: 'again' });
h.controller.reset();
expect(h.frames).toHaveLength(0);
expect(h.clear).toHaveBeenCalledOnce();
expect(h.controller.state.latest).toBe('');
expect(h.controller.state.committed).toBe(false);
});
test('destroy is idempotent and removes listeners and scheduled work', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'pending' });
expect(h.textarea.listenerCount()).toBe(5);
expect(h.element.listenerCount()).toBe(1);
h.controller.destroy();
h.controller.destroy();
expect(h.textarea.listenerCount()).toBe(0);
expect(h.element.listenerCount()).toBe(0);
expect(h.frames).toHaveLength(0);
h.textarea.dispatch('compositionupdate', { data: 'ignored' });
expect(h.scheduleFrame).toHaveBeenCalledOnce();
expect(() => h.controller.reset()).not.toThrow();
expect(h.controller.state.latest).toBe('');
expect(h.controller.state.framePending).toBe(false);
expect(h.controller.state.timerPending).toBe(false);
});
test('contains render, clear, and commit callback exceptions', () => {
const h = harness({
render: vi.fn(() => {
throw new Error('render');
}),
clear: vi.fn(() => {
throw new Error('clear');
}),
onCommit: vi.fn(() => {
throw new Error('commit');
}),
});
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'safe' });
expect(() => h.flushFrame()).not.toThrow();
h.textarea.dispatch('compositionend', { data: 'safe' });
expect(() => h.controller.consumeTerminalData('safe')).not.toThrow();
expect(() => h.controller.reset()).not.toThrow();
});
test.each([
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
[{ userAgent: 'Mozilla/5.0 (iPad) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
[{ userAgent: 'Mozilla/5.0 (iPod) AppleWebKit/605.1.15', maxTouchPoints: 1 }, true],
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 2 }, true],
[{ userAgent: 'CriOS/120.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
[{ userAgent: 'Mozilla/5.0 (iPhone) Gecko/120', maxTouchPoints: 5 }, false],
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 0 }, false],
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 1 }, false],
[{ userAgent: 'Mozilla/5.0 (Android) AppleWebKit/537.36', maxTouchPoints: 5 }, false],
])('detects iOS WebKit touch eligibility for %j', (nav, expected) => {
expect(harness().api.isIosWebKitTouch(nav)).toBe(expected);
});
});
+43 -1
View File
@@ -17,7 +17,28 @@
* Port: N/A (app.inject).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
// Unreachable-mount seam: `stat()` of a path under this root never settles (a hard
// network mount that went away), so the bounded path probe can be driven to its
// stall cap. Every other stat is the real one. The short timeout is read at import.
const deadMount = vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return { root: '/mnt/codeman-clone-test-dead', releases: [] as Array<() => void> };
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (String(path).startsWith(deadMount.root + '/')) {
return new Promise((resolve) => deadMount.releases.push(() => resolve({} as never)));
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { execFileSync } from 'node:child_process';
@@ -38,6 +59,8 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { isGitAvailable } from '../../src/git-clone.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
const gitPresent = isGitAvailable();
@@ -252,6 +275,25 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
});
it('still warns about repo-supplied .claude settings while unrelated mounts are unreachable', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `${deadMount.root}/nas-${i}/project`);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
// Engage the probe's stall cap: every new bounded probe is now refused.
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
created.push('warns-under-cap');
const res = await clone({ name: 'warns-under-cap', repository: origin });
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
} finally {
deadMount.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
warn.mockRestore();
}
});
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
created.push('hooked');
await clone({ name: 'hooked', repository: origin });
+196
View File
@@ -0,0 +1,196 @@
/**
* @fileoverview POST /api/cases with a `path`: create a new case in a custom folder. Real
* filesystem under test/setup.ts's temp HOME (the path policy itself is in test/case-path.test.ts).
* Port: N/A (app.inject()).
*
* This suite deletes and rewrites the linked-cases registry. Under test/setup.ts that file lives in a
* throwaway HOME; a raw `npx vitest` (no setup) would reach the real ~/.codeman, so every test refuses
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
* safeRmHomeTree, so a run can only ever delete what it created.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import { safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
/** test/setup.ts's temp HOME (its prefix is pinned by test/test-env-isolation.test.ts). */
const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-');
let workDir = '';
const work = () => workDir;
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const create = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/cases', payload });
beforeEach(() => {
if (!sandboxed()) {
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
}
// Recursive: the rollback tests turn the registry into a directory.
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (sandboxed()) safeRmHomeTree(LINKED());
});
describe('POST /api/cases with a custom path', () => {
it('creates the folder, scaffolds it like a normal case, and registers it as a linked case', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'my-app');
const res = await create(app, { name: 'my-app', description: 'A thing', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'my-app', path: target });
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('my-app');
expect(existsSync(join(target, 'src'))).toBe(true);
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
expect(linked()).toEqual({ 'my-app': target });
});
it('appears in GET /api/cases at its custom path', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'listed');
await create(app, { name: 'listed', path: target });
const list = (await app.inject({ method: 'GET', url: '/api/cases' })).json();
const cases = Array.isArray(list) ? list : list.data;
expect(cases.find((c: { name: string }) => c.name === 'listed')).toMatchObject({ path: target });
});
it('expands ~ and fills an existing EMPTY folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'empty-one'));
const res = await create(app, { name: 'empty-one', path: `~/${basename(work())}/empty-one` });
expect(res.statusCode).toBe(200);
expect(existsSync(join(work(), 'empty-one', 'CLAUDE.md'))).toBe(true);
});
it('leaves the cases directory alone: nothing is created under codeman-cases', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'elsewhere', path: join(work(), 'elsewhere') });
expect(existsSync(join(homedir(), 'codeman-cases', 'elsewhere'))).toBe(false);
});
it('refuses a folder that already has files (409) and touches nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'existing'));
writeFileSync(join(work(), 'existing', 'keep.txt'), 'mine');
const res = await create(app, { name: 'existing', path: join(work(), 'existing') });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/Link Existing/);
expect(readdirSync(join(work(), 'existing'))).toEqual(['keep.txt']);
expect(linked()).toEqual({});
});
it.each([
['a system folder', () => '/etc/my-case', 400],
['a credential folder', () => join(homedir(), '.ssh', 'x'), 400],
['the home folder itself', () => homedir(), 400],
['a relative path', () => 'projects/x', 400],
['a path with traversal', () => `${work()}/../x`, 400],
['a missing parent', () => join(work(), 'nope', 'deep', 'app'), 404],
] as const)('refuses %s', async (_label, path, status) => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'x', path: path() });
expect(res.statusCode).toBe(status);
expect(linked()).toEqual({});
});
it('refuses a duplicate case name, and a folder that is already a case, without creating anything', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'one', path: join(work(), 'one') });
const dupName = await create(app, { name: 'one', path: join(work(), 'two') });
expect(dupName.statusCode).toBe(409);
expect(existsSync(join(work(), 'two'))).toBe(false);
// Same folder under another name: the first case's folder now has files, which is refused earlier.
const dupPath = await create(app, { name: 'other', path: join(work(), 'one') });
expect(dupPath.statusCode).toBe(409);
expect(linked()).toEqual({ one: join(work(), 'one') });
});
it('validates the case name like a normal create', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: '../evil', path: join(work(), 'x') });
expect(res.statusCode).toBe(400);
expect(existsSync(join(work(), 'x'))).toBe(false);
});
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'doomed'))).toBe(false);
});
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'picked'));
mkdirSync(LINKED(), { recursive: true });
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'picked'))).toBe(true);
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
});
it('a request without a path still creates under the cases directory, as before', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'plain-case' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(homedir(), 'codeman-cases', 'plain-case', 'CLAUDE.md'))).toBe(true);
expect(linked()).toEqual({});
safeRmHomeTree(join(CASES_DIR(), 'plain-case'));
});
it('refuses a target in the cases directory (400): a case there is a plain create, never a linked one', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(CASES_DIR(), 'existing-empty'), { recursive: true });
// A link from the custom parent into the cases dir is judged on its resolved form too.
symlinkSync(CASES_DIR(), join(work(), 'into-cases'));
try {
for (const path of [
join(CASES_DIR(), 'bar'),
join(CASES_DIR(), 'existing-empty'),
join(work(), 'into-cases', 'via-link'),
]) {
const res = await create(app, { name: 'bar', path });
expect(res.statusCode, path).toBe(400);
expect(res.json().error, path).toMatch(/plain Create New/);
}
expect(existsSync(join(CASES_DIR(), 'bar'))).toBe(false);
expect(existsSync(join(CASES_DIR(), 'via-link'))).toBe(false);
expect(readdirSync(join(CASES_DIR(), 'existing-empty'))).toEqual([]);
expect(linked()).toEqual({});
} finally {
safeRmHomeTree(join(CASES_DIR(), 'existing-empty'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const denied = await create(user.app, { name: 'bobs', path: join(work(), 'bobs') });
expect(denied.statusCode).toBe(403);
expect(existsSync(join(work(), 'bobs'))).toBe(false);
expect(linked()).toEqual({});
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
expect((await create(admin.app, { name: 'roots', path: join(work(), 'roots') })).statusCode).toBe(200);
});
});

Some files were not shown because too many files have changed in this diff Show More