mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
2
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)
|
**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`.
|
**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
|
# 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:
|
> Two surfaces from one `statusLine` callback:
|
||||||
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
||||||
|
|||||||
@@ -101,10 +101,14 @@ subscription plan.
|
|||||||
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
|
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
|
||||||
off on phones.
|
off on phones.
|
||||||
|
|
||||||
It works by installing a status line exporter into Claude Code, which posts Claude's own
|
It works by installing a status line exporter into each managed repo's
|
||||||
rate limit data back to Codeman. The exporter is marker-identified, so it only ever touches
|
`.claude/settings.local.json`, which posts Claude's own rate limit data back to Codeman. The
|
||||||
a status line Codeman installed, never one you wrote yourself, and it prints your footer
|
exporter is marker-identified, so it only ever touches a status line Codeman installed, never
|
||||||
through so the in-terminal status line still works.
|
one you wrote yourself. A repo's status line outranks the one in `~/.claude/settings.json`,
|
||||||
|
so the exporter also runs the status line it shadows and prints that instead of its own
|
||||||
|
footer: your global status line keeps rendering in managed repos, and in a repo with no
|
||||||
|
status line of your own you get Codeman's compact session footer. Turning the chip off takes
|
||||||
|
the exporter back out of the repos of your live sessions.
|
||||||
|
|
||||||
The chip and the exporter are the same setting. Turning the chip on without the exporter
|
The chip and the exporter are the same setting. Turning the chip on without the exporter
|
||||||
would leave it showing a dash forever, so resolve it in one place: **App Settings**.
|
would leave it showing a dash forever, so resolve it in one place: **App Settings**.
|
||||||
|
|||||||
+57
-19
@@ -39,6 +39,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import type { HookEventType } from './types.js';
|
import type { HookEventType } from './types.js';
|
||||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||||
import { dataPath } from './config/instance.js';
|
import { dataPath } from './config/instance.js';
|
||||||
|
import { LEGACY_STATUSLINE_MARKER, statusLineShimGuard, STATUSLINE_SHIM_TOKEN } from './statusline-shim.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||||
@@ -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:
|
* The inline exporter: env vars plus curl, portable by construction. It POSTs
|
||||||
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
|
* the statusline JSON and prints Codeman's answer, which means it SHADOWS
|
||||||
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
|
* whatever statusline the user configured globally. That is the cost the shim
|
||||||
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
|
* exists to remove, so this half only renders where the shim cannot run: inside
|
||||||
* (present in every managed session via tmux setenv), so the config is static.
|
* 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 {
|
function generateInlineStatusLineCommand(): string {
|
||||||
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
|
|
||||||
// production setup; without -k curl returns 000 and the statusline shows
|
|
||||||
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
|
|
||||||
// footer is never blank if Codeman is unreachable.
|
|
||||||
return (
|
return (
|
||||||
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
||||||
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||||
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
`curl -sfk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" ` +
|
||||||
`-H 'Content-Type: application/json' ` +
|
`-H 'Content-Type: application/json' ` +
|
||||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||||
`--data @- 2>/dev/null || echo codeman`
|
`--data @- 2>/dev/null || true`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan-usage statusLine exporter command.
|
||||||
|
*
|
||||||
|
* 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
|
* Add or remove Codeman's plan-usage statusLine exporter in
|
||||||
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
|
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
|
||||||
* (command targets `/api/status-telemetry`), so a user's hand-authored
|
* (see `isCodemanStatusLine`, which accepts the shim and the pre-shim inline
|
||||||
* statusLine is never removed OR overwritten — on both the enable and disable
|
* form), so a user's hand-authored statusLine is never removed OR overwritten
|
||||||
* paths we bail out when an existing statusLine isn't ours. Callers gate on
|
* — on both the enable and disable paths we bail out when an existing
|
||||||
* Claude mode. Merges, preserving all other keys (hooks, env, model).
|
* 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> {
|
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
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 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) {
|
if (enabled) {
|
||||||
const desired = generateStatusLineCommand();
|
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 —
|
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
||||||
* which live in the Codeman header chip. Claude requires a statusLine command to
|
* which live in the Codeman header chip. Claude requires a statusLine command to
|
||||||
* emit the rate_limits JSON at all, so this is what that command prints back.
|
* emit the rate_limits JSON at all, so this is what that command prints back
|
||||||
|
* when it has no statusline of the user's own to delegate to. With nothing to
|
||||||
|
* show it returns '' rather than a brand word: the exporter's shim reads an
|
||||||
|
* empty footer as "no telemetry", and a bare `codeman` on the statusline is the
|
||||||
|
* symptom discussion #405 opened with.
|
||||||
*/
|
*/
|
||||||
export function formatSessionStatusText(s: SessionStatus | null): string {
|
export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||||
if (!s) return 'codeman';
|
if (!s) return '';
|
||||||
const groups: string[] = [];
|
const groups: string[] = [];
|
||||||
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
||||||
const tok: string[] = [];
|
const tok: string[] = [];
|
||||||
@@ -184,7 +188,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
|
|||||||
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
||||||
if (tok.length) groups.push(tok.join(' '));
|
if (tok.length) groups.push(tok.join(' '));
|
||||||
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
||||||
return groups.length ? groups.join(' ') : 'codeman';
|
return groups.length ? groups.join(' ') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2050,6 +2050,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
|
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
|
||||||
// as "previously off" — used below to detect a real OFF→ON flip.
|
// as "previously off" — used below to detect a real OFF→ON flip.
|
||||||
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
||||||
|
// Plan-usage chip: the exporter it depends on is removed from live workspaces
|
||||||
|
// ONLY on the save that turns the chip off (see statusLineTelemetryAction).
|
||||||
|
const _prevPlanUsageChip = this.planUsageChipEnabled(_prev);
|
||||||
const settings = {
|
const settings = {
|
||||||
displayName: window.CodemanI18n?.normalizeDisplayName(
|
displayName: window.CodemanI18n?.normalizeDisplayName(
|
||||||
document.getElementById('appSettingsDisplayName').value
|
document.getElementById('appSettingsDisplayName').value
|
||||||
@@ -2280,9 +2283,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
|
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
|
||||||
// also absent from SettingsUpdateSchema, which is .strict() — sending it
|
// also absent from SettingsUpdateSchema, which is .strict() — sending it
|
||||||
// would 400 the whole settings PUT.
|
// would 400 the whole settings PUT.
|
||||||
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
|
// Telemetry COLLECTION is requested out-of-band via the statusLineTelemetry
|
||||||
// ENABLE only, so a device with the chip OFF never strips the exporter that
|
// action field: `true` on every save while the chip is on, `false` only on the
|
||||||
// another device's chip depends on — see system-routes settings handler).
|
// save that turned it off here, nothing otherwise (statusLineTelemetryAction),
|
||||||
|
// so a device whose chip was never on cannot strip the exporter another
|
||||||
|
// device's chip depends on. See the system-routes settings handler.
|
||||||
const {
|
const {
|
||||||
localEchoEnabled: _leo,
|
localEchoEnabled: _leo,
|
||||||
cjkInputEnabled: _cjk,
|
cjkInputEnabled: _cjk,
|
||||||
@@ -2316,10 +2321,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
sessionLineageLines: _sll,
|
sessionLineageLines: _sll,
|
||||||
...serverSettings
|
...serverSettings
|
||||||
} = settings;
|
} = settings;
|
||||||
|
const statusLineTelemetry = this.statusLineTelemetryAction(_prevPlanUsageChip, settings.showPlanUsageLimits);
|
||||||
try {
|
try {
|
||||||
const res = await this._apiPut('/api/settings', {
|
const res = await this._apiPut('/api/settings', {
|
||||||
...serverSettings,
|
...serverSettings,
|
||||||
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
|
...(statusLineTelemetry === undefined ? {} : { statusLineTelemetry }),
|
||||||
notificationPreferences: notifPrefsToSave,
|
notificationPreferences: notifPrefsToSave,
|
||||||
voiceSettings,
|
voiceSettings,
|
||||||
});
|
});
|
||||||
@@ -2592,6 +2598,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
|
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// What a settings save tells the server about the plan-usage exporter, given
|
||||||
|
// the chip's state before and after the save. `true` re-injects the exporter
|
||||||
|
// into every live Claude workspace and may ride every save while the chip is
|
||||||
|
// on. `false` REMOVES it from those workspaces, and the chip is per-device
|
||||||
|
// while the exporter lives in each repo's shared settings.local.json, so it
|
||||||
|
// may ride only the save that turned the chip off on this device: a phone
|
||||||
|
// whose chip was never on must never strip what a desktop's chip depends on.
|
||||||
|
// Pure, so test/plan-usage-telemetry-action.test.ts can pin all three cases.
|
||||||
|
statusLineTelemetryAction(prevEnabled, nowEnabled) {
|
||||||
|
if (nowEnabled) return true;
|
||||||
|
if (prevEnabled) return false;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
|
||||||
applyHeaderVisibilitySettings() {
|
applyHeaderVisibilitySettings() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
const defaults = this.getDefaultSettings();
|
const defaults = this.getDefaultSettings();
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
||||||
*
|
*
|
||||||
* Returns a compact plain-text status string for the exporter to print as the
|
* Returns a compact plain-text status string for the exporter to print as the
|
||||||
* in-terminal footer (print-through), so injecting our statusLine doesn't leave
|
* in-terminal footer when it has no statusline of the user's own to delegate to
|
||||||
* the terminal footer blank.
|
* (see `statusline-shim.ts`). An unknown session gets an EMPTY body: the old
|
||||||
|
* brand-word answer rendered as the statusline of every hand-run `claude` in a
|
||||||
|
* managed repo, and cost discussion #405 seven repositories of debugging.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
@@ -36,10 +38,12 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
|||||||
|
|
||||||
reply.type('text/plain; charset=utf-8');
|
reply.type('text/plain; charset=utf-8');
|
||||||
|
|
||||||
// Unknown session — minimal footer, no broadcast.
|
// Unknown session: nothing to broadcast and nothing to print. Never a brand
|
||||||
|
// word here, it would render as the statusline (the shim treats an empty
|
||||||
|
// answer as "no telemetry" and falls through to the delegate or to blank).
|
||||||
if (!ctx.sessions.has(sessionId)) {
|
if (!ctx.sessions.has(sessionId)) {
|
||||||
lastSig.delete(sessionId);
|
lastSig.delete(sessionId);
|
||||||
return 'codeman';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = data as RawStatuslinePayload | undefined;
|
const payload = data as RawStatuslinePayload | undefined;
|
||||||
|
|||||||
@@ -1034,20 +1034,34 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
|
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
|
||||||
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
|
// Telemetry COLLECTION is a per-save ACTION field in both directions. `true`
|
||||||
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
|
// rides every save while the chip is on: we (re)inject our exporter into every
|
||||||
// into every ACTIVE Claude session's working dir so the live % starts flowing
|
// ACTIVE Claude session's working dir so the live % starts flowing immediately
|
||||||
// immediately (no new session needed). We deliberately never auto-REMOVE here:
|
// (no new session needed), and that is also how a second device catches up.
|
||||||
// the exporter is benign/print-through and a per-repo settings.local.json is
|
// `false` rides ONLY the save that turned the chip OFF on that device
|
||||||
// shared by sibling sessions, so one device's "off" must not yank the exporter
|
// (statusLineTelemetryAction in settings-ui.js) and takes our exporter back
|
||||||
// another device's chip depends on. Each dir handled once.
|
// out of those same dirs. Nothing called the disable path before, so turning
|
||||||
if (statusLineTelemetry === true) {
|
// the chip off left the line in every repo it had ever reached (#405). A
|
||||||
|
// per-repo settings.local.json is shared by sibling sessions and by every
|
||||||
|
// device, so the flip-only rule is what keeps a phone whose chip was never on
|
||||||
|
// from stripping the exporter a desktop's chip depends on; a device with the
|
||||||
|
// chip still on re-injects on its next save or session create and shows the
|
||||||
|
// last snapshot meanwhile. Both paths are isOurs-guarded (a hand-authored
|
||||||
|
// statusLine is never touched), remote attaches are skipped (their workingDir
|
||||||
|
// is a user@host:session pseudo-path the enable path would mkdir as a junk
|
||||||
|
// local dir), and each dir is handled once.
|
||||||
|
if (statusLineTelemetry === true || statusLineTelemetry === false) {
|
||||||
|
const user = getAuthUser(req);
|
||||||
const dirs = new Set<string>();
|
const dirs = new Set<string>();
|
||||||
for (const session of ctx.sessions.values()) {
|
for (const session of ctx.sessions.values()) {
|
||||||
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir)
|
if (!getCli(session.mode)?.capabilities.statusLineTelemetry || !session.workingDir) continue;
|
||||||
|
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);
|
dirs.add(session.workingDir);
|
||||||
}
|
}
|
||||||
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
|
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, statusLineTelemetry).catch(() => {})));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tunnel toggle dynamically
|
// Handle tunnel toggle dynamically
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
updateCaseModel,
|
updateCaseModel,
|
||||||
writeHooksConfig,
|
writeHooksConfig,
|
||||||
} from '../src/hooks-config.js';
|
} from '../src/hooks-config.js';
|
||||||
|
import { LEGACY_STATUSLINE_MARKER, STATUSLINE_SHIM_TOKEN } from '../src/statusline-shim.js';
|
||||||
|
|
||||||
describe('generateHooksConfig', () => {
|
describe('generateHooksConfig', () => {
|
||||||
it('should return an object with hooks key', () => {
|
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');
|
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 });
|
const res = await post({ sessionId: 'does-not-exist', data: REAL });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body).toBe('codeman');
|
expect(res.body).toBe('');
|
||||||
expect(h.ctx.broadcast).not.toHaveBeenCalled();
|
expect(h.ctx.broadcast).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* PUT /api/settings: the `statusLineTelemetry` ACTION field, both directions.
|
||||||
|
*
|
||||||
|
* `true` (sent on every save while the plan-usage chip is on) injects Codeman's
|
||||||
|
* statusLine exporter into every live Claude session's workspace so the chip's
|
||||||
|
* data starts flowing without a new session. `false` (sent only when the chip
|
||||||
|
* was just turned OFF on a device) takes the exporter back out of those same
|
||||||
|
* workspaces. Before this, nothing in `src/` ever called the disable path, so
|
||||||
|
* turning the chip off left the line in every repo it had ever reached
|
||||||
|
* (discussion #405).
|
||||||
|
*
|
||||||
|
* Both directions are `isOurs`-guarded in `applyStatusLineConfig`, so a
|
||||||
|
* statusLine the user wrote themselves is never added to, replaced, or removed.
|
||||||
|
* Remote-attach sessions are skipped in both: their `workingDir` is a
|
||||||
|
* `user@host:session` pseudo-path that the enable path would otherwise create
|
||||||
|
* as a junk local directory.
|
||||||
|
*
|
||||||
|
* Uses app.inject(), real temp workspaces under the test HOME. Port: N/A.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { createMockSession } from '../mocks/mock-session.js';
|
||||||
|
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||||
|
import { STATUSLINE_SHIM_TOKEN } from '../../src/statusline-shim.js';
|
||||||
|
|
||||||
|
// The three service toggles start/stop real watchers from this handler; stub
|
||||||
|
// them so a settings PUT in a test never starts a filesystem watcher.
|
||||||
|
const { subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
|
||||||
|
const makeWatcher = () => ({
|
||||||
|
isRunning: vi.fn(() => false),
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
getStats: vi.fn(() => ({})),
|
||||||
|
watchSession: vi.fn(),
|
||||||
|
getRecentRunSummaries: vi.fn(() => []),
|
||||||
|
});
|
||||||
|
return { subagentWatcher: makeWatcher(), imageWatcher: makeWatcher(), workflowRunWatcher: makeWatcher() };
|
||||||
|
});
|
||||||
|
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
|
||||||
|
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
|
||||||
|
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
|
||||||
|
|
||||||
|
const settingsFile = (dir: string) => join(dir, '.claude', 'settings.local.json');
|
||||||
|
const readSettings = (dir: string) => JSON.parse(readFileSync(settingsFile(dir), 'utf-8'));
|
||||||
|
const writeSettings = (dir: string, value: object) => {
|
||||||
|
mkdirSync(join(dir, '.claude'), { recursive: true });
|
||||||
|
writeFileSync(settingsFile(dir), JSON.stringify(value, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PUT /api/settings statusLineTelemetry', () => {
|
||||||
|
let h: RouteTestHarness;
|
||||||
|
let root: string;
|
||||||
|
let claudeDir: string;
|
||||||
|
let shellDir: string;
|
||||||
|
let remoteDir: string;
|
||||||
|
|
||||||
|
const put = (body: unknown) => h.app.inject({ method: 'PUT', url: '/api/settings', payload: body });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createRouteTestHarness(registerSystemRoutes);
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'codeman-statusline-toggle-'));
|
||||||
|
claudeDir = join(root, 'claude-repo');
|
||||||
|
shellDir = join(root, 'shell-repo');
|
||||||
|
remoteDir = join(root, 'remote-attach');
|
||||||
|
for (const dir of [claudeDir, shellDir]) mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const claude = createMockSession('claude-1');
|
||||||
|
claude.workingDir = claudeDir;
|
||||||
|
const shell = createMockSession('shell-1');
|
||||||
|
shell.mode = 'shell';
|
||||||
|
shell.workingDir = shellDir;
|
||||||
|
const remote = createMockSession('remote-1');
|
||||||
|
remote.workingDir = remoteDir;
|
||||||
|
Object.assign(remote, { remote: { host: 'box', session: 'codeman-ssh-remote-1' } });
|
||||||
|
h.ctx.sessions.clear();
|
||||||
|
for (const s of [claude, shell, remote]) h.ctx.sessions.set(s.id, s);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await h.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('true injects the exporter into live Claude workspaces only', async () => {
|
||||||
|
const res = await put({ statusLineTelemetry: true });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(readSettings(claudeDir).statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||||
|
// A shell session has no statusline to export from.
|
||||||
|
expect(existsSync(settingsFile(shellDir))).toBe(false);
|
||||||
|
// A remote attach's workingDir is a pseudo-path: nothing must be created for it.
|
||||||
|
expect(existsSync(remoteDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false removes the exporter it injected', async () => {
|
||||||
|
await put({ statusLineTelemetry: true });
|
||||||
|
expect(readSettings(claudeDir).statusLine).toBeDefined();
|
||||||
|
|
||||||
|
const res = await put({ statusLineTelemetry: false });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(readSettings(claudeDir).statusLine).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false keeps every other key in the workspace settings file', async () => {
|
||||||
|
writeSettings(claudeDir, { permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
|
||||||
|
await put({ statusLineTelemetry: true });
|
||||||
|
await put({ statusLineTelemetry: false });
|
||||||
|
expect(readSettings(claudeDir)).toEqual({ permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false never removes a statusLine the user wrote themselves', async () => {
|
||||||
|
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
|
||||||
|
writeSettings(claudeDir, { statusLine: mine });
|
||||||
|
await put({ statusLineTelemetry: false });
|
||||||
|
expect(readSettings(claudeDir).statusLine).toEqual(mine);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false creates nothing in a workspace that never had the exporter', async () => {
|
||||||
|
await put({ statusLineTelemetry: false });
|
||||||
|
expect(existsSync(settingsFile(claudeDir))).toBe(false);
|
||||||
|
expect(existsSync(remoteDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is an action field, never persisted into settings.json', async () => {
|
||||||
|
await put({ statusLineTelemetry: false, showTokenCount: true });
|
||||||
|
const res = await h.app.inject({ method: 'GET', url: '/api/settings' });
|
||||||
|
const stored = JSON.parse(res.body);
|
||||||
|
const settings = stored.data ?? stored;
|
||||||
|
expect(settings.showTokenCount).toBe(true);
|
||||||
|
expect('statusLineTelemetry' in settings).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)');
|
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a brand string when there is no data', () => {
|
it('prints nothing when there is no data, never a brand string', () => {
|
||||||
expect(formatSessionStatusText(null)).toBe('codeman');
|
expect(formatSessionStatusText(null)).toBe('');
|
||||||
|
expect(formatSessionStatusText({})).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user