feat(statusline): delegate to the statusline the exporter shadows

Claude Code ranks a repo's .claude/settings.local.json above
~/.claude/settings.json, so the statusLine Codeman injects for the Plan
Usage chip shadows whatever statusline the user configured globally. The
inline exporter then printed Codeman's own footer in its place, and
running `claude` by hand in a managed repo rendered the bare word
`codeman` — the response the server returns for an unknown session id.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 641795821fdbb171f9f47c4909c6e8945465d05b)
This commit is contained in:
Michael Grundberg
2026-09-14 13:32:35 +02:00
committed by Codeman maintainer
parent c4b74415ee
commit c375268879
8 changed files with 845 additions and 17 deletions
@@ -0,0 +1,26 @@
---
"aicodeman": patch
---
feat(statusline): stop the plan-usage exporter from stealing the user's statusline
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 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 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.
+1 -1
View File
@@ -207,7 +207,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine, and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter: 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`
**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`.
+1 -1
View File
@@ -92,7 +92,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
### Plan-usage chip (statusLine telemetry)
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through**. Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox, chip visibility, and Claude create-time exporter flag. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`.
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts` — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. ⚠️ **Taking that slot SHADOWS the user's own statusline**: a repo's `.claude/settings.local.json` outranks `~/.claude/settings.json`, so the inline exporter replaced whatever the user had configured globally, and a hand-run `claude` in a managed repo rendered the bare word `codeman` (what the route returns for an unknown session id). The exporter is therefore a GENERATED shim — `statusline-shim.ts` writes `dataPath('codeman-statusline-shim.mjs')`, same pattern as `deepseek-status-shim.ts`: versioned marker, refreshed when it changes, temp-and-rename because a live render can be executing the path mid-refresh. It forwards the blob and, concurrently, resolves the statusline it shadows and prints THAT; the route's footer is the fallback for when there is nothing to shadow. ⚠️ The delegate resolves at RENDER time, not injection time (walk the settings files Claude Code consults from the render dir upward, then the home ones, first non-ours wins), so editing a global statusline needs no reinjection. ⚠️ **Ownership keys on the version-free `codeman-statusline-shim` token**, and `isCodemanStatusLine()` ALSO accepts the pre-shim `/api/status-telemetry` command: drop that second form and every repo an older Codeman managed reads as hand-authored, so the upgrade refuses to touch it and the user keeps the shadowing exporter forever. The shim skips both forms when hunting a delegate, which is the same predicate serving as its loop guard. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through**. Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox, chip visibility, and Claude create-time exporter flag. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/statusline-shim.test.ts` (runs the generated shim as a real subprocess, since tsc never sees it).
### Cron jobs
+1 -1
View File
@@ -1,6 +1,6 @@
# Plan Usage Limits Display — Design & As-Built
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. **The exporter changed shape 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.
>
> Two surfaces from one `statusLine` callback:
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
+41 -14
View File
@@ -39,6 +39,7 @@ import { fileURLToPath } from 'node:url';
import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
import { generateShimStatusLineCommand, LEGACY_STATUSLINE_MARKER, STATUSLINE_SHIM_TOKEN } from './statusline-shim.js';
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
@@ -844,17 +845,30 @@ 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 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.
*/
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 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.
*/
export function generateStatusLineCommand(): string {
function generateInlineStatusLineCommand(): string {
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
// production setup; without -k curl returns 000 and the statusline shows
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
@@ -862,20 +876,33 @@ export function generateStatusLineCommand(): 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 -sk -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`
);
}
/**
* 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.
*/
export function generateStatusLineCommand(): string {
return generateShimStatusLineCommand() ?? generateInlineStatusLineCommand();
}
/**
* 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 +916,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();
+393
View File
@@ -0,0 +1,393 @@
/**
* @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` — the response this
* instance returns for a session id it does not know.
*
* 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. 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.
*
* ## 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:
* `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.
*/
export const STATUSLINE_SHIM_TOKEN = 'codeman-statusline-shim';
/**
* The pre-shim inline exporter's ownership marker, kept only for recognition.
*
* 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.
*/
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';
/** 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 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.
* - **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 { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import http from 'node:http'
import https from 'node:https'
const SHIM_TOKEN = ${JSON.stringify(STATUSLINE_SHIM_TOKEN)}
const LEGACY_MARKER = ${JSON.stringify(LEGACY_STATUSLINE_MARKER)}
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.
}
// 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()
/**
* 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.
*/
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)]
}
/** 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 either the 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 response body, or null. */
function postTelemetry() {
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,
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, 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$/, ''))
`;
/** 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 falls back to the
* inline command.
*/
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 statusLine command Codeman injects.
*
* `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.
*/
export function generateShimStatusLineCommand(): string | null {
const shim = ensureStatusLineShim();
if (!shim) return null;
return `${shQuote(process.execPath)} ${shQuote(shim)}`;
}
/** Test seam: forget the per-process memo so a fresh temp data dir is provisioned. */
export function resetStatusLineShimForTest(): void {
ensuredThisProcess = false;
}
+79
View File
@@ -23,6 +23,7 @@ import {
updateCaseModel,
writeHooksConfig,
} from '../src/hooks-config.js';
import { LEGACY_STATUSLINE_MARKER, STATUSLINE_SHIM_TOKEN } from '../src/statusline-shim.js';
describe('generateHooksConfig', () => {
it('should return an object with hooks key', () => {
@@ -1305,3 +1306,81 @@ 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 delegating shim rather than an inline exporter', async () => {
await applyStatusLineConfig(testDir, true);
const { statusLine } = read();
expect(statusLine.type).toBe('command');
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);
});
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);
});
});
+303
View File
@@ -0,0 +1,303 @@
/**
* The generated plan-usage statusLine shim.
*
* 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.
*
* 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,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import {
ensureStatusLineShim,
generateShimStatusLineCommand,
LEGACY_STATUSLINE_MARKER,
resetStatusLineShimForTest,
statusLineShimPath,
STATUSLINE_SHIM_TOKEN,
} from '../src/statusline-shim.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;
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 injected command carries the ownership token', () => {
// applyStatusLineConfig 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);
// Absolute node, not a bare `node`: a managed session's PATH need not have one.
expect(command).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()}'`);
});
});
describe('statusLine shim: rendering', () => {
let shim: string;
let server: Server;
let received: Array<{ url: string; body: string }> = [];
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));
});
}
/** 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));
}
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('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 = [];
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', 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.
const { stdout, code } = await render({ cwd: workspace });
expect(code).toBe(0);
expect(stdout).toBe('codeman');
});
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('codeman');
});
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('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.
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);
expect(stdout).toBe('PROJECT');
});
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}` }
);
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 },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
);
expect(stdout).toBe('CODEMAN-FOOTER');
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.
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.
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}` }
);
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 });
expect(stdout).toBe('codeman');
});
});