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>
This commit is contained in:
Codeman maintainer
2026-09-14 13:45:32 +02:00
parent c375268879
commit d1cd7884d4
17 changed files with 623 additions and 218 deletions
+20 -18
View File
@@ -2,25 +2,27 @@
"aicodeman": patch "aicodeman": patch
--- ---
feat(statusline): stop the plan-usage exporter from stealing the user's statusline 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 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 the statusLine Codeman injects for the Plan Usage chip shadowed whatever statusline the user
configured globally. The inline exporter then printed Codeman's own footer in its place, and had configured globally, and running `claude` by hand in a managed repo rendered the bare word
running `claude` by hand in a managed repo rendered the bare word `codeman` — the response `codeman`. The exporter is now a generated, delegating shim (`src/statusline-shim.ts`, the
the server returns for a session id it does not know. `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 exporter is now a generated shim, `src/statusline-shim.ts`, following the The injected command is a self-selecting shell guard that runs the shim where it exists and
`deepseek-status-shim` pattern: versioned `.mjs` written into the data dir, refreshed on a falls through to the inline curl exporter where it does not, so the same bind-mounted
marker change, temp-and-rename so a live render cannot read a half-written file. It forwards `settings.local.json` still reports telemetry from inside a Docker case's container. Ownership
the same payload to `/api/status-telemetry` and, concurrently, resolves the statusline it is accepts both the new `codeman-statusline-shim` token and the old `/api/status-telemetry`
shadowing and prints that instead. Codeman's footer still appears when there is nothing to command, so repos managed by an older Codeman upgrade in place. `POST /api/status-telemetry`
shadow, so the exporter keeps its value on a machine with no statusline of its own. 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.
The delegate is resolved at render time by walking the settings files Claude Code consults, Turning the Plan Usage chip off now removes the exporter from the workspaces of your live Claude
nearest first, skipping Codeman's own entry in either the shim or the pre-shim form. Late sessions. The removal rides only the settings save that flips the chip off on a device, so a
resolution means editing a global statusline takes effect with no reinjection. Ownership now phone whose chip was never on cannot strip the exporter a desktop depends on.
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
rather than being mistaken for hand-authored. A hand-authored statusLine is left alone
exactly as before.
+1 -1
View File
@@ -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) **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: a generated shim (`src/statusline-shim.ts` writes `dataPath('codeman-statusline-shim.mjs')`; ownership is the version-free `codeman-statusline-shim` token, and the pre-shim `/api/status-telemetry` command is still read as ours so managed repos upgrade in place) that 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**, because a repo's `.claude/settings.local.json` outranks `~/.claude/settings.json`. So the shim resolves the entry it shadows at RENDER time (walking the settings files Claude Code consults, nearest first, skipping both of its own command forms), runs it with the same blob on stdin and prints that; the route's footer is the fallback for when there is nothing to shadow. 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`. **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 # 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. **The exporter changed shape on 2026-09-11**: it is now a generated shim (`src/statusline-shim.ts`) that prints the statusline it shadows and falls back to the footer below only when there is nothing to shadow, so the passages describing the footer as the exporter's own output record the original shape too. 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. > **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: > Two surfaces from one `statusLine` callback:
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red. > - **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 **Claude only.** A header chip showing live subscription usage, on by default on desktop and
off on phones. off on phones.
It works by installing a status line exporter into Claude Code, which posts Claude's own It works by installing a status line exporter into each managed repo's
rate limit data back to Codeman. The exporter is marker-identified, so it only ever touches `.claude/settings.local.json`, which posts Claude's own rate limit data back to Codeman. The
a status line Codeman installed, never one you wrote yourself, and it prints your footer exporter is marker-identified, so it only ever touches a status line Codeman installed, never
through so the in-terminal status line still works. 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 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**. would leave it showing a dash forever, so resolve it in one place: **App Settings**.
+33 -22
View File
@@ -39,7 +39,7 @@ import { fileURLToPath } from 'node:url';
import type { HookEventType } from './types.js'; import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js'; import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js'; import { dataPath } from './config/instance.js';
import { generateShimStatusLineCommand, LEGACY_STATUSLINE_MARKER, STATUSLINE_SHIM_TOKEN } from './statusline-shim.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 * Serializes read-modify-write access to a `settings.local.json` path. Every
@@ -848,13 +848,15 @@ async function readWorkspaceHooksEnabled(): Promise<boolean> {
/** /**
* Is this statusLine command one Codeman wrote? * Is this statusLine command one Codeman wrote?
* *
* Two forms count. The current one runs the delegating shim, recognised by the * Two markers count, and every command Codeman has ever injected carries at
* version-free token in its filename. The other is the pre-shim inline `curl`, * least one. The version-free `codeman-statusline-shim` token names the shim
* recognised by the endpoint path it posts to. Both must be read as ours, or * file the current guarded command runs; the `/api/status-telemetry` path is
* the upgrade mistakes an old injected command for a hand-authored line, * what the inline exporter posts to, in the pre-shim command AND in the
* refuses to touch it, and leaves the user with the shadowing exporter. * 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.
*/ */
function isCodemanStatusLine(command: unknown): boolean { export function isCodemanStatusLine(command: unknown): boolean {
return ( return (
typeof command === 'string' && typeof command === 'string' &&
(command.includes(STATUSLINE_SHIM_TOKEN) || command.includes(LEGACY_STATUSLINE_MARKER)) (command.includes(STATUSLINE_SHIM_TOKEN) || command.includes(LEGACY_STATUSLINE_MARKER))
@@ -862,36 +864,45 @@ function isCodemanStatusLine(command: unknown): boolean {
} }
/** /**
* The pre-shim inline exporter, kept as the fallback when the shim cannot be * The inline exporter: env vars plus curl, portable by construction. It POSTs
* installed. It POSTs the statusline JSON and prints Codeman's response, which * the statusline JSON and prints Codeman's answer, which means it SHADOWS
* means it SHADOWS whatever statusline the user configured globally. That is * whatever statusline the user configured globally. That is the cost the shim
* the cost the shim exists to remove, so this runs only when a data dir that * exists to remove, so this half only renders where the shim cannot run: inside
* cannot be written leaves no better option. * 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.
*/ */
function generateInlineStatusLineCommand(): string { function generateInlineStatusLineCommand(): string {
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
// production setup; without -k curl returns 000 and the statusline shows
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
// footer is never blank if Codeman is unreachable.
return ( return (
`INPUT=$(cat 2>/dev/null || echo '{}'); ` + `INPUT=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` + `printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
`curl -sk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" ` + `curl -sfk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" ` +
`-H 'Content-Type: application/json' ` + `-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` + `-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. * The plan-usage statusLine exporter command.
* *
* Normally this runs the delegating shim, which forwards the same JSON to * A self-selecting guard followed by the inline exporter: where the shim and
* Codeman and then prints the statusline its own entry shadows. The inline * the node binary both exist the guard `exec`s the delegating shim, which
* exporter above is the fallback for an uninstallable shim. * 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 { export function generateStatusLineCommand(): string {
return generateShimStatusLineCommand() ?? generateInlineStatusLineCommand(); const guard = statusLineShimGuard();
const inline = generateInlineStatusLineCommand();
return guard ? `${guard} ${inline}` : inline;
} }
/** /**
+132 -92
View File
@@ -13,8 +13,8 @@
* `~/.claude/settings.json`, so writing a statusLine into a managed repo * `~/.claude/settings.json`, so writing a statusLine into a managed repo
* SHADOWS whatever statusline the user configured globally. The inline exporter * 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` * 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` — the response this * by hand in a managed repo saw the bare word `codeman` (discussion #405: seven
* instance returns for a session id it does not know. * repositories before the cause was found).
* *
* This shim keeps the data tap and gives the line back. It forwards the blob * 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 * exactly as before, resolves the statusline it is shadowing, runs that command
@@ -25,11 +25,32 @@
* *
* ## How the delegate is resolved * ## How the delegate is resolved
* *
* At RENDER time, not at injection time. The shim walks the settings files * At RENDER time, not at injection time, from exactly the three files Claude
* Claude Code would consult, nearest first, and takes the first `statusLine` * Code documents for a project: `.claude/settings.local.json` and
* that is not one of ours. Resolving late means a user who edits their global * `.claude/settings.json` under the directory Claude Code was launched in
* statusline sees the change immediately, with no reinjection and no stale * (`workspace.project_dir` in the blob), then `~/.claude/settings.json`. The
* command baked into a config file. * 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 * ## Why it is generated rather than committed
* *
@@ -59,21 +80,21 @@ const SHIM_MARKER = `codeman-statusline-shim v${SHIM_VERSION}`;
* *
* It appears in the generated file's NAME, so it is a substring of the injected * 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: * command for every shim version. Two separate decisions key on that:
* `applyStatusLineConfig` uses it to recognise a statusLine as Codeman's, and * `isCodemanStatusLine()` in hooks-config uses it to recognise a statusLine as
* the shim itself uses it to skip its own entry while hunting for a delegate. * Codeman's, and the shim itself uses it to skip its own entry while hunting
* Deciding ownership on the version-free token means bumping SHIM_VERSION can * for a delegate. Deciding ownership on the version-free token means bumping
* never disown every previously injected command. * SHIM_VERSION can never disown every previously injected command.
*/ */
export const STATUSLINE_SHIM_TOKEN = 'codeman-statusline-shim'; export const STATUSLINE_SHIM_TOKEN = 'codeman-statusline-shim';
/** /**
* The pre-shim inline exporter's ownership marker, kept only for recognition. * The inline exporter's ownership marker: the route it posts to.
* *
* Codeman injected a bare `curl` carrying this path before the shim existed. * Every command Codeman has ever injected carries this path, the pre-shim
* Those commands are still sitting in every repo a previous version managed, so * inline `curl` and the fallback half of the current guarded command alike, so
* `applyStatusLineConfig` must still read them as OURS — otherwise the upgrade * `isCodemanStatusLine()` must keep reading it as OURS. Drop it and every repo
* mistakes them for a hand-authored line, refuses to touch them, and the user * an older Codeman managed reads as hand-authored: the upgrade refuses to touch
* keeps the shadowing exporter forever. * it and the user keeps the shadowing exporter forever.
*/ */
export const LEGACY_STATUSLINE_MARKER = '/api/status-telemetry'; export const LEGACY_STATUSLINE_MARKER = '/api/status-telemetry';
@@ -85,6 +106,15 @@ export const LEGACY_STATUSLINE_MARKER = '/api/status-telemetry';
*/ */
const STATUS_TELEMETRY_PATH = '/api/status-telemetry'; 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. */ /** Wrap a path for safe use inside a single-quoted shell word. */
function shQuote(value: string): string { function shQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`; return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -99,16 +129,18 @@ function shQuote(value: string): string {
* - **The delegate runs concurrently with the POST.** This command executes on * - **The delegate runs concurrently with the POST.** This command executes on
* every assistant message, so its latency lands in the user's prompt. Running * 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. * both at once costs the slower of the two rather than their sum.
* - **A failing delegate never blanks the line.** Empty output, a non-zero * - **A failing delegate falls through, never blanks by accident.** Empty
* exit, or a timeout all fall through to Codeman's footer, then to a brand * output, a non-zero exit, or a timeout all fall through to Codeman's footer.
* string. A statusline that renders nothing looks like a broken terminal. * 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 * - **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 delay a prompt by more than its own budget, and a hung delegate must
* not hold the render open indefinitely. * not hold the render open indefinitely.
*/ */
const SHIM_SOURCE = `#!/usr/bin/env node const SHIM_SOURCE = `#!/usr/bin/env node
// ${SHIM_MARKER} // ${SHIM_MARKER}
// GENERATED BY CODEMAN — do not edit. Rewritten from src/statusline-shim.ts // GENERATED BY CODEMAN. Do not edit: rewritten from src/statusline-shim.ts
// whenever its version marker changes. // whenever its version marker changes.
// //
// Forwards Claude Code's statusline JSON to this Codeman instance (the only // Forwards Claude Code's statusline JSON to this Codeman instance (the only
@@ -116,13 +148,17 @@ const SHIM_SOURCE = `#!/usr/bin/env node
// shadows, so taking the slot costs the user nothing. // shadows, so taking the slot costs the user nothing.
import { existsSync, readFileSync } from 'node:fs' import { existsSync, readFileSync } from 'node:fs'
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { dirname, join } from 'node:path' import { join } from 'node:path'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import http from 'node:http' // The HTTP transport is imported lazily, inside postTelemetry(): node:http
import https from 'node:https' // 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 SHIM_TOKEN = ${JSON.stringify(STATUSLINE_SHIM_TOKEN)}
const LEGACY_MARKER = ${JSON.stringify(LEGACY_STATUSLINE_MARKER)} const LEGACY_MARKER = ${JSON.stringify(LEGACY_STATUSLINE_MARKER)}
const NO_TELEMETRY_WORD = ${JSON.stringify(NO_TELEMETRY_WORD)}
const POST_TIMEOUT_MS = 1500 const POST_TIMEOUT_MS = 1500
const DELEGATE_TIMEOUT_MS = 4000 const DELEGATE_TIMEOUT_MS = 4000
@@ -130,7 +166,7 @@ let input = ''
try { try {
input = readFileSync(0, 'utf-8') input = readFileSync(0, 'utf-8')
} catch { } catch {
// No stdin (a TTY, or a closed pipe) — the delegate still deserves a run. // No stdin (a TTY, or a closed pipe): the delegate still deserves a run.
} }
if (!input.trim()) input = '{}' if (!input.trim()) input = '{}'
@@ -141,36 +177,30 @@ try {
// Malformed payload: still forward it verbatim and still run the delegate. // Malformed payload: still forward it verbatim and still run the delegate.
// Codeman's parser is defensive and the delegate may not need the JSON. // Codeman's parser is defensive and the delegate may not need the JSON.
} }
if (!parsed || typeof parsed !== 'object') parsed = {}
// Claude Code reports the render's directory here. Fall back to the process cwd, const str = (value) => (typeof value === 'string' && value ? value : '')
// which is the same directory in every shape we have seen. const workspace = parsed.workspace && typeof parsed.workspace === 'object' ? parsed.workspace : {}
const cwd =
(typeof parsed.cwd === 'string' && parsed.cwd) || // Claude Code reads a project's settings from the directory it was LAUNCHED in,
(parsed.workspace && typeof parsed.workspace.current_dir === 'string' && parsed.workspace.current_dir) || // which the blob reports as workspace.project_dir; current_dir/cwd can drift
process.cwd() // 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()
/** /**
* Settings files Claude Code consults, nearest first. * The settings files Claude Code consults for this render, highest precedence
* * first: the documented set is exactly these three. No ancestor of the launch
* Walking UP from the render directory matters: Claude Code applies a project's * directory and no user-level settings.local.json: Claude Code reads neither,
* settings from the workspace root, which is often an ancestor of the directory * and delegating to a command it would have ignored is the failure this shim
* a session actually sits in. The home files come last, matching the precedence * exists to end.
* that makes a project entry win over a global one.
*/ */
function settingsCandidates() { function settingsCandidates() {
const out = [] return [
let dir = cwd join(projectDir, '.claude', 'settings.local.json'),
for (;;) { join(projectDir, '.claude', 'settings.json'),
out.push(join(dir, '.claude', 'settings.local.json')) join(homedir(), '.claude', 'settings.json'),
out.push(join(dir, '.claude', 'settings.json')) ]
const parent = dirname(dir)
if (!parent || parent === dir) break
dir = parent
}
const home = homedir()
out.push(join(home, '.claude', 'settings.local.json'))
out.push(join(home, '.claude', 'settings.json'))
return [...new Set(out)]
} }
/** The first statusLine command that is not one of ours, or null. */ /** The first statusLine command that is not one of ours, or null. */
@@ -188,7 +218,7 @@ function resolveDelegate() {
if (line.type && line.type !== 'command') continue if (line.type && line.type !== 'command') continue
const command = line.command const command = line.command
if (typeof command !== 'string' || !command.trim()) continue if (typeof command !== 'string' || !command.trim()) continue
// Our own entry, in either the shim form or the pre-shim inline form. // Our own entry, in the guarded shim form or the pre-shim inline form.
// Delegating to either one would recurse or double-report. // Delegating to either one would recurse or double-report.
if (command.includes(SHIM_TOKEN) || command.includes(LEGACY_MARKER)) continue if (command.includes(SHIM_TOKEN) || command.includes(LEGACY_MARKER)) continue
return command return command
@@ -239,31 +269,32 @@ function runDelegate(command) {
}) })
} }
/** POST the blob to Codeman. Resolves to the response body, or null. */ /** POST the blob to Codeman. Resolves to the footer it answered, or null. */
function postTelemetry() { 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) => { return new Promise((resolve) => {
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 resolve(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.
}
let url
try {
url = new URL(${JSON.stringify(STATUS_TELEMETRY_PATH)}, apiUrl)
} catch {
return resolve(null)
}
const body = JSON.stringify({ sessionId, data: parsed })
const transport = url.protocol === 'https:' ? https : http
const req = transport.request( const req = transport.request(
{ {
protocol: url.protocol, protocol: url.protocol,
@@ -305,10 +336,13 @@ const [delegateOut, telemetryOut] = await Promise.all([
]) ])
// The shadowed line wins. Codeman's footer fills in only when there is no line // The shadowed line wins. Codeman's footer fills in only when there is no line
// to shadow or the delegate produced nothing, and the brand string is the last // to shadow or the delegate produced nothing. With neither, print NOTHING: a
// resort — a blank statusline reads as a broken terminal. // blank statusline is what Claude Code shows a user with no statusline of
const rendered = (delegateOut && delegateOut.trim() && delegateOut) || telemetryOut || 'codeman' // their own, while the bare brand word is the symptom this shim exists to end.
process.stdout.write(rendered.replace(/\\n$/, '')) 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. */ /** Absolute path of the generated shim for this instance. */
@@ -324,8 +358,8 @@ let ensuredThisProcess = false;
* Idempotent and cheap: after the first call in a process it does nothing, and * 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 * 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 * throws. A data dir that cannot be written is a degraded exporter, not a
* failed session start, so the caller receives null and falls back to the * failed session start, so the caller receives null and injects the inline
* inline command. * command alone.
*/ */
export function ensureStatusLineShim(): string | null { export function ensureStatusLineShim(): string | null {
const path = statusLineShimPath(); const path = statusLineShimPath();
@@ -335,7 +369,7 @@ export function ensureStatusLineShim(): string | null {
try { try {
current = readFileSync(path, 'utf-8'); current = readFileSync(path, 'utf-8');
} catch { } catch {
// Missing — fall through to the write. // Missing: fall through to the write.
} }
if (!current.includes(SHIM_MARKER)) { if (!current.includes(SHIM_MARKER)) {
mkdirSync(dirname(path), { recursive: true }); mkdirSync(dirname(path), { recursive: true });
@@ -370,21 +404,27 @@ export function ensureStatusLineShim(): string | null {
} }
/** /**
* The statusLine command Codeman injects. * The shell guard that runs the shim where it exists, for `generateStatusLineCommand()`
* in hooks-config to prepend to the inline exporter.
* *
* `process.execPath` rather than a bare `node`: Codeman is itself running on * `if [ -x <node> ] && [ -f <shim> ]; then exec <node> <shim>; fi;`: both
* that binary, so it is known to exist, and a managed session's PATH need not * tests fail inside a Docker case's container (see the fileoverview), on a
* carry node at all. The absolute path is also self-healing, because a node * host whose data dir was wiped, and after the node Codeman ran on moves, so
* that moves changes this string, and the next session create rewrites the * the inline exporter after it is what renders there. `process.execPath`
* config to match. * 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, leaving the caller to * Returns null when the shim could not be installed, in which case the caller
* decide the fallback. * injects the inline exporter alone.
*/ */
export function generateShimStatusLineCommand(): string | null { export function statusLineShimGuard(): string | null {
const shim = ensureStatusLineShim(); const shim = ensureStatusLineShim();
if (!shim) return null; if (!shim) return null;
return `${shQuote(process.execPath)} ${shQuote(shim)}`; 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. */ /** Test seam: forget the per-process memo so a fresh temp data dir is provisioned. */
+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 — * 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, * `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 * 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 { export function formatSessionStatusText(s: SessionStatus | null): string {
if (!s) return 'codeman'; if (!s) return '';
const groups: string[] = []; const groups: string[] = [];
if (s.modelDisplayName) groups.push(s.modelDisplayName); if (s.modelDisplayName) groups.push(s.modelDisplayName);
const tok: string[] = []; 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 (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
if (tok.length) groups.push(tok.join(' ')); if (tok.length) groups.push(tok.join(' '));
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`); if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
return groups.length ? groups.join(' ') : 'codeman'; return groups.length ? groups.join(' ') : '';
} }
/** /**
+24 -4
View File
@@ -2050,6 +2050,9 @@ Object.assign(CodemanApp.prototype, {
// WebGL toggle: default ON (desktop), so only an explicit stored false counts // WebGL toggle: default ON (desktop), so only an explicit stored false counts
// as "previously off" — used below to detect a real OFF→ON flip. // as "previously off" — used below to detect a real OFF→ON flip.
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true; 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 = { const settings = {
displayName: window.CodemanI18n?.normalizeDisplayName( displayName: window.CodemanI18n?.normalizeDisplayName(
document.getElementById('appSettingsDisplayName').value 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 // and syncing would leak mobile's hidden-checkbox false onto desktop); it's
// also absent from SettingsUpdateSchema, which is .strict() — sending it // also absent from SettingsUpdateSchema, which is .strict() — sending it
// would 400 the whole settings PUT. // would 400 the whole settings PUT.
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on // Telemetry COLLECTION is requested out-of-band via the statusLineTelemetry
// ENABLE only, so a device with the chip OFF never strips the exporter that // action field: `true` on every save while the chip is on, `false` only on the
// another device's chip depends on — see system-routes settings handler). // 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 { const {
localEchoEnabled: _leo, localEchoEnabled: _leo,
cjkInputEnabled: _cjk, cjkInputEnabled: _cjk,
@@ -2316,10 +2321,11 @@ Object.assign(CodemanApp.prototype, {
sessionLineageLines: _sll, sessionLineageLines: _sll,
...serverSettings ...serverSettings
} = settings; } = settings;
const statusLineTelemetry = this.statusLineTelemetryAction(_prevPlanUsageChip, settings.showPlanUsageLimits);
try { try {
const res = await this._apiPut('/api/settings', { const res = await this._apiPut('/api/settings', {
...serverSettings, ...serverSettings,
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}), ...(statusLineTelemetry === undefined ? {} : { statusLineTelemetry }),
notificationPreferences: notifPrefsToSave, notificationPreferences: notifPrefsToSave,
voiceSettings, voiceSettings,
}); });
@@ -2592,6 +2598,20 @@ Object.assign(CodemanApp.prototype, {
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true; 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() { applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage(); const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings(); const defaults = this.getDefaultSettings();
+8 -4
View File
@@ -8,8 +8,10 @@
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth). * (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 * 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 * in-terminal footer when it has no statusline of the user's own to delegate to
* the terminal footer blank. * (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'; import { FastifyInstance } from 'fastify';
@@ -36,10 +38,12 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
reply.type('text/plain; charset=utf-8'); 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)) { if (!ctx.sessions.has(sessionId)) {
lastSig.delete(sessionId); lastSig.delete(sessionId);
return 'codeman'; return '';
} }
const payload = data as RawStatuslinePayload | undefined; 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). // 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 // Telemetry COLLECTION is a per-save ACTION field in both directions. `true`
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter // rides every save while the chip is on: we (re)inject our exporter into every
// into every ACTIVE Claude session's working dir so the live % starts flowing // ACTIVE Claude session's working dir so the live % starts flowing immediately
// immediately (no new session needed). We deliberately never auto-REMOVE here: // (no new session needed), and that is also how a second device catches up.
// the exporter is benign/print-through and a per-repo settings.local.json is // `false` rides ONLY the save that turned the chip OFF on that device
// shared by sibling sessions, so one device's "off" must not yank the exporter // (statusLineTelemetryAction in settings-ui.js) and takes our exporter back
// another device's chip depends on. Each dir handled once. // out of those same dirs. Nothing called the disable path before, so turning
if (statusLineTelemetry === true) { // 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>(); const dirs = new Set<string>();
for (const session of ctx.sessions.values()) { for (const session of ctx.sessions.values()) {
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir) if (!getCli(session.mode)?.capabilities.statusLineTelemetry || !session.workingDir) continue;
dirs.add(session.workingDir); 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 // Handle tunnel toggle dynamically
+8 -4
View File
@@ -1326,14 +1326,18 @@ describe('applyStatusLineConfig', () => {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });
}); });
it('injects the delegating shim rather than an inline exporter', async () => { it('injects the guarded shim command with the inline exporter as its fallback', async () => {
await applyStatusLineConfig(testDir, true); await applyStatusLineConfig(testDir, true);
const { statusLine } = read(); const { statusLine } = read();
expect(statusLine.type).toBe('command'); 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(STATUSLINE_SHIM_TOKEN);
// The inline form SHADOWS the user's statusline, which is the whole reason expect(statusLine.command).toContain(LEGACY_STATUSLINE_MARKER);
// the shim exists. It may never be the command we inject by choice. expect(statusLine.command).not.toContain('echo codeman');
expect(statusLine.command).not.toContain(LEGACY_STATUSLINE_MARKER);
}); });
it('upgrades a pre-shim inline exporter in place', async () => { it('upgrades a pre-shim inline exporter in place', async () => {
+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();
});
});
+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 }); const res = await post({ sessionId: 'does-not-exist', data: REAL });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toBe('codeman'); expect(res.body).toBe('');
expect(h.ctx.broadcast).not.toHaveBeenCalled(); 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);
});
});
+168 -49
View File
@@ -1,10 +1,13 @@
/** /**
* The generated plan-usage statusLine shim. * The generated plan-usage statusLine shim and the command that launches it.
* *
* Like the DeepSeek status shim, this file is emitted as a STRING and executed * 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. * by someone else, Claude Code, before every render, so tsc never sees it. The
* The assertions therefore run the real file in a real `node` process, with a * assertions therefore run the real file in a real `node` process, with a real
* real temp HOME and a real listener, rather than inspecting the source text. * 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 * 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 * usage to Codeman AND give the user back the statusline it shadows. Losing
@@ -21,7 +24,6 @@ import {
mkdtempSync, mkdtempSync,
readdirSync, readdirSync,
readFileSync, readFileSync,
rmSync,
statSync, statSync,
writeFileSync, writeFileSync,
} from 'node:fs'; } from 'node:fs';
@@ -29,18 +31,25 @@ import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { import {
ensureStatusLineShim, ensureStatusLineShim,
generateShimStatusLineCommand,
LEGACY_STATUSLINE_MARKER, LEGACY_STATUSLINE_MARKER,
resetStatusLineShimForTest, resetStatusLineShimForTest,
statusLineShimGuard,
statusLineShimPath, statusLineShimPath,
STATUSLINE_SHIM_TOKEN, STATUSLINE_SHIM_TOKEN,
} from '../src/statusline-shim.js'; } from '../src/statusline-shim.js';
import { generateStatusLineCommand, isCodemanStatusLine } from '../src/hooks-config.js';
const PORT = 3252; const PORT = 3252;
/** A port nothing listens on, for the unreachable-Codeman case. Claimed here so /** 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. */ * the repo-wide `const PORT =` search a contributor runs finds it too. */
const PORT_DEAD = 3253; 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', () => { describe('statusLine shim: provisioning', () => {
beforeEach(() => { beforeEach(() => {
resetStatusLineShimForTest(); resetStatusLineShimForTest();
@@ -78,20 +87,21 @@ describe('statusLine shim: provisioning', () => {
expect(statSync(path).mode & 0o777).toBe(0o700); expect(statSync(path).mode & 0o777).toBe(0o700);
}); });
it('names the shim so the injected command carries the ownership token', () => { it('names the shim so the guard carries the ownership token', () => {
// applyStatusLineConfig decides ownership on this substring. If the file is // isCodemanStatusLine decides ownership on this substring. If the file is
// ever renamed out from under it, Codeman stops recognising its own entries // ever renamed out from under it, Codeman stops recognising its own entries
// and starts treating them as hand-authored. // and starts treating them as hand-authored.
const command = generateShimStatusLineCommand(); const guard = statusLineShimGuard();
expect(command).toBeTruthy(); expect(guard).toBeTruthy();
expect(command).toContain(STATUSLINE_SHIM_TOKEN); expect(guard).toContain(STATUSLINE_SHIM_TOKEN);
// Absolute node, not a bare `node`: a managed session's PATH need not have one. // Absolute node, not a bare `node`: a managed session's PATH need not have one.
expect(command).toContain(process.execPath); expect(guard).toContain(process.execPath);
}); });
it('quotes both paths, so a data dir with a space still runs', () => { it('tests both paths before exec-ing, and quotes them, so a data dir with a space still runs', () => {
const command = generateShimStatusLineCommand()!; const node = `'${process.execPath}'`;
expect(command).toBe(`'${process.execPath}' '${statusLineShimPath()}'`); const shim = `'${statusLineShimPath()}'`;
expect(statusLineShimGuard()).toBe(`if [ -x ${node} ] && [ -f ${shim} ]; then exec ${node} ${shim}; fi;`);
}); });
}); });
@@ -99,6 +109,7 @@ describe('statusLine shim: rendering', () => {
let shim: string; let shim: string;
let server: Server; let server: Server;
let received: Array<{ url: string; body: string }> = []; let received: Array<{ url: string; body: string }> = [];
let footer = 'CODEMAN-FOOTER';
let workspace: string; let workspace: string;
let fakeHome: string; let fakeHome: string;
@@ -121,11 +132,7 @@ describe('statusLine shim: rendering', () => {
}); });
} }
/** Point a settings file's statusLine at a shell command. */ const managed = () => ({ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
function writeStatusLine(file: string, command: string): void {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify({ statusLine: { type: 'command', command } }, null, 2));
}
beforeAll(async () => { beforeAll(async () => {
resetStatusLineShimForTest(); resetStatusLineShimForTest();
@@ -137,7 +144,7 @@ describe('statusLine shim: rendering', () => {
req.on('end', () => { req.on('end', () => {
received.push({ url: req.url ?? '', body }); received.push({ url: req.url ?? '', body });
res.writeHead(200, { 'Content-Type': 'text/plain' }); res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('CODEMAN-FOOTER'); res.end(footer);
}); });
}); });
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r)); await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
@@ -149,6 +156,7 @@ describe('statusLine shim: rendering', () => {
beforeEach(() => { beforeEach(() => {
received = []; received = [];
footer = 'CODEMAN-FOOTER';
const root = mkdtempSync(join(tmpdir(), 'codeman-statusline-')); const root = mkdtempSync(join(tmpdir(), 'codeman-statusline-'));
fakeHome = join(root, 'home'); fakeHome = join(root, 'home');
workspace = join(root, 'repo'); workspace = join(root, 'repo');
@@ -173,12 +181,14 @@ describe('statusLine shim: rendering', () => {
expect(stdout).toContain('"display_name":"Opus 5"'); expect(stdout).toContain('"display_name":"Opus 5"');
}); });
it('never delegates to its own entry', async () => { 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 // 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. // 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 }); const { stdout, code } = await render({ cwd: workspace });
expect(code).toBe(0); expect(code).toBe(0);
expect(stdout).toBe('codeman'); expect(stdout).toBe('');
}); });
it('never delegates to the pre-shim inline exporter', async () => { it('never delegates to the pre-shim inline exporter', async () => {
@@ -189,7 +199,7 @@ describe('statusLine shim: rendering', () => {
`curl -sk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" || echo codeman` `curl -sk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" || echo codeman`
); );
const { stdout } = await render({ cwd: workspace }); const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('codeman'); expect(stdout).toBe('');
}); });
it('prefers a project statusline to the global one', async () => { it('prefers a project statusline to the global one', async () => {
@@ -199,22 +209,46 @@ describe('statusLine shim: rendering', () => {
expect(stdout).toBe('PROJECT'); expect(stdout).toBe('PROJECT');
}); });
it('finds the workspace statusline from a subdirectory', async () => { it('reads project settings from the launch directory, not the current one', async () => {
// Claude Code applies a project's settings from the workspace root, which is // Claude Code applies a project's settings from the directory it was
// routinely an ancestor of the directory the session sits in. // 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'); writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
const deep = join(workspace, 'src', 'nested'); const elsewhere = join(dirname(workspace), 'elsewhere');
mkdirSync(deep, { recursive: true }); mkdirSync(elsewhere, { recursive: true });
const { stdout } = await render({ cwd: deep }, {}, deep); const { stdout } = await render(
{ cwd: elsewhere, workspace: { current_dir: elsewhere, project_dir: workspace } },
{},
elsewhere
);
expect(stdout).toBe('PROJECT'); 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 () => { it('forwards telemetry to Codeman WHILE delegating', async () => {
// The whole point: taking the user's line back must not cost the header chip. // 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'); writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render( const { stdout } = await render(
{ cwd: workspace, rate_limits: { five_hour: { used_percentage: 12, resets_at: 99 } } }, { cwd: workspace, rate_limits: { five_hour: { used_percentage: 12, resets_at: 99 } } },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` } managed()
); );
expect(stdout).toBe('THE-USERS-LINE'); expect(stdout).toBe('THE-USERS-LINE');
@@ -226,32 +260,34 @@ describe('statusLine shim: rendering', () => {
}); });
it("prints Codeman's own footer when there is no line to shadow", async () => { it("prints Codeman's own footer when there is no line to shadow", async () => {
const { stdout } = await render( const { stdout } = await render({ cwd: workspace }, managed());
{ cwd: workspace },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
);
expect(stdout).toBe('CODEMAN-FOOTER'); expect(stdout).toBe('CODEMAN-FOOTER');
expect(received).toHaveLength(1); 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 () => { it('skips the POST entirely outside a managed session', async () => {
// Running `claude` by hand in a managed repo must cost nothing extra, and // Running `claude` by hand in a managed repo must cost nothing extra.
// must not render the old bare-word `codeman` the server returns for an
// unknown session id.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE'); writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render({ cwd: workspace }); const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('THE-USERS-LINE'); expect(stdout).toBe('THE-USERS-LINE');
expect(received).toEqual([]); expect(received).toEqual([]);
}); });
it('falls back rather than blanking when the delegate fails silently', async () => { it('falls back to the footer when the delegate fails silently', async () => {
// A blank statusline reads as a broken terminal, so a delegate that exits // A delegate that exits non-zero with no output must not win over a footer
// non-zero with no output must not win. // Codeman can supply.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'exit 3'); writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'exit 3');
const { stdout } = await render( const { stdout } = await render({ cwd: workspace }, managed());
{ cwd: workspace },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
);
expect(stdout).toBe('CODEMAN-FOOTER'); expect(stdout).toBe('CODEMAN-FOOTER');
}); });
@@ -297,7 +333,90 @@ describe('statusLine shim: rendering', () => {
join(fakeHome, '.claude', 'settings.json'), join(fakeHome, '.claude', 'settings.json'),
JSON.stringify({ statusLine: { type: 'something-else', command: 'echo NOPE' } }) JSON.stringify({ statusLine: { type: 'something-else', command: 'echo NOPE' } })
); );
const { stdout } = await render({ cwd: workspace }); const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('codeman'); 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('');
}); });
}); });
+3 -2
View File
@@ -119,8 +119,9 @@ describe('formatSessionStatusText', () => {
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)'); 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', () => { it('prints nothing when there is no data, never a brand string', () => {
expect(formatSessionStatusText(null)).toBe('codeman'); expect(formatSessionStatusText(null)).toBe('');
expect(formatSessionStatusText({})).toBe('');
}); });
}); });