mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1cd7884d4 | ||
|
|
c375268879 |
@@ -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.
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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