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
17 changed files with 1285 additions and 52 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.
+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)
**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**.
+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();
+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;
}
+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(' ') : '';
}
/**
+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();
+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);
});
});
+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 });
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('');
});
});
+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('');
});
});