Compare commits

..
Author SHA1 Message Date
Codeman maintainer d1cd7884d4 fix(statusline): guard the shim for Docker, drop the ancestor walk, remove on chip-off (#405)
Follow-ups to the delegating statusline shim from discussion #405, answering
the four design questions and the Docker one raised there.

Docker cases: the injected command is now a self-selecting shell guard,
`if [ -x <node> ] && [ -f <shim> ]; then exec <node> <shim>; fi;` followed by
the inline curl exporter. A Docker case bind-mounts the workspace, and with it
settings.local.json, at the same absolute path inside the container, but
neither the host's node nor ~/.codeman exists there, so a bare shim command
would have rendered a broken statusline in every container session. The same
string now runs the shim on the host and the curl inside the container. The
settings-save injection loop needs no docker guard for that reason; it does
skip remote attaches now, whose workingDir is a user@host pseudo-path.

Settings precedence: the shim reads exactly the three files Claude Code
documents, .claude/settings.local.json and .claude/settings.json under
workspace.project_dir (the launch directory), then ~/.claude/settings.json.
No ancestor walk and no user-level settings.local.json: delegating to a
command Claude Code would have ignored is the original failure in a new coat.

The bare word: all three paths that produced `codeman` are gone. The route
answers an unknown session with an empty body, formatSessionStatusText()
returns '' with nothing to show, and the inline fallback ends in `|| true`
(plus `curl -f`, so an HTTP error body never renders as the statusline). The
shim also treats a literal `codeman` from an older server as no telemetry and
prints nothing rather than a brand word when it has neither a delegate nor a
footer, which is what Claude Code shows a user with no statusline of their own.

Removal: turning the plan-usage chip OFF now takes the exporter out of the
workspaces of the caller's live Claude sessions. statusLineTelemetry:false is
sent only by the save that flips the chip off on that device
(statusLineTelemetryAction() in settings-ui.js), so a phone whose chip was
never on cannot strip the exporter a desktop's chip depends on; a second
device with the chip still on re-injects on its next save or session create.
Nothing in src/ called applyStatusLineConfig(dir, false) before.

Tests run the generated shim AND the injected command as real subprocesses
(the fallback half with the shim path pointed at nothing, the container's
view), plus both directions of the action field through PUT /api/settings.
Measured against the live server: a render costs ~85 ms through the shim
versus ~26 ms for the old inline curl (node start ~33 ms, the rest TLS to
the loopback HTTPS server plus the delegate spawn), off the input path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 13:45:32 +02:00
Michael Grundberg c375268879 feat(statusline): delegate to the statusline the exporter shadows
Claude Code ranks a repo's .claude/settings.local.json above
~/.claude/settings.json, so the statusLine Codeman injects for the Plan
Usage chip shadows whatever statusline the user configured globally. The
inline exporter then printed Codeman's own footer in its place, and
running `claude` by hand in a managed repo rendered the bare word
`codeman` — the response the server returns for an unknown session id.

The exporter becomes a generated shim, following the deepseek-status-shim
pattern: a versioned .mjs in the data dir, refreshed on a marker change,
written through temp-and-rename so a live render cannot read a
half-written file. It forwards the same payload to /api/status-telemetry
and, concurrently, resolves the statusline it shadows and prints that.
Codeman's footer still appears when there is nothing to shadow, so the
exporter keeps its value on a machine with no statusline of its own.

The delegate resolves at render time, walking the settings files Claude
Code consults from the render directory upward and then the home ones,
skipping Codeman's own entry in either form. Late resolution means
editing a global statusline needs no reinjection.

Ownership now keys on the version-free `codeman-statusline-shim` token,
and applyStatusLineConfig still reads the old /api/status-telemetry
command as ours, so managed repos upgrade in place instead of being
mistaken for hand-authored. A hand-authored statusLine is left alone
exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 641795821fdbb171f9f47c4909c6e8945465d05b)
2026-09-14 13:32:35 +02:00
Codeman maintainer c4b74415ee chore: sync CLAUDE.md version to 1.28.1
COM step 4. Staged as a single hunk: the shared checkout also holds another
session's in-progress pr-bot discussions work in this file, which is left
untouched and uncommitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:17:53 +02:00
github-actions[bot]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> d8a9e2f2bb chore: version packages (#414)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-14 13:17:30 +02:00
Codeman maintainer 708cb2cbf0 fix(tabs): let a wrapped desktop tab strip grow the header instead of clipping itself
The fixed 120px/96px caps on the two wrapped layouts were row counts in disguise: a
third row was clipped into a ~4px scroller, hiding tabs inside a container nothing
invites you to scroll, while the header had the page below it to grow into. Both
layouts now share one rule capped at var(--tab-strip-max-height, 40vh), a safety net
for an absurd session count rather than a row limit.

Verified before shipping: .header is min-height + flex-shrink: 0 so it can grow, and
terminal-ui's ResizeObserver refits the terminal when it does; updateTabOverflowMode()
returns early for any non-desktop viewport, and below 1024px mobile.css pins the header
to max-height: 48px, so this is desktop-only in effect; the selector is comma-grouped
rather than :is(), so each arm keeps (0,2,0) and mobile.css's overrides still win on
source order. PostCSS parses the file cleanly (prettier ignores styles.css).

Authored in a parallel session against this shared checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:08:57 +02:00
github-actions[bot]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 90ac13da1a chore: version packages (#412)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-14 12:59:48 +02:00
Codeman maintainer 48f30f3055 style: drop em-dashes from the text added in c2114615
House style, and these land in the changelog. Only the sentences added in the
previous commit are touched; the em-dashes in contributor text and in the
pre-existing COD-54/COD-115 comments are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:46:24 +02:00
Codeman maintainer c211461500 fix: merge-time follow-ups for #409, #404 and #399
#409 (Claude truecolor). The changeset becomes the changelog, and its premise
does not hold on tmux 3.2 or newer. Measured here on tmux 3.4: `default-terminal`
sits at its compiled default of `tmux-256color`, a live claude pane reports
`TERM=tmux-256color`, and supports-color reads that as 256 colors, where
rgb(55,55,55) lands on ESC[48;5;237m — visible, just not the color the theme
named. The invisible block the PR describes needs TERM to resolve to a 16-color
entry: tmux older than 3.2, or a ~/.tmux.conf setting `default-terminal screen`,
which Codeman's own tmux server does read (it passes no -f). Both the changeset
and the invariants paragraph now say that, so the next report here gets paired
with the reporter's tmux -V instead of being read as universal. The change itself
stands on the simpler argument: claude was one of two entries not asking for
truecolor while twelve do.

Also reorders buildClaudeEnv(). It applied the registry's unset/exports AFTER the
whole env was built, so a clis.json entry naming CODEMAN_HOOK_SECRET_FILE or PATH
would strip it on the direct-PTY path while the tmux pane kept it — buildEnvExports()
emits `...cliEnv` ahead of `export CODEMAN_MUX=1` and cannot. The block now runs
first and Codeman's own keys are assigned on top, matching the pane.

#404 (Ctrl+Z trap). Adds the missing changeset, and records what the trap does
not cover: an agent CLI already holds its tty with ISIG off (verified on three
live panes: `susp = ^Z -isig -icanon`), so this is defence for the startup window
rather than a fix for the steady state, and two input paths still reach the PTY
unfiltered — the mobile accessory bar's one-shot Ctrl and the CJK textarea.

#399 (path picker sort). The server sorts by name and cuts at 500, so the client
sorting those 500 by date gives "the newest of the first 500 by name", which is
wrong in exactly the >500-entry folder the date sort exists for. The status line
now says "(first 500 by name)" so the cut is legible, with the reasoning parked
on _sortEntries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:45:41 +02:00
Ark0N 37929cb671 Merge pull request #404 from timkjr/pr-ctrlz-suspend-trap
fix(terminal): trap Ctrl+Z in non-shell sessions to prevent accidental suspend
2026-09-14 12:35:33 +02:00
Ark0N e0ebbbdc91 Merge pull request #409 from irisitymichaelgrundberg/fix/claude-truecolor-in-panes
fix(terminal): let Claude use truecolor so its themed backgrounds render
2026-09-14 12:35:28 +02:00
Ark0N 8c237223b0 Merge pull request #399 from shenlvkang-collab/pr/path-picker-sort-jump
feat(files): let the path picker jump to a typed path and sort by name or date
2026-09-14 12:35:23 +02:00
Michael GrundbergandClaude Opus 5 dae2ac580f fix(terminal): read the colour env from the registry on every local spawn path
buildClaudeEnv(), the direct-PTY fallback taken when mux creation fails, now
reads getCli('claude').env and applies its unset and exports lists. It used to
delete COLORTERM and CLAUDECODE from a hand-maintained list of its own, which
left it contradicting the registry entry that the tmux pane and the attach
client both read. An engine value needing a mux name has nothing to resolve
against on this path, so it is skipped rather than guessed.

Claude no longer unsets NO_COLOR. The invisible-background bug does not need
it, and unsetting it overrides a preference the user set deliberately, so a
user who exports NO_COLOR globally keeps monochrome panes. The other seven
truecolor CLIs still unset it; that inconsistency is intentional and the
comment on the entry says so.

The invariants doc gains a Terminal colour env paragraph under Session launch
modes, where a reader looking up Claude will find it — the previous sentence
sat under a heading that lists only the non-Claude CLIs. It now says the lists
are the stock catalog and a clis.json override replaces them wholesale, and
that the declarations reach the tmux pane, its attach client and the direct
PTY but not a remote pane, whose command carries no env exports at all. Docker
hands COLORTERM=truecolor to every mode, including the two the registry says
must unset it.

The changeset named six peer CLIs and there are seven: deepseek also exports
truecolor. A test beside the existing OpenCode assertion pins the new
behaviour, so a future registry edit cannot make the backgrounds vanish again
in silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:42:30 +02:00
Michael GrundbergandClaude Opus 5 7767b16d4f fix(terminal): let Claude use truecolor so its themed backgrounds render
Claude draws the user's own messages as a block of background color, and
inside a Codeman pane that block was invisible. tmux hands each pane
TERM=screen, which supports-color reads as 16 colors, and Claude's registry
entry deleted COLORTERM on top of that. Claude therefore quantized every RGB
color its theme asked for down to the basic palette, where rgb(55, 55, 55)
and every other dark background becomes ESC[40m, the terminal's own black.
Changing the color in a custom Claude theme moved nothing on screen.

Claude now exports COLORTERM=truecolor and unsets NO_COLOR, matching codex,
gemini, antigravity, pi, grok and omp. CLAUDECODE stays unset, because Claude
reads it as a signal that it is running nested inside itself. Both the tmux
session and the attach client read this one registry entry, so they cannot
disagree.

PR #3 introduced the unset in February, citing xterm.js#484 for the claim
that xterm.js mishandles truecolor. xterm.js closed that issue in April 2019,
Codeman now depends on @xterm/xterm 6, and TmuxManager already sets
terminal-overrides ",*:Tc" on its own tmux server, so 24-bit color reaches
the browser today for every CLI that asks for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 12:15:28 +02:00
timkjrandClaude Sonnet 5 0cedf05d13 fix(terminal): trap Ctrl+Z in non-shell sessions to prevent accidental suspend
Ctrl+Z (SIGTSTP) suspends the foreground job on the pane's tty. In a plain
shell session that's the user's own job-control tool (suspend, fg back),
but in claude/omp/pi/codex/etc. sessions it stops an unattended agent loop
dead with no visible output — the same failure shape as an XOFF freeze,
just via job control instead of tty flow control. Ink-based TUIs usually
run in raw mode (ISIG off) where ^Z is inert, but that only holds once the
CLI is actually running and stays in raw mode; it's live at the shell
prompt before launch and during any raw-mode toggle.

Swallow it client-side in attachCustomKeyEventHandler, mode-gated so shell
sessions keep normal job control, mirroring the existing Ctrl+V/Ctrl+Backspace
interception pattern in the same handler. Case-insensitive key match (Caps
Lock flips ev.key to 'Z' without setting shiftKey, so a plain === 'z' check
let the exact suspend keystroke this exists to catch slip through).

Also cover subagent/teammate terminal windows (panels-ui.js's
initTeammateTerminal), which render a separate xterm instance with no
custom key handler at all and are always running an agent CLI — never a
shell — so the trap there is unconditional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 18:30:48 -05:00
shenlvkang-collabandClaude Fable 5.1 58b4cb06d8 feat(files): let the path picker jump to a typed path and sort by name or date
The picker's current-folder line was a read-only breadcrumb, so reaching a
deep folder meant tapping through every level, and the listing was fixed to
name order, so the file an agent had just written was somewhere in a
500-entry list.

The current folder is now an editable field: Enter or Go jumps there, a full
file path lands in its folder with that file selected, and a path that does
not resolve keeps the listing you had and says so, instead of the reset to
the root that a stale initialPath gets. A Sort control orders the listing by
name or modified time in either direction, folders always first, and the
choice is remembered per device like the hidden toggle. Each entry shows a
compact modified time (time of day today, month-day this year, else the
date).

GET /api/filesystem/browse stamps every entry with mtimeMs to make that
possible; the stat that already fetched a file's size now serves both, so
it is still one stat per entry. Entries without an mtime (an older server,
the in-container listing) sort after dated ones and then by name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
2026-09-10 12:42:33 +08:00
32 changed files with 2116 additions and 101 deletions
@@ -0,0 +1,28 @@
---
"aicodeman": patch
---
fix(statusline): stop the plan-usage exporter from stealing the user's statusline (#405)
Claude Code ranks a repo's `.claude/settings.local.json` above `~/.claude/settings.json`, so
the statusLine Codeman injects for the Plan Usage chip shadowed whatever statusline the user
had configured globally, and running `claude` by hand in a managed repo rendered the bare word
`codeman`. The exporter is now a generated, delegating shim (`src/statusline-shim.ts`, the
`deepseek-status-shim` pattern): it forwards the same payload to `/api/status-telemetry` and,
concurrently, runs the statusline it shadows and prints that. Codeman's footer appears only when
there is nothing to shadow, and with neither the line stays blank. The delegate is resolved at
render time from the three settings files Claude Code documents (`workspace.project_dir` first,
then `~/.claude/settings.json`), never from an ancestor directory or a user-level
`settings.local.json`.
The injected command is a self-selecting shell guard that runs the shim where it exists and
falls through to the inline curl exporter where it does not, so the same bind-mounted
`settings.local.json` still reports telemetry from inside a Docker case's container. Ownership
accepts both the new `codeman-statusline-shim` token and the old `/api/status-telemetry`
command, so repos managed by an older Codeman upgrade in place. `POST /api/status-telemetry`
answers an unknown session with an empty body instead of `codeman`, and the session-status
footer is empty rather than a brand word when the payload carries nothing to show.
Turning the Plan Usage chip off now removes the exporter from the workspaces of your live Claude
sessions. The removal rides only the settings save that flips the chip off on a device, so a
phone whose chip was never on cannot strip the exporter a desktop depends on.
+97
View File
@@ -1,5 +1,102 @@
# aicodeman
## 1.28.1
### Patch Changes
- 708cb2c: fix(tabs): let a wrapped desktop tab strip grow the header instead of clipping itself
The wrapped tab strip carried fixed height caps (120px for the manual two-row layout,
96px for measured auto-wrap) that were row counts in disguise. A third row of tabs was
clipped into a roughly 4px scroller, so the tab being looked for sat off-screen inside a
container nothing invites you to scroll, while the header had the whole page below it to
grow into. The header is `min-height` plus `flex-shrink: 0`, and terminal-ui's
ResizeObserver refits the terminal on its own, so growing it costs nothing.
Both wrapped layouts now share one rule capped at `var(--tab-strip-max-height, 40vh)`.
That cap is a safety net for an absurd session count rather than a row limit: past it the
scroller comes back, which still beats a header that swallows the terminal. Nothing sets
`--tab-strip-max-height` yet, so today it is the 40vh fallback plus a hook for a future
control.
Desktop only in effect. `tabs-auto-wrap` is applied by `updateTabOverflowMode()`, which
returns early for anything that is not a desktop viewport, and below 1024px `mobile.css`
pins the header to `max-height: 48px` so it cannot grow at all. The two rules are
comma-grouped rather than wrapped in `:is()`, so each arm keeps its own (0,2,0)
specificity and `mobile.css`'s matching overrides still win on source order.
## 1.28.0
### Minor Changes
- 58b4cb0: feat(files): let the path picker jump to a typed path and sort by name or date
The picker's current-folder line was read-only, so reaching a deep folder meant tapping
through every level, and its listing was fixed to name order, so the file an agent had
just written was somewhere in a 500-entry list. The current folder is now an editable
field (Enter or Go jumps there, a full file path lands in its folder with the file
selected, and a typo keeps the listing you had instead of resetting to the root), the
listing can be sorted by name or modified time in either direction with folders always
first (the choice is remembered per device), and each entry shows a compact modified
time. `GET /api/filesystem/browse` entries carry `mtimeMs` to make that possible, with
one stat per entry.
### Patch Changes
- c211461: fix(terminal): swallow Ctrl+Z in agent sessions so it cannot suspend a running CLI
Ctrl+Z raises SIGTSTP on the pane's tty. In a `shell` session that is ordinary job control and
is left alone, but in an agent session suspending the CLI stops an unattended loop dead with no
visible output, the same failure shape as an XOFF freeze. The key is now swallowed in
`attachCustomKeyEventHandler` for every non-shell mode, and unconditionally in the
subagent/teammate terminals, which always run an agent CLI. The match is case-insensitive,
because Caps Lock flips `ev.key` to `'Z'` without setting `shiftKey` and a plain `=== 'z'`
check would let exactly the keystroke this exists to catch through.
This is defence in depth rather than a fix for the steady state: an agent CLI holds its tty in
raw mode with ISIG off, where ^Z is already inert. It covers the moments that are not the
steady state: the window before the CLI takes the tty at startup, and any point where it hands
the tty back. Two input paths are deliberately not covered and still reach the PTY: the mobile
keyboard accessory bar's one-shot Ctrl, and the CJK composition textarea when `cjkInputEnabled`
is on. Both are separate choke points to the PTY, and both are worth covering if this ever
turns out to matter in practice.
- 7767b16: fix(terminal): let Claude use truecolor so its themed backgrounds render
Claude draws the user's own messages as a block of background color, and it renders as an
approximation of the theme color at best. Claude's registry entry deleted `COLORTERM`, which
left it the only agent CLI here besides `opencode` not asking for 24-bit color, so every RGB
color its theme asks for was quantized down to whatever palette `TERM` alone implies. Claude
now exports `COLORTERM=truecolor` like codex, gemini, antigravity, pi, grok, deepseek and omp
already do, and the block renders in the color the theme actually names.
How bad the quantization was depends on `TERM`, which is why this looks different on different
machines. On tmux 3.2 and newer, whose `default-terminal` defaults to `tmux-256color`,
supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but
not the color the theme asked for. Where `TERM` resolves to a 16-color entry instead (tmux
older than 3.2, or a `~/.tmux.conf` setting `default-terminal screen`, which Codeman's tmux
server does read), every dark background collapses to `ESC[40m`, the terminal's own black, and
the block disappears entirely. That is the case this was reported from, and a custom Claude
theme could change the color there with nothing on screen moving.
Those seven CLIs also unset `NO_COLOR`; Claude does not, so a user who exports `NO_COLOR`
globally keeps the monochrome panes they asked for. `CLAUDECODE` stays unset, because Claude
reads it as a signal that it is running nested inside itself.
`buildClaudeEnv()`, the direct-PTY fallback used when tmux is unavailable, now reads the same
registry entry as the tmux pane and its attach client instead of deleting `COLORTERM` from a
hand-maintained list of its own. It applies that entry before assigning Codeman's own
variables, mirroring `buildEnvExports()`, so a `clis.json` override naming one of them cannot
strip it on this path while the tmux pane keeps it. A remote pane still exports nothing,
because `buildRemoteLaunchCommand()` never carried these declarations, so an SSH-remote Claude
session keeps the old rendering.
PR #3 introduced the `unset COLORTERM` in February, citing xterm.js#484 for the claim that
xterm.js mishandles truecolor, and aiming to fall back to 256-color mode. xterm.js closed that
issue in April 2019, Codeman now depends on `@xterm/xterm` 6, and `TmuxManager` sets
`terminal-overrides ",*:Tc"` on its own tmux server, so 24-bit color already reaches the
browser for the CLIs that ask for it.
## 1.27.0
### Minor Changes
+2 -2
View File
@@ -75,7 +75,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.27.0 (must match `package.json`)
**Version**: 1.28.1 (must match `package.json`)
## Project Overview
@@ -207,7 +207,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine, and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry` and never overwrites a user's hand-authored statusLine. ⚠️ **Injecting a statusLine SHADOWS the user's own** (#405): a repo's `.claude/settings.local.json` outranks `~/.claude/settings.json`, and the old inline exporter printed Codeman's footer in its place, so a hand-run `claude` in any managed repo showed the bare word `codeman`. The exporter is therefore a generated, delegating shim (`src/statusline-shim.ts` writes `dataPath('codeman-statusline-shim.mjs')`, the `deepseek-status-shim` pattern) that forwards the blob and, concurrently, runs the statusline it shadows and prints THAT; the route's footer fills in only when there is nothing to shadow, and with neither it prints NOTHING (the route answers an unknown session with an empty body and `formatSessionStatusText(null)` is `''`: never a brand word). ⚠️ The delegate resolves at RENDER time from exactly the three documented files, `.claude/settings.local.json` + `.claude/settings.json` under `workspace.project_dir` (the launch dir), then `~/.claude/settings.json`, first non-ours wins: no ancestor walk and no user-level `settings.local.json`, since delegating to a command Claude Code would have ignored is the original failure in a new coat. ⚠️ **The injected command stays self-contained shell**: `if [ -x <node> ] && [ -f <shim> ]; then exec …; fi;` followed by the inline curl exporter, so the SAME bind-mounted `settings.local.json` renders the shim on the host and the curl inside a Docker case's container, where neither the host's node nor `~/.codeman` exists. Ownership (`isCodemanStatusLine()`) accepts the version-free `codeman-statusline-shim` token OR the `/api/status-telemetry` path; dropping the second makes every repo an older Codeman managed read as hand-authored. ⚠️ `statusLineTelemetry` is a settings-save ACTION field in BOTH directions: `true` on every save while the chip is on (re-injects into every live Claude workspace, remote pseudo-paths skipped), `false` ONLY on the save that turned the chip OFF on that device (`statusLineTelemetryAction()` in settings-ui.js), which removes our entry from those workspaces. Nothing called `applyStatusLineConfig(dir, false)` before, so turning the chip off left the line in every repo it had ever reached. The flip-only rule is what keeps a phone whose chip was never on from stripping the exporter a desktop depends on; a second device with the chip still on re-injects on its next save or session create and shows the last snapshot meanwhile. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
# Plan Usage Limits Display — Design & As-Built
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
> **Status: SHIPPED. Deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. **The exporter changed shape in 1.28 (discussion #405)**: it is now a self-selecting shell guard that runs a generated, delegating shim (`src/statusline-shim.ts`) where the shim exists and the inline curl below where it does not (inside a Docker case's container). The shim prints the statusline it shadows and falls back to the footer below only when there is nothing to shadow; with neither it prints nothing, and the route now answers an unknown session with an empty body, so the bare word `codeman` never renders. Turning the chip OFF now also removes the exporter from live workspaces (`statusLineTelemetry:false`, sent only on the save that flips the chip off on a device), so the "removal only via the toggle" sentences below are current again and the "never remove" ones record the 1.9-1.27 shape. See `docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry`. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
>
> Two surfaces from one `statusLine` callback:
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
+8 -4
View File
@@ -101,10 +101,14 @@ subscription plan.
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
off on phones.
It works by installing a status line exporter into Claude Code, which posts Claude's own
rate limit data back to Codeman. The exporter is marker-identified, so it only ever touches
a status line Codeman installed, never one you wrote yourself, and it prints your footer
through so the in-terminal status line still works.
It works by installing a status line exporter into each managed repo's
`.claude/settings.local.json`, which posts Claude's own rate limit data back to Codeman. The
exporter is marker-identified, so it only ever touches a status line Codeman installed, never
one you wrote yourself. A repo's status line outranks the one in `~/.claude/settings.json`,
so the exporter also runs the status line it shadows and prints that instead of its own
footer: your global status line keeps rendering in managed repos, and in a repo with no
status line of your own you get Codeman's compact session footer. Turning the chip off takes
the exporter back out of the repos of your live sessions.
The chip and the exporter are the same setting. Turning the chip on without the exporter
would leave it showing a dash forever, so resolve it in one place: **App Settings**.
+8 -4
View File
@@ -117,10 +117,14 @@ For choosing a path rather than typing one. It appears in two places:
- **Browse** in **Add Case → Link Existing**.
- The **📁 Path** key on the mobile keyboard bar.
It browses one directory at a time and can show hidden entries on request. The picker
inserts the path into your prompt **without** pressing Enter, so nothing is submitted by
accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's
`/clear` command.
It browses one directory at a time and can show hidden entries on request. The current
folder is an editable field: type or paste a path and press Enter (or **Go**) to jump
straight there, and a full file path lands in its folder with that file selected. The
**Sort** control orders each listing by name or by modified time (newest first is the
quick way to the file an agent just wrote), with folders always ahead of files; the
choice is remembered per device. The picker inserts the path into your prompt
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
key clears the unsent prompt, and never sends the agent's `/clear` command.
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.27.0",
"version": "1.28.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.27.0",
"version": "1.28.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.27.0",
"version": "1.28.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+13 -2
View File
@@ -174,8 +174,19 @@ const CLAUDE: CliEntry = {
legacyConfigAliases: { resumeId: 'resumeSessionId' },
},
env: {
exports: [],
unset: ['CLAUDECODE', 'COLORTERM'],
// Claude asks for truecolor, like every CLI here except `shell` and `opencode`.
// tmux hands the pane TERM=screen, which supports-color reads as 16 colors, and
// Claude then quantizes every RGB color its theme asks for down to that palette.
// Each dark background lands on ESC[40m, the terminal's own black, so the block
// Claude draws behind the user's own messages renders invisible. PR #3 unset
// COLORTERM here against xterm.js#484, which xterm.js had already closed in 2019,
// and Codeman now ships @xterm/xterm 6 and sets `terminal-overrides *:Tc` itself.
// The other truecolor CLIs also unset NO_COLOR. Claude does not, so a user who
// exports NO_COLOR globally keeps the monochrome panes they asked for.
// CLAUDECODE stays unset, because Claude reads it as a signal that it is running
// nested inside itself.
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['CLAUDECODE'],
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
allowedPrefixes: ['CLAUDE_CODE_'],
+57 -19
View File
@@ -39,6 +39,7 @@ import { fileURLToPath } from 'node:url';
import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
import { LEGACY_STATUSLINE_MARKER, statusLineShimGuard, STATUSLINE_SHIM_TOKEN } from './statusline-shim.js';
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
@@ -844,38 +845,75 @@ async function readWorkspaceHooksEnabled(): Promise<boolean> {
}
}
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
const STATUSLINE_MARKER = '/api/status-telemetry';
/**
* Is this statusLine command one Codeman wrote?
*
* Two markers count, and every command Codeman has ever injected carries at
* least one. The version-free `codeman-statusline-shim` token names the shim
* file the current guarded command runs; the `/api/status-telemetry` path is
* what the inline exporter posts to, in the pre-shim command AND in the
* fallback half of the current one. Both must be read as ours, or the upgrade
* mistakes an old injected command for a hand-authored line, refuses to touch
* it, and leaves the user with the shadowing exporter.
*/
export function isCodemanStatusLine(command: unknown): boolean {
return (
typeof command === 'string' &&
(command.includes(STATUSLINE_SHIM_TOKEN) || command.includes(LEGACY_STATUSLINE_MARKER))
);
}
/**
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
* (present in every managed session via tmux setenv), so the config is static.
* The inline exporter: env vars plus curl, portable by construction. It POSTs
* the statusline JSON and prints Codeman's answer, which means it SHADOWS
* whatever statusline the user configured globally. That is the cost the shim
* exists to remove, so this half only renders where the shim cannot run: inside
* a Docker case's container (the workspace is bind-mounted, `~/.codeman` and
* the host's node are not), or on a host whose data dir could not be written.
*
* `curl -sfk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
* production setup, so without -k curl returns 000 (-k is safe here, loopback
* only); -f keeps an HTTP error body off the statusline. On any failure it
* prints NOTHING: the old `|| echo codeman` is the bare word that a hand-run
* `claude` in a managed repo rendered, and that reads as a broken config.
*/
export function generateStatusLineCommand(): string {
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
// production setup; without -k curl returns 000 and the statusline shows
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
// footer is never blank if Codeman is unreachable.
function generateInlineStatusLineCommand(): string {
return (
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
`curl -sfk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- 2>/dev/null || echo codeman`
`--data @- 2>/dev/null || true`
);
}
/**
* The plan-usage statusLine exporter command.
*
* A self-selecting guard followed by the inline exporter: where the shim and
* the node binary both exist the guard `exec`s the delegating shim, which
* forwards the same JSON to Codeman and then prints the statusline its own
* entry shadows; anywhere else the shell falls through to the inline curl.
* The SAME injected string therefore renders correctly from the host and from
* inside a Docker case's container, which is what lets a bind-mounted
* `settings.local.json` carry it. See `statusLineShimGuard()`.
*/
export function generateStatusLineCommand(): string {
const guard = statusLineShimGuard();
const inline = generateInlineStatusLineCommand();
return guard ? `${guard} ${inline}` : inline;
}
/**
* Add or remove Codeman's plan-usage statusLine exporter in
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
* (command targets `/api/status-telemetry`), so a user's hand-authored
* statusLine is never removed OR overwritten — on both the enable and disable
* paths we bail out when an existing statusLine isn't ours. Callers gate on
* Claude mode. Merges, preserving all other keys (hooks, env, model).
* (see `isCodemanStatusLine`, which accepts the shim and the pre-shim inline
* form), so a user's hand-authored statusLine is never removed OR overwritten
* — on both the enable and disable paths we bail out when an existing
* statusLine isn't ours. An enable on a repo still carrying the old inline
* command upgrades it to the shim in place. Callers gate on Claude mode.
* Merges, preserving all other keys (hooks, env, model).
*/
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
@@ -889,7 +927,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
}
const current = existing.statusLine as { command?: unknown } | undefined;
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
const isOurs = !!current && isCodemanStatusLine(current.command);
if (enabled) {
const desired = generateStatusLineCommand();
+32 -5
View File
@@ -13,6 +13,7 @@ import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { compareVersions } from './utils/dependency-checker.js';
import { dataPath } from './config/instance.js';
import { getCli } from './config/cli-registry/registry.js';
/**
* Build Claude CLI permission flags based on the configured mode.
@@ -169,6 +170,36 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
...process.env,
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
};
// The colour and identity vars come from the registry entry, the same source
// buildEnvExports() and buildMuxAttachEnv() read, so this fallback cannot drift from
// the tmux pane the way a hand-maintained list here did.
// ⚠️ This block runs BEFORE Codeman's own keys are assigned, mirroring
// buildEnvExports(), where `...cliEnv` is emitted ahead of `export CODEMAN_MUX=1`.
// Applied afterwards it would outrank them: `unset` and `exports` are config
// (`~/.codeman/clis.json` overrides any entry), so an entry naming
// CODEMAN_HOOK_SECRET_FILE or PATH would strip or rewrite it on this path while the
// tmux pane, where Codeman's exports come last, kept its own value.
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
const cliEnv = getCli('claude')?.env;
for (const name of cliEnv?.unset ?? []) delete env[name];
for (const item of cliEnv?.exports ?? []) {
// A direct PTY has no mux, so `muxName` has no value to resolve against. Claude
// declares literals only; an unresolvable engine value is skipped, never guessed.
const value =
typeof item.value === 'string'
? item.value
: item.value.engine === 'sessionId'
? sessionId
: item.value.engine === 'codemanPrefixedSessionId'
? `codeman_${sessionId}`
: undefined;
if (value !== undefined) env[item.name] = value;
}
Object.assign(env, {
PATH: getAugmentedPath(),
TERM: 'xterm-256color',
// Inform Claude it's running within Codeman (helps prevent self-termination)
@@ -180,11 +211,7 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
// as the literal "CODEMAN_API_URL=undefined" (COD-115).
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
};
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
delete env.COLORTERM;
delete env.CLAUDECODE;
});
return env;
}
+433
View File
@@ -0,0 +1,433 @@
/**
* @fileoverview The plan-usage statusLine exporter, as a delegating shim.
*
* ## Why this exists
*
* Claude Code hands its statusLine command a JSON blob on stdin before every
* render, and on a subscription that blob is the ONLY place `rate_limits`
* surfaces. No hook event carries plan usage. So Codeman takes the statusLine
* slot purely as a data tap for the header "Plan Usage Limits" chip.
*
* Taking that slot has a cost the original inline exporter did not pay back.
* Claude Code ranks a repo's `.claude/settings.local.json` above the user's
* `~/.claude/settings.json`, so writing a statusLine into a managed repo
* SHADOWS whatever statusline the user configured globally. The inline exporter
* then printed Codeman's own footer in its place, and a user who ran `claude`
* by hand in a managed repo saw the bare word `codeman` (discussion #405: seven
* repositories before the cause was found).
*
* This shim keeps the data tap and gives the line back. It forwards the blob
* exactly as before, resolves the statusline it is shadowing, runs that command
* with the same blob on stdin, and prints its output. Codeman's own footer
* still appears when there is nothing to shadow, so the exporter remains useful
* on a machine with no statusline of its own and stops being a thief on one
* that has it.
*
* ## How the delegate is resolved
*
* At RENDER time, not at injection time, from exactly the three files Claude
* Code documents for a project: `.claude/settings.local.json` and
* `.claude/settings.json` under the directory Claude Code was launched in
* (`workspace.project_dir` in the blob), then `~/.claude/settings.json`. The
* first `statusLine` that is not one of ours wins. Resolving late means a user
* who edits their global statusline sees the change immediately, with no
* reinjection and no stale command baked into a config file.
*
* Two candidates are deliberately NOT consulted, because delegating to a
* command Claude Code itself would have ignored is exactly the failure this
* shim exists to end: a user-level `~/.claude/settings.local.json` (not in the
* documented set), and the project files of any ANCESTOR of the launch
* directory (Claude Code reads project settings from the launch directory
* alone, and a walk upward can land on a `.claude` that is not a project root).
*
* ## Why the injected command is shell that MAY run the shim
*
* The shim is a file at an absolute path under this instance's data dir, run
* by the node binary Codeman itself runs on. Neither exists on the other side of
* a Docker case's bind mount: the workspace (and its `settings.local.json`) is
* mounted at the same absolute path inside the container, but `~/.codeman` and
* the host's node are not. So the injected command is a self-selecting guard:
* run the shim when both paths resolve, else fall through to the inline curl
* exporter, which is env vars plus curl and works wherever the hooks do. The
* SAME file therefore renders correctly from the host and from inside the
* container, and the inline form is also what a wiped data dir degrades to.
*
* ## Why it is generated rather than committed
*
* Same reasoning as `deepseek-status-shim`: the shim must be a file at a stable
* absolute path in a git clone, in an `npm i -g aicodeman` install where only
* `dist` ships, and under any `CODEMAN_INSTANCE`. Writing it into the data dir
* covers all three from one code path and single-sources the content here.
*
* @module statusline-shim
*/
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from './config/instance.js';
/**
* Bumped whenever SHIM_SOURCE changes, and embedded in the generated file so
* `ensureStatusLineShim()` can tell a current shim from one an older Codeman
* wrote. Without it an upgraded Codeman would either rewrite on every session
* create or leave a stale shim in place forever.
*/
const SHIM_VERSION = 1;
const SHIM_MARKER = `codeman-statusline-shim v${SHIM_VERSION}`;
/**
* Version-agnostic ownership token, and the shim's own loop guard.
*
* It appears in the generated file's NAME, so it is a substring of the injected
* command for every shim version. Two separate decisions key on that:
* `isCodemanStatusLine()` in hooks-config uses it to recognise a statusLine as
* Codeman's, and the shim itself uses it to skip its own entry while hunting
* for a delegate. Deciding ownership on the version-free token means bumping
* SHIM_VERSION can never disown every previously injected command.
*/
export const STATUSLINE_SHIM_TOKEN = 'codeman-statusline-shim';
/**
* The inline exporter's ownership marker: the route it posts to.
*
* Every command Codeman has ever injected carries this path, the pre-shim
* inline `curl` and the fallback half of the current guarded command alike, so
* `isCodemanStatusLine()` must keep reading it as OURS. Drop it and every repo
* an older Codeman managed reads as hand-authored: the upgrade refuses to touch
* it and the user keeps the shadowing exporter forever.
*/
export const LEGACY_STATUSLINE_MARKER = '/api/status-telemetry';
/**
* The route the shim reports to. Identical in text to the legacy marker above,
* and separate from it on purpose: one names an endpoint this code calls, the
* other names a string an old config is recognised by. Changing the route must
* not silently change what counts as an old config.
*/
const STATUS_TELEMETRY_PATH = '/api/status-telemetry';
/**
* What a pre-1.28 server answers for a session it does not know. The current
* route answers an empty body, but a shim written by a newer Codeman can be
* talking to an older one (two instances sharing a repo), and this exact word
* rendered as a statusline is the symptom the whole change exists to remove,
* so the shim treats it as "no telemetry" rather than printing it.
*/
const NO_TELEMETRY_WORD = 'codeman';
/** Wrap a path for safe use inside a single-quoted shell word. */
function shQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* The generated shim.
*
* Three behaviours are worth reading closely, because each one exists to avoid
* a specific failure the inline exporter had or would have had:
*
* - **The delegate runs concurrently with the POST.** This command executes on
* every assistant message, so its latency lands in the user's prompt. Running
* both at once costs the slower of the two rather than their sum.
* - **A failing delegate falls through, never blanks by accident.** Empty
* output, a non-zero exit, or a timeout all fall through to Codeman's footer.
* With no footer either the shim prints nothing at all, which is what a user
* with no statusline of their own gets from Claude Code anyway: the one thing
* it never prints is a brand word that reads as a broken config.
* - **Both timeouts are short and independent.** An unreachable Codeman must
* not delay a prompt by more than its own budget, and a hung delegate must
* not hold the render open indefinitely.
*/
const SHIM_SOURCE = `#!/usr/bin/env node
// ${SHIM_MARKER}
// GENERATED BY CODEMAN. Do not edit: rewritten from src/statusline-shim.ts
// whenever its version marker changes.
//
// Forwards Claude Code's statusline JSON to this Codeman instance (the only
// source of plan rate-limit numbers) and then prints the statusline this entry
// shadows, so taking the slot costs the user nothing.
import { existsSync, readFileSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { homedir } from 'node:os'
// The HTTP transport is imported lazily, inside postTelemetry(): node:http
// costs ~38 ms to load on a fast Linux box (measured, versus ~4 ms for
// node:https alone), and this file runs on every assistant message. Loading
// only the transport the URL needs, and none outside a managed session, is
// most of the difference between an 80 ms render and a 110 ms one.
const SHIM_TOKEN = ${JSON.stringify(STATUSLINE_SHIM_TOKEN)}
const LEGACY_MARKER = ${JSON.stringify(LEGACY_STATUSLINE_MARKER)}
const NO_TELEMETRY_WORD = ${JSON.stringify(NO_TELEMETRY_WORD)}
const POST_TIMEOUT_MS = 1500
const DELEGATE_TIMEOUT_MS = 4000
let input = ''
try {
input = readFileSync(0, 'utf-8')
} catch {
// No stdin (a TTY, or a closed pipe): the delegate still deserves a run.
}
if (!input.trim()) input = '{}'
let parsed = {}
try {
parsed = JSON.parse(input)
} catch {
// Malformed payload: still forward it verbatim and still run the delegate.
// Codeman's parser is defensive and the delegate may not need the JSON.
}
if (!parsed || typeof parsed !== 'object') parsed = {}
const str = (value) => (typeof value === 'string' && value ? value : '')
const workspace = parsed.workspace && typeof parsed.workspace === 'object' ? parsed.workspace : {}
// Claude Code reads a project's settings from the directory it was LAUNCHED in,
// which the blob reports as workspace.project_dir; current_dir/cwd can drift
// from it when the working directory changes mid-session. The process cwd is
// the last resort for a blob that carries neither.
const projectDir = str(workspace.project_dir) || str(workspace.current_dir) || str(parsed.cwd) || process.cwd()
/**
* The settings files Claude Code consults for this render, highest precedence
* first: the documented set is exactly these three. No ancestor of the launch
* directory and no user-level settings.local.json: Claude Code reads neither,
* and delegating to a command it would have ignored is the failure this shim
* exists to end.
*/
function settingsCandidates() {
return [
join(projectDir, '.claude', 'settings.local.json'),
join(projectDir, '.claude', 'settings.json'),
join(homedir(), '.claude', 'settings.json'),
]
}
/** The first statusLine command that is not one of ours, or null. */
function resolveDelegate() {
for (const file of settingsCandidates()) {
if (!existsSync(file)) continue
let settings
try {
settings = JSON.parse(readFileSync(file, 'utf-8'))
} catch {
continue // Malformed file: Claude Code would ignore it too.
}
const line = settings && settings.statusLine
if (!line || typeof line !== 'object') continue
if (line.type && line.type !== 'command') continue
const command = line.command
if (typeof command !== 'string' || !command.trim()) continue
// Our own entry, in the guarded shim form or the pre-shim inline form.
// Delegating to either one would recurse or double-report.
if (command.includes(SHIM_TOKEN) || command.includes(LEGACY_MARKER)) continue
return command
}
return null
}
/** Run the shadowed statusline with the same JSON on stdin. Never rejects. */
function runDelegate(command) {
return new Promise((resolve) => {
// bash when it exists: a user's statusline may well use bashisms, and
// /bin/sh is dash on Debian-family systems.
const shell = existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh'
let child
try {
child = spawn(shell, ['-c', command], { stdio: ['pipe', 'pipe', 'ignore'] })
} catch {
return resolve(null)
}
let out = ''
let settled = false
const finish = (value) => {
if (settled) return
settled = true
resolve(value)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish(out.trim() ? out : null) // partial output beats no output
}, DELEGATE_TIMEOUT_MS)
timer.unref?.()
child.stdout.on('data', (chunk) => {
out += chunk
})
child.on('error', () => {
clearTimeout(timer)
finish(null)
})
child.on('close', (code) => {
clearTimeout(timer)
// A non-zero exit that still printed something is worth showing: plenty
// of statusline scripts end on the exit code of their last command.
const usable = out.trim().length > 0 || code === 0
finish(usable ? out : null)
})
child.stdin.on('error', () => {}) // a delegate that ignores stdin closes it early
child.stdin.end(input)
})
}
/** POST the blob to Codeman. Resolves to the footer it answered, or null. */
async function postTelemetry() {
const sessionId = process.env.CODEMAN_SESSION_ID
const apiUrl = process.env.CODEMAN_API_URL
// Outside a managed session there is no session to report against, so the
// shim costs nothing beyond running the delegate.
if (!sessionId || !apiUrl) return null
let url
try {
url = new URL(${JSON.stringify(STATUS_TELEMETRY_PATH)}, apiUrl)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
let secret = ''
try {
secret = readFileSync(process.env.CODEMAN_HOOK_SECRET_FILE || '', 'utf-8').trim()
} catch {
// Missing file: the loopback bypass still applies when no tunnel runs.
}
const { default: transport } = await import(url.protocol === 'https:' ? 'node:https' : 'node:http')
const body = JSON.stringify({ sessionId, data: parsed })
return new Promise((resolve) => {
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
timeout: POST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-Codeman-Hook-Secret': secret,
},
// Loopback HTTPS with a self-signed cert (--https / tailscale installs).
rejectUnauthorized: false,
},
(res) => {
let text = ''
res.setEncoding('utf-8')
res.on('data', (chunk) => {
text += chunk
})
res.on('end', () => resolve(res.statusCode >= 200 && res.statusCode < 300 ? text : null))
}
)
req.on('timeout', () => {
req.destroy()
resolve(null)
})
req.on('error', () => resolve(null))
req.end(body)
})
}
const delegateCommand = resolveDelegate()
const [delegateOut, telemetryOut] = await Promise.all([
delegateCommand ? runDelegate(delegateCommand) : Promise.resolve(null),
postTelemetry(),
])
// The shadowed line wins. Codeman's footer fills in only when there is no line
// to shadow or the delegate produced nothing. With neither, print NOTHING: a
// blank statusline is what Claude Code shows a user with no statusline of
// their own, while the bare brand word is the symptom this shim exists to end.
const own = delegateOut && delegateOut.trim() ? delegateOut : ''
const footer = telemetryOut && telemetryOut.trim() && telemetryOut.trim() !== NO_TELEMETRY_WORD ? telemetryOut : ''
const rendered = own || footer
if (rendered) process.stdout.write(rendered.replace(/\\n$/, ''))
`;
/** Absolute path of the generated shim for this instance. */
export function statusLineShimPath(): string {
return dataPath(`${STATUSLINE_SHIM_TOKEN}.mjs`);
}
let ensuredThisProcess = false;
/**
* Write the shim if it is missing or stale, and return its path.
*
* Idempotent and cheap: after the first call in a process it does nothing, and
* even the first call rewrites only when the on-disk marker differs. Never
* throws. A data dir that cannot be written is a degraded exporter, not a
* failed session start, so the caller receives null and injects the inline
* command alone.
*/
export function ensureStatusLineShim(): string | null {
const path = statusLineShimPath();
if (ensuredThisProcess) return path;
try {
let current = '';
try {
current = readFileSync(path, 'utf-8');
} catch {
// Missing: fall through to the write.
}
if (!current.includes(SHIM_MARKER)) {
mkdirSync(dirname(path), { recursive: true });
// Temp + rename, same reasoning as the DeepSeek shim: a live session can
// be executing this exact path at the moment an upgraded Codeman
// refreshes it, and a reader that catches a half-written file gets a
// syntax error and a blank statusline. rename(2) is atomic within the
// directory. Pid-suffixed so two instances sharing a data dir cannot
// collide on the temp name.
const tempPath = `${path}.${process.pid}.tmp`;
try {
writeFileSync(tempPath, SHIM_SOURCE, { mode: 0o700 });
// The mode argument applies only when writeFileSync CREATES the file,
// so a leftover temp from a crashed run would keep its old permissions.
chmodSync(tempPath, 0o700);
renameSync(tempPath, path);
} catch (err) {
rmSync(tempPath, { force: true });
throw err;
}
}
// Re-assert the mode even when the content matched: a shim that lost its
// executable bit (a restored backup, a copied data dir) would fail on every
// render, and the user would see the fallback string instead of their line.
chmodSync(path, 0o700);
ensuredThisProcess = true;
return path;
} catch (err) {
console.warn(`[statusline] Could not install the shim at ${path}: ${(err as Error).message}`);
return null;
}
}
/**
* The shell guard that runs the shim where it exists, for `generateStatusLineCommand()`
* in hooks-config to prepend to the inline exporter.
*
* `if [ -x <node> ] && [ -f <shim> ]; then exec <node> <shim>; fi;`: both
* tests fail inside a Docker case's container (see the fileoverview), on a
* host whose data dir was wiped, and after the node Codeman ran on moves, so
* the inline exporter after it is what renders there. `process.execPath`
* rather than a bare `node`: Codeman is itself running on that binary, so it
* is known to exist, and a managed session's PATH need not carry node at all.
* The absolute path is also self-healing, because a node that moves changes
* this string, and the next session create rewrites the config to match.
*
* Returns null when the shim could not be installed, in which case the caller
* injects the inline exporter alone.
*/
export function statusLineShimGuard(): string | null {
const shim = ensureStatusLineShim();
if (!shim) return null;
const node = shQuote(process.execPath);
const file = shQuote(shim);
return `if [ -x ${node} ] && [ -f ${file} ]; then exec ${node} ${file}; fi;`;
}
/** Test seam: forget the per-process memo so a fresh temp data dir is provisioned. */
export function resetStatusLineShimForTest(): void {
ensuredThisProcess = false;
}
+2
View File
@@ -77,6 +77,8 @@ export interface FilesystemBrowseEntry {
path: string;
type: 'file' | 'directory';
size?: number;
/** Last-modified time (ms since epoch) of the entry's target; lets the picker sort by date. */
mtimeMs?: number;
symlink?: boolean;
previewKind?: FilesystemPreviewKind;
}
+7 -3
View File
@@ -173,10 +173,14 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
* which live in the Codeman header chip. Claude requires a statusLine command to
* emit the rate_limits JSON at all, so this is what that command prints back.
* emit the rate_limits JSON at all, so this is what that command prints back
* when it has no statusline of the user's own to delegate to. With nothing to
* show it returns '' rather than a brand word: the exporter's shim reads an
* empty footer as "no telemetry", and a bare `codeman` on the statusline is the
* symptom discussion #405 opened with.
*/
export function formatSessionStatusText(s: SessionStatus | null): string {
if (!s) return 'codeman';
if (!s) return '';
const groups: string[] = [];
if (s.modelDisplayName) groups.push(s.modelDisplayName);
const tok: string[] = [];
@@ -184,7 +188,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
if (tok.length) groups.push(tok.join(' '));
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
return groups.length ? groups.join(' ') : 'codeman';
return groups.length ? groups.join(' ') : '';
}
/**
+182 -13
View File
@@ -47,11 +47,24 @@
// the picker browses Home and every configured root, so wanting dotfiles in a
// project does not imply wanting them in ~.
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
// Per-device like the hidden toggle: how you scan a folder is a habit of the
// hand, not of the workspace.
const PATH_PICKER_SORT_KEY = 'codeman:pathPickerSort';
const PATH_PICKER_SORT_MODES = [
{ value: 'name-asc', label: 'Name A→Z' },
{ value: 'name-desc', label: 'Name Z→A' },
{ value: 'mtime-desc', label: 'Newest first' },
{ value: 'mtime-asc', label: 'Oldest first' },
];
const PATH_PICKER_DEFAULT_SORT = 'name-asc';
const PathPicker = {
overlay: null,
_options: null,
_selectedPath: '',
_currentPath: '',
_entries: [],
_truncated: false,
_previousFocus: null,
_keydownHandler: null,
_loadSequence: 0,
@@ -59,6 +72,7 @@ const PathPicker = {
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_showHidden: false,
_sortMode: PATH_PICKER_DEFAULT_SORT,
/**
* Open the lazy filesystem browser.
@@ -69,7 +83,10 @@ const PathPicker = {
this.close(false);
this._options = options;
this._selectedPath = '';
this._currentPath = '';
this._entries = [];
this._showHidden = this._loadShowHidden();
this._sortMode = this._loadSortMode();
this._previousFocus = document.activeElement;
this._previousFocus?.blur?.();
@@ -90,11 +107,19 @@ const PathPicker = {
</div>
<div class="path-picker-nav">
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">&#x2191;</button>
<div class="path-picker-current" title="Current folder"></div>
<form class="path-picker-jump" title="Current folder — edit and press Enter to jump">
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
</form>
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<div class="path-picker-status" aria-live="polite">Loading...</div>
<div class="path-picker-toolbar">
<div class="path-picker-status" aria-live="polite">Loading...</div>
<label class="path-picker-sort-label">Sort
<select class="path-picker-sort" aria-label="Sort entries"></select>
</label>
</div>
<div class="path-picker-list" role="listbox"></div>
<div class="path-picker-selection">
<span class="path-picker-selection-label">Selected</span>
@@ -114,12 +139,27 @@ const PathPicker = {
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
const current = overlay.querySelector('.path-picker-current').textContent;
if (current) this.select(current);
if (this._currentPath) this.select(this._currentPath);
});
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath));
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
this._syncHiddenButton();
// Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go,
// never on each keystroke: a half-typed path is a 404 the server has to
// answer for nothing, and jumping mid-edit would yank the field around.
overlay.querySelector('.path-picker-jump').addEventListener('submit', (event) => {
event.preventDefault();
this.jumpTo(overlay.querySelector('.path-picker-current').value);
});
const sortSelect = overlay.querySelector('.path-picker-sort');
for (const mode of PATH_PICKER_SORT_MODES) {
const option = document.createElement('option');
option.value = mode.value;
option.textContent = mode.label;
sortSelect.appendChild(option);
}
sortSelect.value = this._sortMode;
sortSelect.addEventListener('change', (event) => this.setSortMode(event.target.value));
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
if (parent) this.load(parent);
@@ -169,15 +209,97 @@ const PathPicker = {
// OFF inside a hidden folder makes the current path unbrowsable again; the
// server answers 403 and load()'s catch falls back to the default root,
// which is the only place left to stand.
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
this.load(this._currentPath || '');
},
async load(path) {
_loadSortMode() {
try {
const stored = localStorage.getItem(PATH_PICKER_SORT_KEY);
return PATH_PICKER_SORT_MODES.some((mode) => mode.value === stored) ? stored : PATH_PICKER_DEFAULT_SORT;
} catch {
return PATH_PICKER_DEFAULT_SORT;
}
},
setSortMode(mode) {
if (!PATH_PICKER_SORT_MODES.some((candidate) => candidate.value === mode)) return;
this._sortMode = mode;
try {
localStorage.setItem(PATH_PICKER_SORT_KEY, mode);
} catch {}
const select = this.overlay?.querySelector('.path-picker-sort');
if (select && select.value !== mode) select.value = mode;
// Re-order what is already on screen; no round trip, no lost selection.
if (this.overlay) this.renderEntries();
},
/**
* Order entries for display. Folders always come first, whatever the mode:
* a date sort is for finding the file you just made, and the folders are the
* way past it, not the thing being looked for. An entry without an mtime (an
* older server, the in-container source) sorts after every dated one and then
* by name, so a listing never degrades into an unstable order.
*
* ⚠️ This re-orders the listing the SERVER returned, and the server cuts at
* FILESYSTEM_PICKER_ENTRY_LIMIT (500) after sorting by name. So in a folder past
* that limit, "Newest first" is the newest of the first 500 BY NAME, not the newest
* in the folder, which is the one case this sort exists for. The status line says
* "(first 500 by name)" rather than "(first 500)" so the cut is legible; ordering
* before the cut would have to happen server-side, and would cost a stat on every
* entry in the directory rather than on the 500 that are returned.
*/
_sortEntries(entries) {
const [key, direction] = this._sortMode.split('-');
const sign = direction === 'desc' ? -1 : 1;
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
return entries.slice().sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
if (key === 'mtime') {
const aTime = typeof a.mtimeMs === 'number' ? a.mtimeMs : null;
const bTime = typeof b.mtimeMs === 'number' ? b.mtimeMs : null;
if (aTime !== null && bTime !== null && aTime !== bTime) return sign * (aTime - bTime);
if (aTime === null && bTime !== null) return 1;
if (aTime !== null && bTime === null) return -1;
return byName(a, b);
}
return sign * byName(a, b);
});
},
/** Compact modified-time label: time of day today, month-day this year, else the date. */
_formatModified(mtimeMs) {
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return '';
const date = new Date(mtimeMs);
if (Number.isNaN(date.getTime())) return '';
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
if (date.toDateString() === now.toDateString()) return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
if (date.getFullYear() === now.getFullYear()) return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
},
/**
* Go to a path the user typed. A file path lands in its folder with the
* file selected, so pasting a full path from a log or a message is one Enter
* away from Select. A path that does not resolve lands in its parent folder
* when that exists (the closest place to stand) and otherwise keeps the
* current listing, and says so either way — unlike a stale initialPath, a
* typo is not a reason to throw the user back to the root.
*/
jumpTo(rawPath) {
const path = String(rawPath || '').trim();
if (!path) return;
this.load(path, { typed: true });
},
async load(path, options = {}) {
if (!this.overlay || !this._options) return;
const loadSequence = ++this._loadSequence;
const list = this.overlay.querySelector('.path-picker-list');
const status = this.overlay.querySelector('.path-picker-status');
list.replaceChildren();
const typed = !!options.typed;
if (!typed) list.replaceChildren();
status.classList.remove('error');
status.textContent = 'Loading...';
const params = new URLSearchParams();
@@ -194,13 +316,40 @@ const PathPicker = {
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
if (!this.overlay || loadSequence !== this._loadSequence) return;
this.render(result.data);
if (options.selectIfListed) {
// Landed in the typed path's folder: select the entry if it is there
// (a file path), otherwise say what the server said about the full
// path — the listing is still the closest place to stand.
if (this._entries.some((entry) => entry.path === options.selectIfListed)) {
this.select(options.selectIfListed);
} else {
status.textContent = options.failMessage || 'Path not found';
status.classList.add('error');
}
}
} catch (error) {
if (!this.overlay || loadSequence !== this._loadSequence) return;
const message = error.message || 'Failed to browse this folder';
if (typed) {
// The browse endpoint answers a FILE path with "not found" (it resolves
// folders only), so one retry lands in the parent folder and selects
// the entry from the listing. Only one level: a typo two segments up
// is an error, not a reason to climb to the root.
const slash = path.lastIndexOf('/');
if (slash > 0 && !options.selectIfListed) {
this.load(path.slice(0, slash), { typed: true, selectIfListed: path, failMessage: message });
return;
}
this.renderEntries();
status.textContent = options.failMessage || message;
status.classList.add('error');
return;
}
if (path) {
this.load('');
return;
}
status.textContent = error.message || 'Failed to browse this folder';
status.textContent = message;
status.classList.add('error');
}
},
@@ -216,19 +365,29 @@ const PathPicker = {
rootSelect.appendChild(option);
}
this.overlay.querySelector('.path-picker-current').textContent = data.path;
this._currentPath = data.path;
this.overlay.querySelector('.path-picker-current').value = data.path;
const up = this.overlay.querySelector('.path-picker-up');
up.dataset.parent = data.parent || '';
up.disabled = !data.parent;
this._entries = Array.isArray(data.entries) ? data.entries : [];
this._truncated = !!data.truncated;
this.renderEntries();
},
/** (Re)build the list from the last listing in the current sort order. */
renderEntries() {
if (!this.overlay) return;
const entries = this._sortEntries(this._entries);
const status = this.overlay.querySelector('.path-picker-status');
status.classList.remove('error');
status.textContent = data.entries.length === 0
status.textContent = entries.length === 0
? 'This folder is empty'
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
: `${entries.length} item${entries.length === 1 ? '' : 's'}${this._truncated ? ' (first 500 by name)' : ''}`;
const list = this.overlay.querySelector('.path-picker-list');
list.replaceChildren();
for (const entry of data.entries) {
for (const entry of entries) {
const row = document.createElement('div');
row.className = 'path-picker-item';
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
@@ -248,6 +407,14 @@ const PathPicker = {
name.className = 'path-picker-item-name';
name.textContent = entry.name;
open.append(icon, name);
const modified = this._formatModified(entry.mtimeMs);
if (modified) {
const meta = document.createElement('span');
meta.className = 'path-picker-item-meta';
meta.textContent = modified;
meta.title = new Date(entry.mtimeMs).toLocaleString();
open.appendChild(meta);
}
if (entry.symlink) {
const link = document.createElement('span');
link.className = 'path-picker-item-link';
@@ -417,6 +584,8 @@ const PathPicker = {
this._previousFocus = null;
this._options = null;
this._selectedPath = '';
this._currentPath = '';
this._entries = [];
if (restoreFocus) previousFocus?.focus?.();
},
};
+21
View File
@@ -2314,6 +2314,27 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Ctrl+Z (SIGTSTP/job-control suspend): a teammate/subagent pane is always
// running an agent CLI (Task-tool dispatched, never a plain shell), so
// unlike the main terminal's mode-gated trap this one is unconditional.
// Mirrors the main terminal's guard in terminal-ui.js's
// attachCustomKeyEventHandler — case-insensitive so Caps Lock (which
// flips ev.key to 'Z' without setting shiftKey) can't slip a suspend past it.
terminal.attachCustomKeyEventHandler((ev) => {
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey
) {
ev.preventDefault();
return false;
}
return true;
});
// Wait for terminal renderer to fully initialize before any writes.
// xterm.js needs a few frames after open() before write() is safe.
setTimeout(() => {
+24 -4
View File
@@ -2050,6 +2050,9 @@ Object.assign(CodemanApp.prototype, {
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
// as "previously off" — used below to detect a real OFF→ON flip.
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
// Plan-usage chip: the exporter it depends on is removed from live workspaces
// ONLY on the save that turns the chip off (see statusLineTelemetryAction).
const _prevPlanUsageChip = this.planUsageChipEnabled(_prev);
const settings = {
displayName: window.CodemanI18n?.normalizeDisplayName(
document.getElementById('appSettingsDisplayName').value
@@ -2280,9 +2283,11 @@ Object.assign(CodemanApp.prototype, {
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
// also absent from SettingsUpdateSchema, which is .strict() — sending it
// would 400 the whole settings PUT.
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
// ENABLE only, so a device with the chip OFF never strips the exporter that
// another device's chip depends on — see system-routes settings handler).
// Telemetry COLLECTION is requested out-of-band via the statusLineTelemetry
// action field: `true` on every save while the chip is on, `false` only on the
// save that turned it off here, nothing otherwise (statusLineTelemetryAction),
// so a device whose chip was never on cannot strip the exporter another
// device's chip depends on. See the system-routes settings handler.
const {
localEchoEnabled: _leo,
cjkInputEnabled: _cjk,
@@ -2316,10 +2321,11 @@ Object.assign(CodemanApp.prototype, {
sessionLineageLines: _sll,
...serverSettings
} = settings;
const statusLineTelemetry = this.statusLineTelemetryAction(_prevPlanUsageChip, settings.showPlanUsageLimits);
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
...(statusLineTelemetry === undefined ? {} : { statusLineTelemetry }),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
@@ -2592,6 +2598,20 @@ Object.assign(CodemanApp.prototype, {
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
},
// What a settings save tells the server about the plan-usage exporter, given
// the chip's state before and after the save. `true` re-injects the exporter
// into every live Claude workspace and may ride every save while the chip is
// on. `false` REMOVES it from those workspaces, and the chip is per-device
// while the exporter lives in each repo's shared settings.local.json, so it
// may ride only the save that turned the chip off on this device: a phone
// whose chip was never on must never strip what a desktop's chip depends on.
// Pure, so test/plan-usage-telemetry-action.test.ts can pin all three cases.
statusLineTelemetryAction(prevEnabled, nowEnabled) {
if (nowEnabled) return true;
if (prevEnabled) return false;
return undefined;
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
+91 -17
View File
@@ -545,18 +545,21 @@ body {
contain: layout;
}
.session-tabs.tabs-two-rows {
flex-wrap: wrap;
overflow-x: hidden;
overflow-y: auto;
max-height: 120px;
}
/* A wrapped strip GROWS the header instead of scrolling inside it. The old
fixed caps (120px tall-tabs / 96px auto-wrap) were row counts in disguise:
a third row of tabs was clipped into a 4px scroller, so the tabs you were
looking for were off-screen in a container nothing tells you to scroll —
while the header had the whole page below it to grow into (it is
`min-height` + `flex-shrink: 0`, and terminal-ui's ResizeObserver refits
the terminal on its own). `--tab-strip-max-height` is a safety net for an
absurd session count, not a row limit: past it the scroller comes back,
which beats a header that swallows the terminal. */
.session-tabs.tabs-two-rows,
.session-tabs.tabs-auto-wrap {
flex-wrap: wrap;
overflow-x: hidden;
overflow-y: auto;
max-height: 96px;
max-height: var(--tab-strip-max-height, 40vh);
}
.session-tabs::-webkit-scrollbar {
@@ -13617,24 +13620,95 @@ body.touch-device.cjk-input-visible .main {
cursor: default;
}
.path-picker-current {
/* The current path is an editable field: type or paste a path and press Enter
* (or Go) to jump there. The Go button is part of the same rounded control so
* the field keeps the width the read-only breadcrumb had. */
.path-picker-jump {
display: flex;
flex: 1;
min-width: 0;
padding: 9px 11px;
overflow-x: auto;
color: var(--accent);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
white-space: nowrap;
margin: 0;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-status {
padding: 0 14px 8px;
.path-picker-jump:focus-within {
border-color: var(--accent);
}
.path-picker-current {
flex: 1;
min-width: 0;
height: 36px;
padding: 0 10px;
color: var(--accent);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
white-space: nowrap;
background: transparent;
border: 0;
outline: none;
}
.path-picker-go {
flex: 0 0 auto;
padding: 0 10px;
color: var(--text-dim);
font-size: 0.7rem;
font-weight: 600;
background: transparent;
border: 0;
border-left: 1px solid var(--border);
cursor: pointer;
}
.path-picker-go:hover {
color: var(--accent);
}
.path-picker-toolbar {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 0 14px 8px;
}
.path-picker-status {
min-width: 0;
overflow: hidden;
color: var(--text-dim);
font-size: 0.7rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-picker-sort-label {
display: inline-flex;
flex: 0 0 auto;
gap: 6px;
align-items: center;
color: var(--text-dim);
font-size: 0.7rem;
}
.path-picker-sort {
padding: 3px 6px;
color: var(--text);
font-size: 0.7rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
}
.path-picker-item-meta {
flex: 0 0 auto;
margin-left: 8px;
color: var(--text-dim);
font-family: var(--font-mono, monospace);
font-size: 0.65rem;
white-space: nowrap;
}
.path-picker-status.error {
+23
View File
@@ -383,6 +383,29 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend): in a plain shell session this is the
// user's own job-control tool (suspend a foreground command, `fg` it back) —
// leave it alone. In every other mode (claude/omp/pi/codex/... — Ink/TUI apps
// that normally run in raw mode with ISIG off, so ^Z is usually inert there
// already) suspending the CLI stops an unattended agent loop dead with no
// visible output — the same failure shape as an XOFF freeze. Swallow it
// before xterm can send \x1a into the PTY rather than relying on every CLI's
// raw-mode state holding at every instant (startup, raw-mode toggles, etc).
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey
) {
const activeCtrlZSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
if (activeCtrlZSession && activeCtrlZSession.mode !== 'shell') {
ev.preventDefault();
return false;
}
}
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
// 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)
+10 -3
View File
@@ -876,6 +876,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
let type: FilesystemBrowseEntry['type'];
let size: number | undefined;
let mtimeMs: number | undefined;
const symlink = entry.isSymbolicLink();
if (entry.isDirectory()) {
type = 'directory';
@@ -886,6 +887,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const targetStat = await fs.stat(targetPath);
type = targetStat.isDirectory() ? 'directory' : 'file';
if (type === 'file') size = targetStat.size;
mtimeMs = targetStat.mtimeMs;
} catch {
continue;
}
@@ -894,11 +896,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
if (type === 'file' && size === undefined) {
if (mtimeMs === undefined) {
// One stat per entry: the modified time lets the picker sort by date, and
// a file's size rides along on the same call.
try {
size = (await fs.stat(targetPath)).size;
const targetStat = await fs.stat(targetPath);
mtimeMs = targetStat.mtimeMs;
if (type === 'file') size = targetStat.size;
} catch {
// The path is still selectable even when a size lookup races a change.
// The path is still selectable even when a stat races a change.
}
}
entries.push({
@@ -906,6 +912,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
path: visiblePath,
type,
size,
mtimeMs,
symlink: symlink || undefined,
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
});
+8 -4
View File
@@ -8,8 +8,10 @@
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
*
* Returns a compact plain-text status string for the exporter to print as the
* in-terminal footer (print-through), so injecting our statusLine doesn't leave
* the terminal footer blank.
* in-terminal footer when it has no statusline of the user's own to delegate to
* (see `statusline-shim.ts`). An unknown session gets an EMPTY body: the old
* brand-word answer rendered as the statusline of every hand-run `claude` in a
* managed repo, and cost discussion #405 seven repositories of debugging.
*/
import { FastifyInstance } from 'fastify';
@@ -36,10 +38,12 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
reply.type('text/plain; charset=utf-8');
// Unknown session — minimal footer, no broadcast.
// Unknown session: nothing to broadcast and nothing to print. Never a brand
// word here, it would render as the statusline (the shim treats an empty
// answer as "no telemetry" and falls through to the delegate or to blank).
if (!ctx.sessions.has(sessionId)) {
lastSig.delete(sessionId);
return 'codeman';
return '';
}
const payload = data as RawStatuslinePayload | undefined;
+25 -11
View File
@@ -1034,20 +1034,34 @@ export function registerSystemRoutes(
});
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
// into every ACTIVE Claude session's working dir so the live % starts flowing
// immediately (no new session needed). We deliberately never auto-REMOVE here:
// the exporter is benign/print-through and a per-repo settings.local.json is
// shared by sibling sessions, so one device's "off" must not yank the exporter
// another device's chip depends on. Each dir handled once.
if (statusLineTelemetry === true) {
// Telemetry COLLECTION is a per-save ACTION field in both directions. `true`
// rides every save while the chip is on: we (re)inject our exporter into every
// ACTIVE Claude session's working dir so the live % starts flowing immediately
// (no new session needed), and that is also how a second device catches up.
// `false` rides ONLY the save that turned the chip OFF on that device
// (statusLineTelemetryAction in settings-ui.js) and takes our exporter back
// out of those same dirs. Nothing called the disable path before, so turning
// the chip off left the line in every repo it had ever reached (#405). A
// per-repo settings.local.json is shared by sibling sessions and by every
// device, so the flip-only rule is what keeps a phone whose chip was never on
// from stripping the exporter a desktop's chip depends on; a device with the
// chip still on re-injects on its next save or session create and shows the
// last snapshot meanwhile. Both paths are isOurs-guarded (a hand-authored
// statusLine is never touched), remote attaches are skipped (their workingDir
// is a user@host:session pseudo-path the enable path would mkdir as a junk
// local dir), and each dir is handled once.
if (statusLineTelemetry === true || statusLineTelemetry === false) {
const user = getAuthUser(req);
const dirs = new Set<string>();
for (const session of ctx.sessions.values()) {
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir)
dirs.add(session.workingDir);
if (!getCli(session.mode)?.capabilities.statusLineTelemetry || !session.workingDir) continue;
if (session.remote) continue;
// Removal is the destructive direction: only the caller's own workspaces
// (canAccessOwned is allow-all for admins and in single-user mode).
if (!statusLineTelemetry && !canAccessOwned(user, session.owner)) continue;
dirs.add(session.workingDir);
}
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, statusLineTelemetry).catch(() => {})));
}
// Handle tunnel toggle dynamically
+83
View File
@@ -23,6 +23,7 @@ import {
updateCaseModel,
writeHooksConfig,
} from '../src/hooks-config.js';
import { LEGACY_STATUSLINE_MARKER, STATUSLINE_SHIM_TOKEN } from '../src/statusline-shim.js';
describe('generateHooksConfig', () => {
it('should return an object with hooks key', () => {
@@ -1305,3 +1306,85 @@ describe('Hook Config Generation - Extended', () => {
expect(stopHooks[0].hooks[0].command).toContain('stop');
});
});
describe('applyStatusLineConfig', () => {
const testDir = join(tmpdir(), 'codeman-statusline-config-' + Date.now());
const settingsFile = join(testDir, '.claude', 'settings.local.json');
const read = () => JSON.parse(readFileSync(settingsFile, 'utf-8'));
const write = (value: object) => {
mkdirSync(join(testDir, '.claude'), { recursive: true });
writeFileSync(settingsFile, JSON.stringify(value, null, 2));
};
beforeEach(() => {
rmSync(testDir, { recursive: true, force: true });
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('injects the guarded shim command with the inline exporter as its fallback', async () => {
await applyStatusLineConfig(testDir, true);
const { statusLine } = read();
expect(statusLine.type).toBe('command');
// The shim runs first wherever it exists: it is what gives the user their
// own statusline back. The inline half after it is what renders where the
// shim cannot (inside a Docker case's container), and it must not carry the
// brand-word fallback the old exporter printed.
expect(statusLine.command.startsWith('if [ -x ')).toBe(true);
expect(statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
expect(statusLine.command).toContain(LEGACY_STATUSLINE_MARKER);
expect(statusLine.command).not.toContain('echo codeman');
});
it('upgrades a pre-shim inline exporter in place', async () => {
// Every repo a previous Codeman managed still holds this command. If the
// ownership check missed it, the upgrade would read it as hand-authored,
// refuse to touch it, and leave the user shadowed forever.
write({
statusLine: { type: 'command', command: `curl -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` },
permissions: { allow: ['Read'] },
});
await applyStatusLineConfig(testDir, true);
const settings = read();
expect(settings.statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
expect(settings.permissions).toEqual({ allow: ['Read'] });
});
it('removes a pre-shim inline exporter on the disable path', async () => {
write({ statusLine: { type: 'command', command: `curl "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` } });
await applyStatusLineConfig(testDir, false);
expect(read().statusLine).toBeUndefined();
});
it('removes its own shim entry on the disable path', async () => {
await applyStatusLineConfig(testDir, true);
await applyStatusLineConfig(testDir, false);
expect(read().statusLine).toBeUndefined();
});
it('never touches a statusLine the user wrote themselves', async () => {
// Unchanged contract: a hand-authored entry in the repo's own file stops
// Codeman cold, so it never owns an entry it would have to restore later.
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
write({ statusLine: mine });
await applyStatusLineConfig(testDir, true);
expect(read().statusLine).toEqual(mine);
await applyStatusLineConfig(testDir, false);
expect(read().statusLine).toEqual(mine);
});
it('rewrites nothing when the shim command is already current', async () => {
await applyStatusLineConfig(testDir, true);
const before = readFileSync(settingsFile, 'utf-8');
await applyStatusLineConfig(testDir, true);
expect(readFileSync(settingsFile, 'utf-8')).toBe(before);
});
});
+294
View File
@@ -0,0 +1,294 @@
/**
* @fileoverview Path picker: sort order and the editable path field.
*
* Same jsdom harness as path-picker-hidden.test.ts: keyboard-accessory.js is
* evaluated against a jsdom window with a scripted fetch, so the assertions
* run against the real DOM the picker builds rather than string matches.
* Port: N/A
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const SORT_KEY = 'codeman:pathPickerSort';
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const jsdomDocument = jsdomWindow.document;
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): any {
const MobileDetection = { isTouchDevice: () => false };
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection);
}
type Entry = { name: string; type: 'file' | 'directory'; mtimeMs?: number };
function browseResponse(entries: Entry[], path = '/home/dev/project') {
return {
ok: true,
json: async () => ({
success: true,
data: {
path,
parent: path === '/home/dev' ? null : '/home/dev',
root: '/home/dev',
roots: [{ label: 'Home', path: '/home/dev' }],
entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })),
truncated: false,
},
}),
};
}
function errorResponse(error: string) {
return { ok: false, json: async () => ({ success: false, error }) };
}
const DAY = 24 * 60 * 60 * 1000;
const NOW = Date.now();
const LISTING: Entry[] = [
{ name: 'zeta.txt', type: 'file', mtimeMs: NOW - 3 * DAY },
{ name: 'alpha.txt', type: 'file', mtimeMs: NOW - 1 * DAY },
{ name: 'mid.txt', type: 'file', mtimeMs: NOW - 2 * DAY },
{ name: 'old-dir', type: 'directory', mtimeMs: NOW - 30 * DAY },
{ name: 'new-dir', type: 'directory', mtimeMs: NOW - 1000 },
];
describe('PathPicker sort order', () => {
let PathPicker: any;
let urls: string[];
let respond: (url: string) => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
jsdomDocument.body.replaceChildren();
urls = [];
respond = () => browseResponse(LISTING);
PathPicker = loadPathPicker(async (url: string) => {
urls.push(url);
return respond(url);
});
});
afterEach(() => {
PathPicker?.close?.(false);
jsdomDocument.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, ...options });
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
};
const names = () => Array.from(jsdomDocument.querySelectorAll('.path-picker-item-name')).map((el) => el.textContent);
const sortSelect = () => jsdomDocument.querySelector('.path-picker-sort') as HTMLSelectElement;
const setSort = (value: string) => {
sortSelect().value = value;
sortSelect().dispatchEvent(new jsdomWindow.Event('change', { bubbles: true }));
};
it('sorts by name with folders first by default', async () => {
await open();
expect(sortSelect().value).toBe('name-asc');
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
});
it('re-orders the listing without another request, keeping folders first', async () => {
await open();
const requests = urls.length;
setSort('mtime-desc');
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
setSort('mtime-asc');
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
setSort('name-desc');
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
expect(urls.length).toBe(requests);
});
it('remembers the sort mode across reopenings', async () => {
await open();
setSort('mtime-desc');
expect(jsdomWindow.localStorage.getItem(SORT_KEY)).toBe('mtime-desc');
PathPicker.close(false);
await open();
expect(sortSelect().value).toBe('mtime-desc');
});
it('ignores a corrupt stored mode and a localStorage that throws', async () => {
jsdomWindow.localStorage.setItem(SORT_KEY, 'bogus');
await open();
expect(sortSelect().value).toBe('name-asc');
PathPicker.close(false);
const getItem = vi.spyOn(jsdomWindow.localStorage.__proto__, 'getItem').mockImplementation(() => {
throw new Error('private mode');
});
try {
await open();
expect(sortSelect().value).toBe('name-asc');
setSort('mtime-asc');
expect(names()[0]).toBe('old-dir');
} finally {
getItem.mockRestore();
}
});
it('places entries without a modified time after dated ones on a date sort', async () => {
respond = () =>
browseResponse([
{ name: 'undated.txt', type: 'file' },
{ name: 'dated.txt', type: 'file', mtimeMs: NOW - DAY },
]);
await open();
setSort('mtime-desc');
expect(names()).toEqual(['dated.txt', 'undated.txt']);
setSort('mtime-asc');
expect(names()).toEqual(['dated.txt', 'undated.txt']);
});
it('shows a compact modified time only when the server supplied one', async () => {
respond = () =>
browseResponse([
{ name: 'undated.txt', type: 'file' },
{ name: 'today.txt', type: 'file', mtimeMs: NOW },
]);
await open();
const rows = Array.from(jsdomDocument.querySelectorAll('.path-picker-item'));
const meta = (row: Element) => row.querySelector('.path-picker-item-meta')?.textContent ?? null;
expect(meta(rows[0])).toMatch(/^\d{2}:\d{2}$/);
expect(meta(rows[1])).toBeNull();
});
it('styles the sort control and the modified column', () => {
expect(stylesCss).toContain('.path-picker-sort {');
expect(stylesCss).toContain('.path-picker-item-meta {');
});
});
describe('PathPicker editable path', () => {
let PathPicker: any;
let urls: string[];
let respond: (url: string) => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
jsdomDocument.body.replaceChildren();
urls = [];
respond = () => browseResponse(LISTING);
PathPicker = loadPathPicker(async (url: string) => {
urls.push(url);
return respond(url);
});
});
afterEach(() => {
PathPicker?.close?.(false);
jsdomDocument.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, ...options });
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
};
const field = () => jsdomDocument.querySelector('.path-picker-current') as HTMLInputElement;
const submit = (value: string) => {
field().value = value;
(jsdomDocument.querySelector('.path-picker-jump') as HTMLFormElement).dispatchEvent(
new jsdomWindow.Event('submit', { bubbles: true, cancelable: true })
);
};
const pathParam = (url: string) => new URL(url, 'https://localhost').searchParams.get('path');
const status = () => jsdomDocument.querySelector('.path-picker-status') as HTMLElement;
it('shows the current folder in an editable field and jumps on Enter', async () => {
await open({ initialPath: '/home/dev/project' });
expect(field().value).toBe('/home/dev/project');
const before = urls.length;
// Typing alone never fetches.
field().value = '/home/dev/oth';
field().dispatchEvent(new jsdomWindow.Event('input', { bubbles: true }));
expect(urls.length).toBe(before);
respond = () => browseResponse([{ name: 'readme.md', type: 'file' }], '/home/dev/other');
submit(' /home/dev/other ');
await vi.waitFor(() => expect(field().value).toBe('/home/dev/other'));
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/other');
expect(jsdomDocument.querySelector('.path-picker-item-name')?.textContent).toBe('readme.md');
});
it('keeps the current listing and reports the error when neither a typed path nor its parent resolves', async () => {
await open({ initialPath: '/home/dev/project' });
respond = () => errorResponse('Path not found: /home/dev/nope/deeper');
submit('/home/dev/nope/deeper');
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
expect(status().textContent).toBe('Path not found: /home/dev/nope/deeper');
// One retry on the parent, then stop: never a climb to the root.
expect(urls.slice(-2).map(pathParam)).toEqual(['/home/dev/nope/deeper', '/home/dev/nope']);
expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBe(LISTING.length);
// The typed text stays in the field so the typo can be corrected in place.
expect(field().value).toBe('/home/dev/nope/deeper');
});
it('lands in the parent folder, unselected, when only the last segment is wrong', async () => {
await open({ initialPath: '/home/dev' });
respond = (url) =>
pathParam(url) === '/home/dev/project/typo.txt'
? errorResponse('Path not found: /home/dev/project/typo.txt')
: browseResponse(LISTING);
submit('/home/dev/project/typo.txt');
await vi.waitFor(() => expect(field().value).toBe('/home/dev/project'));
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
expect(status().textContent).toBe('Path not found: /home/dev/project/typo.txt');
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('None');
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(true);
});
it('lands a typed file path in its folder with the file selected', async () => {
await open({ initialPath: '/home/dev/project' });
respond = (url) =>
pathParam(url) === '/home/dev/project/alpha.txt'
? errorResponse('Path not found: /home/dev/project/alpha.txt')
: browseResponse(LISTING);
submit('/home/dev/project/alpha.txt');
await vi.waitFor(() =>
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe(
'/home/dev/project/alpha.txt'
)
);
expect(field().value).toBe('/home/dev/project');
expect(jsdomDocument.querySelector('.path-picker-item.selected .path-picker-item-name')?.textContent).toBe(
'alpha.txt'
);
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false);
});
it('selects the current folder from the field value and refreshes in place', async () => {
await open({ initialPath: '/home/dev/project' });
(jsdomDocument.querySelector('.path-picker-current-select') as HTMLButtonElement).click();
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project');
const before = urls.length;
(jsdomDocument.querySelector('.path-picker-refresh') as HTMLButtonElement).click();
await vi.waitFor(() => expect(urls.length).toBe(before + 1));
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/project');
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* `statusLineTelemetryAction()` in settings-ui.js: the one place that decides
* what a settings save tells the server about the plan-usage exporter.
*
* The chip is per-device (desktop default ON, phones OFF), while the exporter
* it depends on lives in each repo's shared `.claude/settings.local.json`. So
* the save may send `true` freely (every save while the chip is on re-injects,
* which is how a second device catches up) but may send `false` ONLY on the
* save that turned the chip off on this device. A phone with the chip off
* saving its font size must not strip the exporter a desktop's chip depends on.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
function loadSettingsUi() {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
vm.runInContext(source, context, { filename: 'settings-ui.js' });
return CodemanApp.prototype as { statusLineTelemetryAction: (prev: boolean, now: boolean) => boolean | undefined };
}
describe('statusLineTelemetryAction', () => {
const ui = loadSettingsUi();
it('sends true on every save while the chip is on', () => {
expect(ui.statusLineTelemetryAction(true, true)).toBe(true);
expect(ui.statusLineTelemetryAction(false, true)).toBe(true);
});
it('sends false only on the save that turned the chip off', () => {
expect(ui.statusLineTelemetryAction(true, false)).toBe(false);
});
it('sends nothing from a device whose chip was already off', () => {
expect(ui.statusLineTelemetryAction(false, false)).toBeUndefined();
});
});
+44
View File
@@ -115,6 +115,50 @@ describe('file-routes', () => {
]);
});
it('stamps every entry with its modified time so the picker can sort by date', async () => {
mockedReaddir.mockResolvedValueOnce([
{ name: 'notes.txt', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: 'link', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
] as never);
mockedStat.mockImplementation(async (candidate) => {
const target = String(candidate);
if (target.endsWith('/notes.txt')) {
return { size: 42, mtimeMs: 1_700_000_000_000, isFile: () => true, isDirectory: () => false } as never;
}
if (target.endsWith('/src')) {
return { size: 4096, mtimeMs: 1_700_000_001_000, isFile: () => false, isDirectory: () => true } as never;
}
if (target.endsWith('/link')) {
return { size: 7, mtimeMs: 1_700_000_002_000, isFile: () => true, isDirectory: () => false } as never;
}
return { size: 0, mtimeMs: 0, isFile: () => false, isDirectory: () => true } as never;
});
const path = harness.ctx._session.workingDir;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
});
expect(res.statusCode).toBe(200);
const entries = JSON.parse(res.body).data.entries as Array<{
name: string;
type: string;
size?: number;
mtimeMs?: number;
}>;
expect(entries.map((entry) => [entry.name, entry.type, entry.size, entry.mtimeMs])).toEqual([
['src', 'directory', undefined, 1_700_000_001_000],
['link', 'file', 7, 1_700_000_002_000],
['notes.txt', 'file', 42, 1_700_000_000_000],
]);
// One stat per entry: the date and the size ride on the same call.
const statsFor = (name: string) =>
mockedStat.mock.calls.filter(([candidate]) => String(candidate).endsWith(`/${name}`)).length;
expect([statsFor('notes.txt'), statsFor('src'), statsFor('link')]).toEqual([1, 1, 1]);
});
it('defaults to the Codeman Cases root, not Home, when linking a case with no path chosen yet', async () => {
// The "Link Existing" case picker opens with an empty path and no
// sessionId. `Home` and `Codeman Cases` are unrelated bind mounts under
+5 -2
View File
@@ -49,10 +49,13 @@ describe('POST /api/status-telemetry', () => {
});
});
it('does not broadcast for an unknown session; returns the brand footer', async () => {
it('does not broadcast for an unknown session, and answers an empty body', async () => {
// Never the bare brand word: the exporter prints this answer as the
// statusline, and `codeman` on the statusline of every hand-run `claude` in
// a managed repo is the symptom discussion #405 opened with.
const res = await post({ sessionId: 'does-not-exist', data: REAL });
expect(res.statusCode).toBe(200);
expect(res.body).toBe('codeman');
expect(res.body).toBe('');
expect(h.ctx.broadcast).not.toHaveBeenCalled();
});
@@ -0,0 +1,133 @@
/**
* PUT /api/settings: the `statusLineTelemetry` ACTION field, both directions.
*
* `true` (sent on every save while the plan-usage chip is on) injects Codeman's
* statusLine exporter into every live Claude session's workspace so the chip's
* data starts flowing without a new session. `false` (sent only when the chip
* was just turned OFF on a device) takes the exporter back out of those same
* workspaces. Before this, nothing in `src/` ever called the disable path, so
* turning the chip off left the line in every repo it had ever reached
* (discussion #405).
*
* Both directions are `isOurs`-guarded in `applyStatusLineConfig`, so a
* statusLine the user wrote themselves is never added to, replaced, or removed.
* Remote-attach sessions are skipped in both: their `workingDir` is a
* `user@host:session` pseudo-path that the enable path would otherwise create
* as a junk local directory.
*
* Uses app.inject(), real temp workspaces under the test HOME. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { createMockSession } from '../mocks/mock-session.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { STATUSLINE_SHIM_TOKEN } from '../../src/statusline-shim.js';
// The three service toggles start/stop real watchers from this handler; stub
// them so a settings PUT in a test never starts a filesystem watcher.
const { subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
const makeWatcher = () => ({
isRunning: vi.fn(() => false),
start: vi.fn(),
stop: vi.fn(),
getStats: vi.fn(() => ({})),
watchSession: vi.fn(),
getRecentRunSummaries: vi.fn(() => []),
});
return { subagentWatcher: makeWatcher(), imageWatcher: makeWatcher(), workflowRunWatcher: makeWatcher() };
});
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
const settingsFile = (dir: string) => join(dir, '.claude', 'settings.local.json');
const readSettings = (dir: string) => JSON.parse(readFileSync(settingsFile(dir), 'utf-8'));
const writeSettings = (dir: string, value: object) => {
mkdirSync(join(dir, '.claude'), { recursive: true });
writeFileSync(settingsFile(dir), JSON.stringify(value, null, 2));
};
describe('PUT /api/settings statusLineTelemetry', () => {
let h: RouteTestHarness;
let root: string;
let claudeDir: string;
let shellDir: string;
let remoteDir: string;
const put = (body: unknown) => h.app.inject({ method: 'PUT', url: '/api/settings', payload: body });
beforeEach(async () => {
h = await createRouteTestHarness(registerSystemRoutes);
root = mkdtempSync(join(tmpdir(), 'codeman-statusline-toggle-'));
claudeDir = join(root, 'claude-repo');
shellDir = join(root, 'shell-repo');
remoteDir = join(root, 'remote-attach');
for (const dir of [claudeDir, shellDir]) mkdirSync(dir, { recursive: true });
const claude = createMockSession('claude-1');
claude.workingDir = claudeDir;
const shell = createMockSession('shell-1');
shell.mode = 'shell';
shell.workingDir = shellDir;
const remote = createMockSession('remote-1');
remote.workingDir = remoteDir;
Object.assign(remote, { remote: { host: 'box', session: 'codeman-ssh-remote-1' } });
h.ctx.sessions.clear();
for (const s of [claude, shell, remote]) h.ctx.sessions.set(s.id, s);
});
afterEach(async () => {
await h.app.close();
});
it('true injects the exporter into live Claude workspaces only', async () => {
const res = await put({ statusLineTelemetry: true });
expect(res.statusCode).toBe(200);
expect(readSettings(claudeDir).statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
// A shell session has no statusline to export from.
expect(existsSync(settingsFile(shellDir))).toBe(false);
// A remote attach's workingDir is a pseudo-path: nothing must be created for it.
expect(existsSync(remoteDir)).toBe(false);
});
it('false removes the exporter it injected', async () => {
await put({ statusLineTelemetry: true });
expect(readSettings(claudeDir).statusLine).toBeDefined();
const res = await put({ statusLineTelemetry: false });
expect(res.statusCode).toBe(200);
expect(readSettings(claudeDir).statusLine).toBeUndefined();
});
it('false keeps every other key in the workspace settings file', async () => {
writeSettings(claudeDir, { permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
await put({ statusLineTelemetry: true });
await put({ statusLineTelemetry: false });
expect(readSettings(claudeDir)).toEqual({ permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
});
it('false never removes a statusLine the user wrote themselves', async () => {
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
writeSettings(claudeDir, { statusLine: mine });
await put({ statusLineTelemetry: false });
expect(readSettings(claudeDir).statusLine).toEqual(mine);
});
it('false creates nothing in a workspace that never had the exporter', async () => {
await put({ statusLineTelemetry: false });
expect(existsSync(settingsFile(claudeDir))).toBe(false);
expect(existsSync(remoteDir)).toBe(false);
});
it('is an action field, never persisted into settings.json', async () => {
await put({ statusLineTelemetry: false, showTokenCount: true });
const res = await h.app.inject({ method: 'GET', url: '/api/settings' });
const stored = JSON.parse(res.body);
const settings = stored.data ?? stored;
expect(settings.showTokenCount).toBe(true);
expect('statusLineTelemetry' in settings).toBe(false);
});
});
+422
View File
@@ -0,0 +1,422 @@
/**
* The generated plan-usage statusLine shim and the command that launches it.
*
* Like the DeepSeek status shim, the shim is emitted as a STRING and executed
* by someone else, Claude Code, before every render, so tsc never sees it. The
* assertions therefore run the real file in a real `node` process, with a real
* temp HOME and a real listener, rather than inspecting the source text. The
* injected command is exercised the same way, through `sh -c`, because its
* fallback half is the only thing that renders inside a Docker case's
* container and a typo there is invisible to every other check.
*
* The load-bearing property is the pair: the shim must keep forwarding plan
* usage to Codeman AND give the user back the statusline it shadows. Losing
* either half silently defeats the feature, in one direction by blanking the
* header chip and in the other by stealing the terminal footer.
*/
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import {
ensureStatusLineShim,
LEGACY_STATUSLINE_MARKER,
resetStatusLineShimForTest,
statusLineShimGuard,
statusLineShimPath,
STATUSLINE_SHIM_TOKEN,
} from '../src/statusline-shim.js';
import { generateStatusLineCommand, isCodemanStatusLine } from '../src/hooks-config.js';
const PORT = 3252;
/** A port nothing listens on, for the unreachable-Codeman case. Claimed here so
* the repo-wide `const PORT =` search a contributor runs finds it too. */
const PORT_DEAD = 3253;
/** Point a settings file's statusLine at a shell command. */
function writeStatusLine(file: string, command: string): void {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify({ statusLine: { type: 'command', command } }, null, 2));
}
describe('statusLine shim: provisioning', () => {
beforeEach(() => {
resetStatusLineShimForTest();
});
it('writes an executable shim that node can actually parse', () => {
const path = ensureStatusLineShim();
expect(path).toBeTruthy();
expect(existsSync(path!)).toBe(true);
expect(statSync(path!).mode & 0o777).toBe(0o700);
// `node --check` on the real file: a template-literal typo in SHIM_SOURCE is
// invisible to tsc, because the shim is a string as far as it is concerned.
expect(() => execFileSync(process.execPath, ['--check', path!], { stdio: 'pipe' })).not.toThrow();
});
it('refreshes a shim written by an older Codeman, and leaves no temp file behind', () => {
const path = statusLineShimPath();
ensureStatusLineShim();
const current = readFileSync(path, 'utf-8');
writeFileSync(path, `#!/usr/bin/env node\n// ${STATUSLINE_SHIM_TOKEN} v0\nprocess.exit(0)\n`, { mode: 0o700 });
resetStatusLineShimForTest();
ensureStatusLineShim();
expect(readFileSync(path, 'utf-8')).toBe(current);
const strays = readdirSync(dirname(path)).filter((f) => f.startsWith(STATUSLINE_SHIM_TOKEN) && f.endsWith('.tmp'));
expect(strays).toEqual([]);
});
it('re-asserts the exec bit even when the content already matches', () => {
const path = ensureStatusLineShim()!;
chmodSync(path, 0o600); // a restored backup / copied data dir
resetStatusLineShimForTest();
ensureStatusLineShim();
expect(statSync(path).mode & 0o777).toBe(0o700);
});
it('names the shim so the guard carries the ownership token', () => {
// isCodemanStatusLine decides ownership on this substring. If the file is
// ever renamed out from under it, Codeman stops recognising its own entries
// and starts treating them as hand-authored.
const guard = statusLineShimGuard();
expect(guard).toBeTruthy();
expect(guard).toContain(STATUSLINE_SHIM_TOKEN);
// Absolute node, not a bare `node`: a managed session's PATH need not have one.
expect(guard).toContain(process.execPath);
});
it('tests both paths before exec-ing, and quotes them, so a data dir with a space still runs', () => {
const node = `'${process.execPath}'`;
const shim = `'${statusLineShimPath()}'`;
expect(statusLineShimGuard()).toBe(`if [ -x ${node} ] && [ -f ${shim} ]; then exec ${node} ${shim}; fi;`);
});
});
describe('statusLine shim: rendering', () => {
let shim: string;
let server: Server;
let received: Array<{ url: string; body: string }> = [];
let footer = 'CODEMAN-FOOTER';
let workspace: string;
let fakeHome: string;
/** Run the shim the way Claude Code does: a subprocess, JSON on stdin. */
function render(
payload: object,
env: Record<string, string> = {},
cwd: string = workspace
): Promise<{ stdout: string; code: number | null }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [shim], {
cwd,
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.on('data', (c) => (stdout += c));
child.on('close', (code) => resolve({ stdout, code }));
child.stdin.end(JSON.stringify(payload));
});
}
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
beforeAll(async () => {
resetStatusLineShimForTest();
shim = ensureStatusLineShim()!;
server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push({ url: req.url ?? '', body });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(footer);
});
});
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
});
afterAll(async () => {
await new Promise<void>((r) => server.close(() => r()));
});
beforeEach(() => {
received = [];
footer = 'CODEMAN-FOOTER';
const root = mkdtempSync(join(tmpdir(), 'codeman-statusline-'));
fakeHome = join(root, 'home');
workspace = join(root, 'repo');
mkdirSync(join(fakeHome, '.claude'), { recursive: true });
mkdirSync(join(workspace, '.claude'), { recursive: true });
// The entry Codeman injects into the managed repo. Every case below has it,
// because the shim must always skip its own entry while hunting a delegate.
writeStatusLine(join(workspace, '.claude', 'settings.local.json'), `'${process.execPath}' '${shim}'`);
});
it('prints the global statusline it shadows', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('THE-USERS-LINE');
});
it('hands the delegate the same JSON Claude Code sent', async () => {
// The delegate is only useful if it sees the payload: every statusline
// script reads the model, the cwd or the rate limits off this blob.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), `bash -c 'read -r j; echo "GOT:$j"'`);
const { stdout } = await render({ cwd: workspace, model: { display_name: 'Opus 5' } });
expect(stdout).toContain('"display_name":"Opus 5"');
});
it('never delegates to its own entry, and prints nothing rather than a brand word', async () => {
// No other statusLine exists, so the only candidate is the shim's own. If
// the loop guard failed this would fork until something ran out. And with
// nothing to shadow and no Codeman to ask, the line stays blank: the bare
// word `codeman` is the symptom discussion #405 opened with.
const { stdout, code } = await render({ cwd: workspace });
expect(code).toBe(0);
expect(stdout).toBe('');
});
it('never delegates to the pre-shim inline exporter', async () => {
// An upgraded install can still have the old command in a parent settings
// file. Running it would double-report and print Codeman's footer anyway.
writeStatusLine(
join(fakeHome, '.claude', 'settings.json'),
`curl -sk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" || echo codeman`
);
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('');
});
it('prefers a project statusline to the global one', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('PROJECT');
});
it('reads project settings from the launch directory, not the current one', async () => {
// Claude Code applies a project's settings from the directory it was
// launched in (workspace.project_dir), which the blob keeps reporting after
// the working directory changes mid-session.
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
const elsewhere = join(dirname(workspace), 'elsewhere');
mkdirSync(elsewhere, { recursive: true });
const { stdout } = await render(
{ cwd: elsewhere, workspace: { current_dir: elsewhere, project_dir: workspace } },
{},
elsewhere
);
expect(stdout).toBe('PROJECT');
});
it('does not walk up from the launch directory', async () => {
// Claude Code reads project settings from the launch directory alone, so a
// .claude in an ancestor is one it would have ignored. Delegating to it
// would run a statusline the user never sees otherwise.
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo ANCESTOR');
const sub = join(workspace, 'packages', 'inner');
mkdirSync(sub, { recursive: true });
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
const { stdout } = await render({ cwd: sub, workspace: { current_dir: sub, project_dir: sub } }, {}, sub);
expect(stdout).toBe('GLOBAL');
});
it('ignores a user-level settings.local.json, which Claude Code does not read', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.local.json'), 'echo NOT-A-REAL-FILE');
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('GLOBAL');
});
it('forwards telemetry to Codeman WHILE delegating', async () => {
// The whole point: taking the user's line back must not cost the header chip.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render(
{ cwd: workspace, rate_limits: { five_hour: { used_percentage: 12, resets_at: 99 } } },
managed()
);
expect(stdout).toBe('THE-USERS-LINE');
expect(received).toHaveLength(1);
expect(received[0].url).toBe(LEGACY_STATUSLINE_MARKER);
const posted = JSON.parse(received[0].body);
expect(posted.sessionId).toBe('sess-1');
expect(posted.data.rate_limits.five_hour.used_percentage).toBe(12);
});
it("prints Codeman's own footer when there is no line to shadow", async () => {
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('CODEMAN-FOOTER');
expect(received).toHaveLength(1);
});
it('treats the bare brand word from an older server as no telemetry', async () => {
// A pre-1.28 route answers `codeman` for a session it does not know. That
// word on the statusline is what cost discussion #405 seven repositories
// of debugging, so it must never be printed, whichever server answers.
footer = 'codeman';
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('');
expect(received).toHaveLength(1);
});
it('skips the POST entirely outside a managed session', async () => {
// Running `claude` by hand in a managed repo must cost nothing extra.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('THE-USERS-LINE');
expect(received).toEqual([]);
});
it('falls back to the footer when the delegate fails silently', async () => {
// A delegate that exits non-zero with no output must not win over a footer
// Codeman can supply.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'exit 3');
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('CODEMAN-FOOTER');
});
it('still prints a failing delegate that produced output', async () => {
// Plenty of statusline scripts end on the exit code of their last command.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo PARTIAL; exit 1');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('PARTIAL');
});
it('survives an unreachable Codeman and a malformed payload', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const child = spawn(process.execPath, [shim], {
cwd: workspace,
env: {
...process.env,
HOME: fakeHome,
USERPROFILE: fakeHome,
CODEMAN_SESSION_ID: 'sess-1',
// Nothing listens here.
CODEMAN_API_URL: `http://127.0.0.1:${PORT_DEAD}`,
},
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.on('data', (c) => (stdout += c));
child.stdin.end('not json at all');
const code = await new Promise<number | null>((r) => child.on('close', r));
expect(code).toBe(0);
expect(stdout).toBe('THE-USERS-LINE');
});
it('ignores a malformed settings file instead of dying on it', async () => {
writeFileSync(join(workspace, '.claude', 'settings.json'), '{ broken');
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout, code } = await render({ cwd: workspace });
expect(code).toBe(0);
expect(stdout).toBe('THE-USERS-LINE');
});
it('ignores a statusLine that is not a command', async () => {
writeFileSync(
join(fakeHome, '.claude', 'settings.json'),
JSON.stringify({ statusLine: { type: 'something-else', command: 'echo NOPE' } })
);
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('CODEMAN-FOOTER');
});
});
describe('the injected statusLine command', () => {
let server: Server;
let received: string[] = [];
let fakeHome: string;
/** Run the command the way Claude Code does: through a shell, JSON on stdin. */
function run(command: string, env: Record<string, string>, stdin = '{"model":{"display_name":"Opus"}}') {
return new Promise<{ stdout: string; code: number | null }>((resolve) => {
const child = spawn('/bin/sh', ['-c', command], {
cwd: fakeHome,
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.on('data', (c) => (stdout += c));
child.on('close', (code) => resolve({ stdout, code }));
child.stdin.end(stdin);
});
}
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-2', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
beforeAll(async () => {
resetStatusLineShimForTest();
server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push(body);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('CODEMAN-FOOTER');
});
});
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
});
afterAll(async () => {
await new Promise<void>((r) => server.close(() => r()));
});
beforeEach(() => {
received = [];
fakeHome = mkdtempSync(join(tmpdir(), 'codeman-statusline-cmd-'));
});
it('is recognised as ours by both of its halves', () => {
const command = generateStatusLineCommand();
expect(command.startsWith('if [ -x ')).toBe(true);
expect(command).toContain(STATUSLINE_SHIM_TOKEN);
expect(command).toContain(LEGACY_STATUSLINE_MARKER);
expect(isCodemanStatusLine(command)).toBe(true);
// The word the whole change exists to remove.
expect(command).not.toContain('echo codeman');
});
it('runs the shim where the shim exists', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await run(generateStatusLineCommand(), managed());
expect(stdout).toBe('THE-USERS-LINE');
expect(JSON.parse(received[0]).sessionId).toBe('sess-2');
});
it('falls through to the inline curl exporter where the shim does not exist', async () => {
// Inside a Docker case's container the workspace's settings.local.json is
// bind-mounted at the same absolute path, but neither the host's node nor
// its data dir is. The same command must still report telemetry there.
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await run(command, managed());
expect(received).toHaveLength(1);
expect(JSON.parse(received[0])).toMatchObject({ sessionId: 'sess-2', data: { model: { display_name: 'Opus' } } });
// The inline half cannot delegate, so it prints the footer through.
expect(stdout).toBe('CODEMAN-FOOTER');
});
it('prints nothing, not a brand word, when the inline half has no Codeman to reach', async () => {
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
const { stdout, code } = await run(command, { CODEMAN_API_URL: '', CODEMAN_SESSION_ID: '' });
expect(code).toBe(0);
expect(stdout).toBe('');
});
});
+7
View File
@@ -295,6 +295,13 @@ describe('TmuxManager (unit)', () => {
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
});
// Claude renders its themed backgrounds as RGB. Without this the pane inherits
// tmux's TERM=screen, supports-color reads 16 colors, and every dark background
// quantizes to ESC[40m — the terminal's own black — so the block goes invisible.
it('exports truecolor for Claude sessions', () => {
expect(callBuildEnvExports('claude')).toContain('export COLORTERM=truecolor');
});
it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
const original = process.env.CODEMAN_API_URL;
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';
+3 -2
View File
@@ -119,8 +119,9 @@ describe('formatSessionStatusText', () => {
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
});
it('falls back to a brand string when there is no data', () => {
expect(formatSessionStatusText(null)).toBe('codeman');
it('prints nothing when there is no data, never a brand string', () => {
expect(formatSessionStatusText(null)).toBe('');
expect(formatSessionStatusText({})).toBe('');
});
});