mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -2,25 +2,27 @@
|
||||
"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
|
||||
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 a session id it does not know.
|
||||
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 exporter is now a generated shim, `src/statusline-shim.ts`, following the
|
||||
`deepseek-status-shim` pattern: versioned `.mjs` written into the data dir, refreshed on a
|
||||
marker change, 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 is
|
||||
shadowing and prints that instead. 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 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.
|
||||
|
||||
The delegate is resolved at render time by walking the settings files Claude Code consults,
|
||||
nearest first, skipping Codeman's own entry in either the shim or the pre-shim form. Late
|
||||
resolution means editing a global statusline takes effect with 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
|
||||
rather than being mistaken for hand-authored. A hand-authored statusLine is left alone
|
||||
exactly as before.
|
||||
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.
|
||||
|
||||
@@ -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: 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`.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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. **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:
|
||||
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
||||
|
||||
@@ -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**.
|
||||
|
||||
+33
-22
@@ -39,7 +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 { 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
|
||||
@@ -848,13 +848,15 @@ async function readWorkspaceHooksEnabled(): Promise<boolean> {
|
||||
/**
|
||||
* Is this statusLine command one Codeman wrote?
|
||||
*
|
||||
* Two forms count. The current one runs the delegating shim, recognised by the
|
||||
* version-free token in its filename. The other is the pre-shim inline `curl`,
|
||||
* recognised by the endpoint path it posts to. 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.
|
||||
* 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.
|
||||
*/
|
||||
function isCodemanStatusLine(command: unknown): boolean {
|
||||
export function isCodemanStatusLine(command: unknown): boolean {
|
||||
return (
|
||||
typeof command === 'string' &&
|
||||
(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
|
||||
* installed. It POSTs the statusline JSON and prints Codeman's response, which
|
||||
* means it SHADOWS whatever statusline the user configured globally. That is
|
||||
* the cost the shim exists to remove, so this runs only when a data dir that
|
||||
* cannot be written leaves no better option.
|
||||
* 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.
|
||||
*/
|
||||
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 (
|
||||
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
||||
`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 "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.
|
||||
*
|
||||
* Normally this runs the delegating shim, which forwards the same JSON to
|
||||
* Codeman and then prints the statusline its own entry shadows. The inline
|
||||
* exporter above is the fallback for an uninstallable shim.
|
||||
* 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 {
|
||||
return generateShimStatusLineCommand() ?? generateInlineStatusLineCommand();
|
||||
const guard = statusLineShimGuard();
|
||||
const inline = generateInlineStatusLineCommand();
|
||||
return guard ? `${guard} ${inline}` : inline;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+132
-92
@@ -13,8 +13,8 @@
|
||||
* `~/.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` — the response this
|
||||
* instance returns for a session id it does not know.
|
||||
* 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
|
||||
@@ -25,11 +25,32 @@
|
||||
*
|
||||
* ## How the delegate is resolved
|
||||
*
|
||||
* At RENDER time, not at injection time. The shim walks the settings files
|
||||
* Claude Code would consult, nearest first, and takes the first `statusLine`
|
||||
* that is not one of ours. 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.
|
||||
* 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
|
||||
*
|
||||
@@ -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
|
||||
* command for every shim version. Two separate decisions key on that:
|
||||
* `applyStatusLineConfig` 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.
|
||||
* `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 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.
|
||||
* Those commands are still sitting in every repo a previous version managed, so
|
||||
* `applyStatusLineConfig` must still read them as OURS — otherwise the upgrade
|
||||
* mistakes them for a hand-authored line, refuses to touch them, and the user
|
||||
* keeps the shadowing exporter forever.
|
||||
* 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';
|
||||
|
||||
@@ -85,6 +106,15 @@ export const LEGACY_STATUSLINE_MARKER = '/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. */
|
||||
function shQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
@@ -99,16 +129,18 @@ function shQuote(value: string): string {
|
||||
* - **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 never blanks the line.** Empty output, a non-zero
|
||||
* exit, or a timeout all fall through to Codeman's footer, then to a brand
|
||||
* string. A statusline that renders nothing looks like a broken terminal.
|
||||
* - **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
|
||||
// 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
|
||||
@@ -116,13 +148,17 @@ const SHIM_SOURCE = `#!/usr/bin/env node
|
||||
// shadows, so taking the slot costs the user nothing.
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
// 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
|
||||
|
||||
@@ -130,7 +166,7 @@ let input = ''
|
||||
try {
|
||||
input = readFileSync(0, 'utf-8')
|
||||
} 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 = '{}'
|
||||
|
||||
@@ -141,36 +177,30 @@ try {
|
||||
// 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 = {}
|
||||
|
||||
// Claude Code reports the render's directory here. Fall back to the process cwd,
|
||||
// which is the same directory in every shape we have seen.
|
||||
const cwd =
|
||||
(typeof parsed.cwd === 'string' && parsed.cwd) ||
|
||||
(parsed.workspace && typeof parsed.workspace.current_dir === 'string' && parsed.workspace.current_dir) ||
|
||||
process.cwd()
|
||||
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()
|
||||
|
||||
/**
|
||||
* Settings files Claude Code consults, nearest first.
|
||||
*
|
||||
* Walking UP from the render directory matters: Claude Code applies a project's
|
||||
* settings from the workspace root, which is often an ancestor of the directory
|
||||
* a session actually sits in. The home files come last, matching the precedence
|
||||
* that makes a project entry win over a global one.
|
||||
* 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() {
|
||||
const out = []
|
||||
let dir = cwd
|
||||
for (;;) {
|
||||
out.push(join(dir, '.claude', 'settings.local.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)]
|
||||
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. */
|
||||
@@ -188,7 +218,7 @@ function resolveDelegate() {
|
||||
if (line.type && line.type !== 'command') continue
|
||||
const command = line.command
|
||||
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.
|
||||
if (command.includes(SHIM_TOKEN) || command.includes(LEGACY_MARKER)) continue
|
||||
return command
|
||||
@@ -239,31 +269,32 @@ function runDelegate(command) {
|
||||
})
|
||||
}
|
||||
|
||||
/** POST the blob to Codeman. Resolves to the response body, or null. */
|
||||
function postTelemetry() {
|
||||
/** 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 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(
|
||||
{
|
||||
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
|
||||
// to shadow or the delegate produced nothing, and the brand string is the last
|
||||
// resort — a blank statusline reads as a broken terminal.
|
||||
const rendered = (delegateOut && delegateOut.trim() && delegateOut) || telemetryOut || 'codeman'
|
||||
process.stdout.write(rendered.replace(/\\n$/, ''))
|
||||
// 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. */
|
||||
@@ -324,8 +358,8 @@ let ensuredThisProcess = false;
|
||||
* 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 falls back to the
|
||||
* inline command.
|
||||
* failed session start, so the caller receives null and injects the inline
|
||||
* command alone.
|
||||
*/
|
||||
export function ensureStatusLineShim(): string | null {
|
||||
const path = statusLineShimPath();
|
||||
@@ -335,7 +369,7 @@ export function ensureStatusLineShim(): string | null {
|
||||
try {
|
||||
current = readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
// Missing — fall through to the write.
|
||||
// Missing: fall through to the write.
|
||||
}
|
||||
if (!current.includes(SHIM_MARKER)) {
|
||||
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
|
||||
* 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.
|
||||
* `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, leaving the caller to
|
||||
* decide the fallback.
|
||||
* Returns null when the shim could not be installed, in which case the caller
|
||||
* injects the inline exporter alone.
|
||||
*/
|
||||
export function generateShimStatusLineCommand(): string | null {
|
||||
export function statusLineShimGuard(): string | null {
|
||||
const shim = ensureStatusLineShim();
|
||||
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. */
|
||||
|
||||
@@ -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(' ') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1326,14 +1326,18 @@ describe('applyStatusLineConfig', () => {
|
||||
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);
|
||||
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);
|
||||
// The inline form SHADOWS the user's statusline, which is the whole reason
|
||||
// the shim exists. It may never be the command we inject by choice.
|
||||
expect(statusLine.command).not.toContain(LEGACY_STATUSLINE_MARKER);
|
||||
expect(statusLine.command).toContain(LEGACY_STATUSLINE_MARKER);
|
||||
expect(statusLine.command).not.toContain('echo codeman');
|
||||
});
|
||||
|
||||
it('upgrades a pre-shim inline exporter in place', async () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+168
-49
@@ -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
|
||||
* 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.
|
||||
* 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
|
||||
@@ -21,7 +24,6 @@ import {
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
@@ -29,18 +31,25 @@ import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
ensureStatusLineShim,
|
||||
generateShimStatusLineCommand,
|
||||
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();
|
||||
@@ -78,20 +87,21 @@ describe('statusLine shim: provisioning', () => {
|
||||
expect(statSync(path).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
it('names the shim so the injected command carries the ownership token', () => {
|
||||
// applyStatusLineConfig decides ownership on this substring. If the file is
|
||||
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 command = generateShimStatusLineCommand();
|
||||
expect(command).toBeTruthy();
|
||||
expect(command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||
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(command).toContain(process.execPath);
|
||||
expect(guard).toContain(process.execPath);
|
||||
});
|
||||
|
||||
it('quotes both paths, so a data dir with a space still runs', () => {
|
||||
const command = generateShimStatusLineCommand()!;
|
||||
expect(command).toBe(`'${process.execPath}' '${statusLineShimPath()}'`);
|
||||
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;`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +109,7 @@ 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;
|
||||
|
||||
@@ -121,11 +132,7 @@ describe('statusLine shim: rendering', () => {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
|
||||
|
||||
beforeAll(async () => {
|
||||
resetStatusLineShimForTest();
|
||||
@@ -137,7 +144,7 @@ describe('statusLine shim: rendering', () => {
|
||||
req.on('end', () => {
|
||||
received.push({ url: req.url ?? '', body });
|
||||
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));
|
||||
@@ -149,6 +156,7 @@ describe('statusLine shim: rendering', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
received = [];
|
||||
footer = 'CODEMAN-FOOTER';
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-statusline-'));
|
||||
fakeHome = join(root, 'home');
|
||||
workspace = join(root, 'repo');
|
||||
@@ -173,12 +181,14 @@ describe('statusLine shim: rendering', () => {
|
||||
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
|
||||
// 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 });
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toBe('codeman');
|
||||
expect(stdout).toBe('');
|
||||
});
|
||||
|
||||
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`
|
||||
);
|
||||
const { stdout } = await render({ cwd: workspace });
|
||||
expect(stdout).toBe('codeman');
|
||||
expect(stdout).toBe('');
|
||||
});
|
||||
|
||||
it('prefers a project statusline to the global one', async () => {
|
||||
@@ -199,22 +209,46 @@ describe('statusLine shim: rendering', () => {
|
||||
expect(stdout).toBe('PROJECT');
|
||||
});
|
||||
|
||||
it('finds the workspace statusline from a subdirectory', async () => {
|
||||
// Claude Code applies a project's settings from the workspace root, which is
|
||||
// routinely an ancestor of the directory the session sits in.
|
||||
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 deep = join(workspace, 'src', 'nested');
|
||||
mkdirSync(deep, { recursive: true });
|
||||
const { stdout } = await render({ cwd: deep }, {}, deep);
|
||||
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 } } },
|
||||
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
|
||||
managed()
|
||||
);
|
||||
|
||||
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 () => {
|
||||
const { stdout } = await render(
|
||||
{ cwd: workspace },
|
||||
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
|
||||
);
|
||||
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, and
|
||||
// must not render the old bare-word `codeman` the server returns for an
|
||||
// unknown session id.
|
||||
// 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 rather than blanking when the delegate fails silently', async () => {
|
||||
// A blank statusline reads as a broken terminal, so a delegate that exits
|
||||
// non-zero with no output must not win.
|
||||
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 },
|
||||
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
|
||||
);
|
||||
const { stdout } = await render({ cwd: workspace }, managed());
|
||||
expect(stdout).toBe('CODEMAN-FOOTER');
|
||||
});
|
||||
|
||||
@@ -297,7 +333,90 @@ describe('statusLine shim: rendering', () => {
|
||||
join(fakeHome, '.claude', 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'something-else', command: 'echo NOPE' } })
|
||||
);
|
||||
const { stdout } = await render({ cwd: workspace });
|
||||
expect(stdout).toBe('codeman');
|
||||
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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user