mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1cd7884d4 | ||
|
|
c375268879 | ||
|
|
c4b74415ee | ||
|
|
d8a9e2f2bb | ||
|
|
708cb2cbf0 | ||
|
|
90ac13da1a | ||
|
|
48f30f3055 | ||
|
|
c211461500 | ||
|
|
37929cb671 | ||
|
|
e0ebbbdc91 | ||
|
|
8c237223b0 | ||
|
|
dae2ac580f | ||
|
|
7767b16d4f | ||
|
|
0cedf05d13 | ||
|
|
58b4cb06d8 |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
fix(statusline): stop the plan-usage exporter from stealing the user's statusline (#405)
|
||||
|
||||
Claude Code ranks a repo's `.claude/settings.local.json` above `~/.claude/settings.json`, so
|
||||
the statusLine Codeman injects for the Plan Usage chip shadowed whatever statusline the user
|
||||
had configured globally, and running `claude` by hand in a managed repo rendered the bare word
|
||||
`codeman`. The exporter is now a generated, delegating shim (`src/statusline-shim.ts`, the
|
||||
`deepseek-status-shim` pattern): it forwards the same payload to `/api/status-telemetry` and,
|
||||
concurrently, runs the statusline it shadows and prints that. Codeman's footer appears only when
|
||||
there is nothing to shadow, and with neither the line stays blank. The delegate is resolved at
|
||||
render time from the three settings files Claude Code documents (`workspace.project_dir` first,
|
||||
then `~/.claude/settings.json`), never from an ancestor directory or a user-level
|
||||
`settings.local.json`.
|
||||
|
||||
The injected command is a self-selecting shell guard that runs the shim where it exists and
|
||||
falls through to the inline curl exporter where it does not, so the same bind-mounted
|
||||
`settings.local.json` still reports telemetry from inside a Docker case's container. Ownership
|
||||
accepts both the new `codeman-statusline-shim` token and the old `/api/status-telemetry`
|
||||
command, so repos managed by an older Codeman upgrade in place. `POST /api/status-telemetry`
|
||||
answers an unknown session with an empty body instead of `codeman`, and the session-status
|
||||
footer is empty rather than a brand word when the payload carries nothing to show.
|
||||
|
||||
Turning the Plan Usage chip off now removes the exporter from the workspaces of your live Claude
|
||||
sessions. The removal rides only the settings save that flips the chip off on a device, so a
|
||||
phone whose chip was never on cannot strip the exporter a desktop depends on.
|
||||
@@ -1,5 +1,102 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.28.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 708cb2c: fix(tabs): let a wrapped desktop tab strip grow the header instead of clipping itself
|
||||
|
||||
The wrapped tab strip carried fixed height caps (120px for the manual two-row layout,
|
||||
96px for measured auto-wrap) that were row counts in disguise. A third row of tabs was
|
||||
clipped into a roughly 4px scroller, so the tab being looked for sat off-screen inside a
|
||||
container nothing invites you to scroll, while the header had the whole page below it to
|
||||
grow into. The header is `min-height` plus `flex-shrink: 0`, and terminal-ui's
|
||||
ResizeObserver refits the terminal on its own, so growing it costs nothing.
|
||||
|
||||
Both wrapped layouts now share one rule capped at `var(--tab-strip-max-height, 40vh)`.
|
||||
That cap is a safety net for an absurd session count rather than a row limit: past it the
|
||||
scroller comes back, which still beats a header that swallows the terminal. Nothing sets
|
||||
`--tab-strip-max-height` yet, so today it is the 40vh fallback plus a hook for a future
|
||||
control.
|
||||
|
||||
Desktop only in effect. `tabs-auto-wrap` is applied by `updateTabOverflowMode()`, which
|
||||
returns early for anything that is not a desktop viewport, and below 1024px `mobile.css`
|
||||
pins the header to `max-height: 48px` so it cannot grow at all. The two rules are
|
||||
comma-grouped rather than wrapped in `:is()`, so each arm keeps its own (0,2,0)
|
||||
specificity and `mobile.css`'s matching overrides still win on source order.
|
||||
|
||||
## 1.28.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 58b4cb0: feat(files): let the path picker jump to a typed path and sort by name or date
|
||||
|
||||
The picker's current-folder line was read-only, so reaching a deep folder meant tapping
|
||||
through every level, and its listing was fixed to name order, so the file an agent had
|
||||
just written was somewhere in a 500-entry list. The current folder is now an editable
|
||||
field (Enter or Go jumps there, a full file path lands in its folder with the file
|
||||
selected, and a typo keeps the listing you had instead of resetting to the root), the
|
||||
listing can be sorted by name or modified time in either direction with folders always
|
||||
first (the choice is remembered per device), and each entry shows a compact modified
|
||||
time. `GET /api/filesystem/browse` entries carry `mtimeMs` to make that possible, with
|
||||
one stat per entry.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c211461: fix(terminal): swallow Ctrl+Z in agent sessions so it cannot suspend a running CLI
|
||||
|
||||
Ctrl+Z raises SIGTSTP on the pane's tty. In a `shell` session that is ordinary job control and
|
||||
is left alone, but in an agent session suspending the CLI stops an unattended loop dead with no
|
||||
visible output, the same failure shape as an XOFF freeze. The key is now swallowed in
|
||||
`attachCustomKeyEventHandler` for every non-shell mode, and unconditionally in the
|
||||
subagent/teammate terminals, which always run an agent CLI. The match is case-insensitive,
|
||||
because Caps Lock flips `ev.key` to `'Z'` without setting `shiftKey` and a plain `=== 'z'`
|
||||
check would let exactly the keystroke this exists to catch through.
|
||||
|
||||
This is defence in depth rather than a fix for the steady state: an agent CLI holds its tty in
|
||||
raw mode with ISIG off, where ^Z is already inert. It covers the moments that are not the
|
||||
steady state: the window before the CLI takes the tty at startup, and any point where it hands
|
||||
the tty back. Two input paths are deliberately not covered and still reach the PTY: the mobile
|
||||
keyboard accessory bar's one-shot Ctrl, and the CJK composition textarea when `cjkInputEnabled`
|
||||
is on. Both are separate choke points to the PTY, and both are worth covering if this ever
|
||||
turns out to matter in practice.
|
||||
|
||||
- 7767b16: fix(terminal): let Claude use truecolor so its themed backgrounds render
|
||||
|
||||
Claude draws the user's own messages as a block of background color, and it renders as an
|
||||
approximation of the theme color at best. Claude's registry entry deleted `COLORTERM`, which
|
||||
left it the only agent CLI here besides `opencode` not asking for 24-bit color, so every RGB
|
||||
color its theme asks for was quantized down to whatever palette `TERM` alone implies. Claude
|
||||
now exports `COLORTERM=truecolor` like codex, gemini, antigravity, pi, grok, deepseek and omp
|
||||
already do, and the block renders in the color the theme actually names.
|
||||
|
||||
How bad the quantization was depends on `TERM`, which is why this looks different on different
|
||||
machines. On tmux 3.2 and newer, whose `default-terminal` defaults to `tmux-256color`,
|
||||
supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but
|
||||
not the color the theme asked for. Where `TERM` resolves to a 16-color entry instead (tmux
|
||||
older than 3.2, or a `~/.tmux.conf` setting `default-terminal screen`, which Codeman's tmux
|
||||
server does read), every dark background collapses to `ESC[40m`, the terminal's own black, and
|
||||
the block disappears entirely. That is the case this was reported from, and a custom Claude
|
||||
theme could change the color there with nothing on screen moving.
|
||||
|
||||
Those seven CLIs also unset `NO_COLOR`; Claude does not, so a user who exports `NO_COLOR`
|
||||
globally keeps the monochrome panes they asked for. `CLAUDECODE` stays unset, because Claude
|
||||
reads it as a signal that it is running nested inside itself.
|
||||
|
||||
`buildClaudeEnv()`, the direct-PTY fallback used when tmux is unavailable, now reads the same
|
||||
registry entry as the tmux pane and its attach client instead of deleting `COLORTERM` from a
|
||||
hand-maintained list of its own. It applies that entry before assigning Codeman's own
|
||||
variables, mirroring `buildEnvExports()`, so a `clis.json` override naming one of them cannot
|
||||
strip it on this path while the tmux pane keeps it. A remote pane still exports nothing,
|
||||
because `buildRemoteLaunchCommand()` never carried these declarations, so an SSH-remote Claude
|
||||
session keeps the old rendering.
|
||||
|
||||
PR #3 introduced the `unset COLORTERM` in February, citing xterm.js#484 for the claim that
|
||||
xterm.js mishandles truecolor, and aiming to fall back to 256-color mode. xterm.js closed that
|
||||
issue in April 2019, Codeman now depends on `@xterm/xterm` 6, and `TmuxManager` sets
|
||||
`terminal-overrides ",*:Tc"` on its own tmux server, so 24-bit color already reaches the
|
||||
browser for the CLIs that ask for it.
|
||||
|
||||
## 1.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -75,7 +75,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.27.0 (must match `package.json`)
|
||||
**Version**: 1.28.1 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -207,7 +207,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine, and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry` and never overwrites a user's hand-authored statusLine. ⚠️ **Injecting a statusLine SHADOWS the user's own** (#405): a repo's `.claude/settings.local.json` outranks `~/.claude/settings.json`, and the old inline exporter printed Codeman's footer in its place, so a hand-run `claude` in any managed repo showed the bare word `codeman`. The exporter is therefore a generated, delegating shim (`src/statusline-shim.ts` writes `dataPath('codeman-statusline-shim.mjs')`, the `deepseek-status-shim` pattern) that forwards the blob and, concurrently, runs the statusline it shadows and prints THAT; the route's footer fills in only when there is nothing to shadow, and with neither it prints NOTHING (the route answers an unknown session with an empty body and `formatSessionStatusText(null)` is `''`: never a brand word). ⚠️ The delegate resolves at RENDER time from exactly the three documented files, `.claude/settings.local.json` + `.claude/settings.json` under `workspace.project_dir` (the launch dir), then `~/.claude/settings.json`, first non-ours wins: no ancestor walk and no user-level `settings.local.json`, since delegating to a command Claude Code would have ignored is the original failure in a new coat. ⚠️ **The injected command stays self-contained shell**: `if [ -x <node> ] && [ -f <shim> ]; then exec …; fi;` followed by the inline curl exporter, so the SAME bind-mounted `settings.local.json` renders the shim on the host and the curl inside a Docker case's container, where neither the host's node nor `~/.codeman` exists. Ownership (`isCodemanStatusLine()`) accepts the version-free `codeman-statusline-shim` token OR the `/api/status-telemetry` path; dropping the second makes every repo an older Codeman managed read as hand-authored. ⚠️ `statusLineTelemetry` is a settings-save ACTION field in BOTH directions: `true` on every save while the chip is on (re-injects into every live Claude workspace, remote pseudo-paths skipped), `false` ONLY on the save that turned the chip OFF on that device (`statusLineTelemetryAction()` in settings-ui.js), which removes our entry from those workspaces. Nothing called `applyStatusLineConfig(dir, false)` before, so turning the chip off left the line in every repo it had ever reached. The flip-only rule is what keeps a phone whose chip was never on from stripping the exporter a desktop depends on; a second device with the chip still on re-injects on its next save or session create and shows the last snapshot meanwhile. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# Plan Usage Limits Display — Design & As-Built
|
||||
|
||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
> **Status: SHIPPED. Deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. **The exporter changed shape in 1.28 (discussion #405)**: it is now a self-selecting shell guard that runs a generated, delegating shim (`src/statusline-shim.ts`) where the shim exists and the inline curl below where it does not (inside a Docker case's container). The shim prints the statusline it shadows and falls back to the footer below only when there is nothing to shadow; with neither it prints nothing, and the route now answers an unknown session with an empty body, so the bare word `codeman` never renders. Turning the chip OFF now also removes the exporter from live workspaces (`statusLineTelemetry:false`, sent only on the save that flips the chip off on a device), so the "removal only via the toggle" sentences below are current again and the "never remove" ones record the 1.9-1.27 shape. See `docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry`. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
>
|
||||
> Two surfaces from one `statusLine` callback:
|
||||
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
||||
|
||||
@@ -101,10 +101,14 @@ subscription plan.
|
||||
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
|
||||
off on phones.
|
||||
|
||||
It works by installing a status line exporter into Claude Code, which posts Claude's own
|
||||
rate limit data back to Codeman. The exporter is marker-identified, so it only ever touches
|
||||
a status line Codeman installed, never one you wrote yourself, and it prints your footer
|
||||
through so the in-terminal status line still works.
|
||||
It works by installing a status line exporter into each managed repo's
|
||||
`.claude/settings.local.json`, which posts Claude's own rate limit data back to Codeman. The
|
||||
exporter is marker-identified, so it only ever touches a status line Codeman installed, never
|
||||
one you wrote yourself. A repo's status line outranks the one in `~/.claude/settings.json`,
|
||||
so the exporter also runs the status line it shadows and prints that instead of its own
|
||||
footer: your global status line keeps rendering in managed repos, and in a repo with no
|
||||
status line of your own you get Codeman's compact session footer. Turning the chip off takes
|
||||
the exporter back out of the repos of your live sessions.
|
||||
|
||||
The chip and the exporter are the same setting. Turning the chip on without the exporter
|
||||
would leave it showing a dash forever, so resolve it in one place: **App Settings**.
|
||||
|
||||
@@ -117,10 +117,14 @@ For choosing a path rather than typing one. It appears in two places:
|
||||
- **Browse** in **Add Case → Link Existing**.
|
||||
- The **📁 Path** key on the mobile keyboard bar.
|
||||
|
||||
It browses one directory at a time and can show hidden entries on request. The picker
|
||||
inserts the path into your prompt **without** pressing Enter, so nothing is submitted by
|
||||
accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's
|
||||
`/clear` command.
|
||||
It browses one directory at a time and can show hidden entries on request. The current
|
||||
folder is an editable field: type or paste a path and press Enter (or **Go**) to jump
|
||||
straight there, and a full file path lands in its folder with that file selected. The
|
||||
**Sort** control orders each listing by name or by modified time (newest first is the
|
||||
quick way to the file an agent just wrote), with folders always ahead of files; the
|
||||
choice is remembered per device. The picker inserts the path into your prompt
|
||||
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
|
||||
key clears the unsent prompt, and never sends the agent's `/clear` command.
|
||||
|
||||
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
|
||||
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -174,8 +174,19 @@ const CLAUDE: CliEntry = {
|
||||
legacyConfigAliases: { resumeId: 'resumeSessionId' },
|
||||
},
|
||||
env: {
|
||||
exports: [],
|
||||
unset: ['CLAUDECODE', 'COLORTERM'],
|
||||
// Claude asks for truecolor, like every CLI here except `shell` and `opencode`.
|
||||
// tmux hands the pane TERM=screen, which supports-color reads as 16 colors, and
|
||||
// Claude then quantizes every RGB color its theme asks for down to that palette.
|
||||
// Each dark background lands on ESC[40m, the terminal's own black, so the block
|
||||
// Claude draws behind the user's own messages renders invisible. PR #3 unset
|
||||
// COLORTERM here against xterm.js#484, which xterm.js had already closed in 2019,
|
||||
// and Codeman now ships @xterm/xterm 6 and sets `terminal-overrides *:Tc` itself.
|
||||
// The other truecolor CLIs also unset NO_COLOR. Claude does not, so a user who
|
||||
// exports NO_COLOR globally keeps the monochrome panes they asked for.
|
||||
// CLAUDECODE stays unset, because Claude reads it as a signal that it is running
|
||||
// nested inside itself.
|
||||
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
|
||||
unset: ['CLAUDECODE'],
|
||||
tmuxSetenvKeys: [],
|
||||
dockerExecEnvNames: [],
|
||||
allowedPrefixes: ['CLAUDE_CODE_'],
|
||||
|
||||
+57
-19
@@ -39,6 +39,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import type { HookEventType } from './types.js';
|
||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { LEGACY_STATUSLINE_MARKER, statusLineShimGuard, STATUSLINE_SHIM_TOKEN } from './statusline-shim.js';
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||
@@ -844,38 +845,75 @@ async function readWorkspaceHooksEnabled(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
|
||||
const STATUSLINE_MARKER = '/api/status-telemetry';
|
||||
/**
|
||||
* Is this statusLine command one Codeman wrote?
|
||||
*
|
||||
* Two markers count, and every command Codeman has ever injected carries at
|
||||
* least one. The version-free `codeman-statusline-shim` token names the shim
|
||||
* file the current guarded command runs; the `/api/status-telemetry` path is
|
||||
* what the inline exporter posts to, in the pre-shim command AND in the
|
||||
* fallback half of the current one. Both must be read as ours, or the upgrade
|
||||
* mistakes an old injected command for a hand-authored line, refuses to touch
|
||||
* it, and leaves the user with the shadowing exporter.
|
||||
*/
|
||||
export function isCodemanStatusLine(command: unknown): boolean {
|
||||
return (
|
||||
typeof command === 'string' &&
|
||||
(command.includes(STATUSLINE_SHIM_TOKEN) || command.includes(LEGACY_STATUSLINE_MARKER))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
|
||||
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
|
||||
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
|
||||
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
|
||||
* (present in every managed session via tmux setenv), so the config is static.
|
||||
* The inline exporter: env vars plus curl, portable by construction. It POSTs
|
||||
* the statusline JSON and prints Codeman's answer, which means it SHADOWS
|
||||
* whatever statusline the user configured globally. That is the cost the shim
|
||||
* exists to remove, so this half only renders where the shim cannot run: inside
|
||||
* a Docker case's container (the workspace is bind-mounted, `~/.codeman` and
|
||||
* the host's node are not), or on a host whose data dir could not be written.
|
||||
*
|
||||
* `curl -sfk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
|
||||
* production setup, so without -k curl returns 000 (-k is safe here, loopback
|
||||
* only); -f keeps an HTTP error body off the statusline. On any failure it
|
||||
* prints NOTHING: the old `|| echo codeman` is the bare word that a hand-run
|
||||
* `claude` in a managed repo rendered, and that reads as a broken config.
|
||||
*/
|
||||
export function generateStatusLineCommand(): string {
|
||||
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
|
||||
// production setup; without -k curl returns 000 and the statusline shows
|
||||
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
|
||||
// footer is never blank if Codeman is unreachable.
|
||||
function generateInlineStatusLineCommand(): string {
|
||||
return (
|
||||
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
||||
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
||||
`curl -sfk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||
`--data @- 2>/dev/null || echo codeman`
|
||||
`--data @- 2>/dev/null || true`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan-usage statusLine exporter command.
|
||||
*
|
||||
* A self-selecting guard followed by the inline exporter: where the shim and
|
||||
* the node binary both exist the guard `exec`s the delegating shim, which
|
||||
* forwards the same JSON to Codeman and then prints the statusline its own
|
||||
* entry shadows; anywhere else the shell falls through to the inline curl.
|
||||
* The SAME injected string therefore renders correctly from the host and from
|
||||
* inside a Docker case's container, which is what lets a bind-mounted
|
||||
* `settings.local.json` carry it. See `statusLineShimGuard()`.
|
||||
*/
|
||||
export function generateStatusLineCommand(): string {
|
||||
const guard = statusLineShimGuard();
|
||||
const inline = generateInlineStatusLineCommand();
|
||||
return guard ? `${guard} ${inline}` : inline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove Codeman's plan-usage statusLine exporter in
|
||||
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
|
||||
* (command targets `/api/status-telemetry`), so a user's hand-authored
|
||||
* statusLine is never removed OR overwritten — on both the enable and disable
|
||||
* paths we bail out when an existing statusLine isn't ours. Callers gate on
|
||||
* Claude mode. Merges, preserving all other keys (hooks, env, model).
|
||||
* (see `isCodemanStatusLine`, which accepts the shim and the pre-shim inline
|
||||
* form), so a user's hand-authored statusLine is never removed OR overwritten
|
||||
* — on both the enable and disable paths we bail out when an existing
|
||||
* statusLine isn't ours. An enable on a repo still carrying the old inline
|
||||
* command upgrades it to the shim in place. Callers gate on Claude mode.
|
||||
* Merges, preserving all other keys (hooks, env, model).
|
||||
*/
|
||||
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
||||
@@ -889,7 +927,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
||||
}
|
||||
|
||||
const current = existing.statusLine as { command?: unknown } | undefined;
|
||||
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
|
||||
const isOurs = !!current && isCodemanStatusLine(current.command);
|
||||
|
||||
if (enabled) {
|
||||
const desired = generateStatusLineCommand();
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isEffortLevel } from './types.js';
|
||||
import { getAugmentedPath } from './utils/index.js';
|
||||
import { compareVersions } from './utils/dependency-checker.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
|
||||
/**
|
||||
* Build Claude CLI permission flags based on the configured mode.
|
||||
@@ -169,6 +170,36 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
};
|
||||
|
||||
// The colour and identity vars come from the registry entry, the same source
|
||||
// buildEnvExports() and buildMuxAttachEnv() read, so this fallback cannot drift from
|
||||
// the tmux pane the way a hand-maintained list here did.
|
||||
// ⚠️ This block runs BEFORE Codeman's own keys are assigned, mirroring
|
||||
// buildEnvExports(), where `...cliEnv` is emitted ahead of `export CODEMAN_MUX=1`.
|
||||
// Applied afterwards it would outrank them: `unset` and `exports` are config
|
||||
// (`~/.codeman/clis.json` overrides any entry), so an entry naming
|
||||
// CODEMAN_HOOK_SECRET_FILE or PATH would strip or rewrite it on this path while the
|
||||
// tmux pane, where Codeman's exports come last, kept its own value.
|
||||
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
|
||||
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
|
||||
const cliEnv = getCli('claude')?.env;
|
||||
for (const name of cliEnv?.unset ?? []) delete env[name];
|
||||
for (const item of cliEnv?.exports ?? []) {
|
||||
// A direct PTY has no mux, so `muxName` has no value to resolve against. Claude
|
||||
// declares literals only; an unresolvable engine value is skipped, never guessed.
|
||||
const value =
|
||||
typeof item.value === 'string'
|
||||
? item.value
|
||||
: item.value.engine === 'sessionId'
|
||||
? sessionId
|
||||
: item.value.engine === 'codemanPrefixedSessionId'
|
||||
? `codeman_${sessionId}`
|
||||
: undefined;
|
||||
if (value !== undefined) env[item.name] = value;
|
||||
}
|
||||
|
||||
Object.assign(env, {
|
||||
PATH: getAugmentedPath(),
|
||||
TERM: 'xterm-256color',
|
||||
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
||||
@@ -180,11 +211,7 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
// as the literal "CODEMAN_API_URL=undefined" (COD-115).
|
||||
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
||||
};
|
||||
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
|
||||
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
|
||||
delete env.COLORTERM;
|
||||
delete env.CLAUDECODE;
|
||||
});
|
||||
return env;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -77,6 +77,8 @@ export interface FilesystemBrowseEntry {
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
/** Last-modified time (ms since epoch) of the entry's target; lets the picker sort by date. */
|
||||
mtimeMs?: number;
|
||||
symlink?: boolean;
|
||||
previewKind?: FilesystemPreviewKind;
|
||||
}
|
||||
|
||||
@@ -173,10 +173,14 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
|
||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
||||
* which live in the Codeman header chip. Claude requires a statusLine command to
|
||||
* emit the rate_limits JSON at all, so this is what that command prints back.
|
||||
* emit the rate_limits JSON at all, so this is what that command prints back
|
||||
* when it has no statusline of the user's own to delegate to. With nothing to
|
||||
* show it returns '' rather than a brand word: the exporter's shim reads an
|
||||
* empty footer as "no telemetry", and a bare `codeman` on the statusline is the
|
||||
* symptom discussion #405 opened with.
|
||||
*/
|
||||
export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
if (!s) return 'codeman';
|
||||
if (!s) return '';
|
||||
const groups: string[] = [];
|
||||
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
||||
const tok: string[] = [];
|
||||
@@ -184,7 +188,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
||||
if (tok.length) groups.push(tok.join(' '));
|
||||
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
||||
return groups.length ? groups.join(' ') : 'codeman';
|
||||
return groups.length ? groups.join(' ') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,11 +47,24 @@
|
||||
// the picker browses Home and every configured root, so wanting dotfiles in a
|
||||
// project does not imply wanting them in ~.
|
||||
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
|
||||
// Per-device like the hidden toggle: how you scan a folder is a habit of the
|
||||
// hand, not of the workspace.
|
||||
const PATH_PICKER_SORT_KEY = 'codeman:pathPickerSort';
|
||||
const PATH_PICKER_SORT_MODES = [
|
||||
{ value: 'name-asc', label: 'Name A→Z' },
|
||||
{ value: 'name-desc', label: 'Name Z→A' },
|
||||
{ value: 'mtime-desc', label: 'Newest first' },
|
||||
{ value: 'mtime-asc', label: 'Oldest first' },
|
||||
];
|
||||
const PATH_PICKER_DEFAULT_SORT = 'name-asc';
|
||||
|
||||
const PathPicker = {
|
||||
overlay: null,
|
||||
_options: null,
|
||||
_selectedPath: '',
|
||||
_currentPath: '',
|
||||
_entries: [],
|
||||
_truncated: false,
|
||||
_previousFocus: null,
|
||||
_keydownHandler: null,
|
||||
_loadSequence: 0,
|
||||
@@ -59,6 +72,7 @@ const PathPicker = {
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
_showHidden: false,
|
||||
_sortMode: PATH_PICKER_DEFAULT_SORT,
|
||||
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
@@ -69,7 +83,10 @@ const PathPicker = {
|
||||
this.close(false);
|
||||
this._options = options;
|
||||
this._selectedPath = '';
|
||||
this._currentPath = '';
|
||||
this._entries = [];
|
||||
this._showHidden = this._loadShowHidden();
|
||||
this._sortMode = this._loadSortMode();
|
||||
this._previousFocus = document.activeElement;
|
||||
this._previousFocus?.blur?.();
|
||||
|
||||
@@ -90,11 +107,19 @@ const PathPicker = {
|
||||
</div>
|
||||
<div class="path-picker-nav">
|
||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<div class="path-picker-current" title="Current folder"></div>
|
||||
<form class="path-picker-jump" title="Current folder — edit and press Enter to jump">
|
||||
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
|
||||
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
|
||||
</form>
|
||||
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
|
||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<div class="path-picker-toolbar">
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<label class="path-picker-sort-label">Sort
|
||||
<select class="path-picker-sort" aria-label="Sort entries"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="path-picker-list" role="listbox"></div>
|
||||
<div class="path-picker-selection">
|
||||
<span class="path-picker-selection-label">Selected</span>
|
||||
@@ -114,12 +139,27 @@ const PathPicker = {
|
||||
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
|
||||
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
|
||||
const current = overlay.querySelector('.path-picker-current').textContent;
|
||||
if (current) this.select(current);
|
||||
if (this._currentPath) this.select(this._currentPath);
|
||||
});
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath));
|
||||
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
|
||||
this._syncHiddenButton();
|
||||
// Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go,
|
||||
// never on each keystroke: a half-typed path is a 404 the server has to
|
||||
// answer for nothing, and jumping mid-edit would yank the field around.
|
||||
overlay.querySelector('.path-picker-jump').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
this.jumpTo(overlay.querySelector('.path-picker-current').value);
|
||||
});
|
||||
const sortSelect = overlay.querySelector('.path-picker-sort');
|
||||
for (const mode of PATH_PICKER_SORT_MODES) {
|
||||
const option = document.createElement('option');
|
||||
option.value = mode.value;
|
||||
option.textContent = mode.label;
|
||||
sortSelect.appendChild(option);
|
||||
}
|
||||
sortSelect.value = this._sortMode;
|
||||
sortSelect.addEventListener('change', (event) => this.setSortMode(event.target.value));
|
||||
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||
if (parent) this.load(parent);
|
||||
@@ -169,15 +209,97 @@ const PathPicker = {
|
||||
// OFF inside a hidden folder makes the current path unbrowsable again; the
|
||||
// server answers 403 and load()'s catch falls back to the default root,
|
||||
// which is the only place left to stand.
|
||||
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
|
||||
this.load(this._currentPath || '');
|
||||
},
|
||||
|
||||
async load(path) {
|
||||
_loadSortMode() {
|
||||
try {
|
||||
const stored = localStorage.getItem(PATH_PICKER_SORT_KEY);
|
||||
return PATH_PICKER_SORT_MODES.some((mode) => mode.value === stored) ? stored : PATH_PICKER_DEFAULT_SORT;
|
||||
} catch {
|
||||
return PATH_PICKER_DEFAULT_SORT;
|
||||
}
|
||||
},
|
||||
|
||||
setSortMode(mode) {
|
||||
if (!PATH_PICKER_SORT_MODES.some((candidate) => candidate.value === mode)) return;
|
||||
this._sortMode = mode;
|
||||
try {
|
||||
localStorage.setItem(PATH_PICKER_SORT_KEY, mode);
|
||||
} catch {}
|
||||
const select = this.overlay?.querySelector('.path-picker-sort');
|
||||
if (select && select.value !== mode) select.value = mode;
|
||||
// Re-order what is already on screen; no round trip, no lost selection.
|
||||
if (this.overlay) this.renderEntries();
|
||||
},
|
||||
|
||||
/**
|
||||
* Order entries for display. Folders always come first, whatever the mode:
|
||||
* a date sort is for finding the file you just made, and the folders are the
|
||||
* way past it, not the thing being looked for. An entry without an mtime (an
|
||||
* older server, the in-container source) sorts after every dated one and then
|
||||
* by name, so a listing never degrades into an unstable order.
|
||||
*
|
||||
* ⚠️ This re-orders the listing the SERVER returned, and the server cuts at
|
||||
* FILESYSTEM_PICKER_ENTRY_LIMIT (500) after sorting by name. So in a folder past
|
||||
* that limit, "Newest first" is the newest of the first 500 BY NAME, not the newest
|
||||
* in the folder, which is the one case this sort exists for. The status line says
|
||||
* "(first 500 by name)" rather than "(first 500)" so the cut is legible; ordering
|
||||
* before the cut would have to happen server-side, and would cost a stat on every
|
||||
* entry in the directory rather than on the 500 that are returned.
|
||||
*/
|
||||
_sortEntries(entries) {
|
||||
const [key, direction] = this._sortMode.split('-');
|
||||
const sign = direction === 'desc' ? -1 : 1;
|
||||
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||
return entries.slice().sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
if (key === 'mtime') {
|
||||
const aTime = typeof a.mtimeMs === 'number' ? a.mtimeMs : null;
|
||||
const bTime = typeof b.mtimeMs === 'number' ? b.mtimeMs : null;
|
||||
if (aTime !== null && bTime !== null && aTime !== bTime) return sign * (aTime - bTime);
|
||||
if (aTime === null && bTime !== null) return 1;
|
||||
if (aTime !== null && bTime === null) return -1;
|
||||
return byName(a, b);
|
||||
}
|
||||
return sign * byName(a, b);
|
||||
});
|
||||
},
|
||||
|
||||
/** Compact modified-time label: time of day today, month-day this year, else the date. */
|
||||
_formatModified(mtimeMs) {
|
||||
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return '';
|
||||
const date = new Date(mtimeMs);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
if (date.toDateString() === now.toDateString()) return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
if (date.getFullYear() === now.getFullYear()) return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Go to a path the user typed. A file path lands in its folder with the
|
||||
* file selected, so pasting a full path from a log or a message is one Enter
|
||||
* away from Select. A path that does not resolve lands in its parent folder
|
||||
* when that exists (the closest place to stand) and otherwise keeps the
|
||||
* current listing, and says so either way — unlike a stale initialPath, a
|
||||
* typo is not a reason to throw the user back to the root.
|
||||
*/
|
||||
jumpTo(rawPath) {
|
||||
const path = String(rawPath || '').trim();
|
||||
if (!path) return;
|
||||
this.load(path, { typed: true });
|
||||
},
|
||||
|
||||
async load(path, options = {}) {
|
||||
if (!this.overlay || !this._options) return;
|
||||
const loadSequence = ++this._loadSequence;
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
list.replaceChildren();
|
||||
const typed = !!options.typed;
|
||||
if (!typed) list.replaceChildren();
|
||||
status.classList.remove('error');
|
||||
status.textContent = 'Loading...';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
@@ -194,13 +316,40 @@ const PathPicker = {
|
||||
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
this.render(result.data);
|
||||
if (options.selectIfListed) {
|
||||
// Landed in the typed path's folder: select the entry if it is there
|
||||
// (a file path), otherwise say what the server said about the full
|
||||
// path — the listing is still the closest place to stand.
|
||||
if (this._entries.some((entry) => entry.path === options.selectIfListed)) {
|
||||
this.select(options.selectIfListed);
|
||||
} else {
|
||||
status.textContent = options.failMessage || 'Path not found';
|
||||
status.classList.add('error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
const message = error.message || 'Failed to browse this folder';
|
||||
if (typed) {
|
||||
// The browse endpoint answers a FILE path with "not found" (it resolves
|
||||
// folders only), so one retry lands in the parent folder and selects
|
||||
// the entry from the listing. Only one level: a typo two segments up
|
||||
// is an error, not a reason to climb to the root.
|
||||
const slash = path.lastIndexOf('/');
|
||||
if (slash > 0 && !options.selectIfListed) {
|
||||
this.load(path.slice(0, slash), { typed: true, selectIfListed: path, failMessage: message });
|
||||
return;
|
||||
}
|
||||
this.renderEntries();
|
||||
status.textContent = options.failMessage || message;
|
||||
status.classList.add('error');
|
||||
return;
|
||||
}
|
||||
if (path) {
|
||||
this.load('');
|
||||
return;
|
||||
}
|
||||
status.textContent = error.message || 'Failed to browse this folder';
|
||||
status.textContent = message;
|
||||
status.classList.add('error');
|
||||
}
|
||||
},
|
||||
@@ -216,19 +365,29 @@ const PathPicker = {
|
||||
rootSelect.appendChild(option);
|
||||
}
|
||||
|
||||
this.overlay.querySelector('.path-picker-current').textContent = data.path;
|
||||
this._currentPath = data.path;
|
||||
this.overlay.querySelector('.path-picker-current').value = data.path;
|
||||
const up = this.overlay.querySelector('.path-picker-up');
|
||||
up.dataset.parent = data.parent || '';
|
||||
up.disabled = !data.parent;
|
||||
this._entries = Array.isArray(data.entries) ? data.entries : [];
|
||||
this._truncated = !!data.truncated;
|
||||
this.renderEntries();
|
||||
},
|
||||
|
||||
/** (Re)build the list from the last listing in the current sort order. */
|
||||
renderEntries() {
|
||||
if (!this.overlay) return;
|
||||
const entries = this._sortEntries(this._entries);
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
status.classList.remove('error');
|
||||
status.textContent = data.entries.length === 0
|
||||
status.textContent = entries.length === 0
|
||||
? 'This folder is empty'
|
||||
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
|
||||
: `${entries.length} item${entries.length === 1 ? '' : 's'}${this._truncated ? ' (first 500 by name)' : ''}`;
|
||||
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
list.replaceChildren();
|
||||
for (const entry of data.entries) {
|
||||
for (const entry of entries) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'path-picker-item';
|
||||
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
|
||||
@@ -248,6 +407,14 @@ const PathPicker = {
|
||||
name.className = 'path-picker-item-name';
|
||||
name.textContent = entry.name;
|
||||
open.append(icon, name);
|
||||
const modified = this._formatModified(entry.mtimeMs);
|
||||
if (modified) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'path-picker-item-meta';
|
||||
meta.textContent = modified;
|
||||
meta.title = new Date(entry.mtimeMs).toLocaleString();
|
||||
open.appendChild(meta);
|
||||
}
|
||||
if (entry.symlink) {
|
||||
const link = document.createElement('span');
|
||||
link.className = 'path-picker-item-link';
|
||||
@@ -417,6 +584,8 @@ const PathPicker = {
|
||||
this._previousFocus = null;
|
||||
this._options = null;
|
||||
this._selectedPath = '';
|
||||
this._currentPath = '';
|
||||
this._entries = [];
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2314,6 +2314,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): a teammate/subagent pane is always
|
||||
// running an agent CLI (Task-tool dispatched, never a plain shell), so
|
||||
// unlike the main terminal's mode-gated trap this one is unconditional.
|
||||
// Mirrors the main terminal's guard in terminal-ui.js's
|
||||
// attachCustomKeyEventHandler — case-insensitive so Caps Lock (which
|
||||
// flips ev.key to 'Z' without setting shiftKey) can't slip a suspend past it.
|
||||
terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey
|
||||
) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Wait for terminal renderer to fully initialize before any writes.
|
||||
// xterm.js needs a few frames after open() before write() is safe.
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -2050,6 +2050,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
|
||||
// as "previously off" — used below to detect a real OFF→ON flip.
|
||||
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
||||
// Plan-usage chip: the exporter it depends on is removed from live workspaces
|
||||
// ONLY on the save that turns the chip off (see statusLineTelemetryAction).
|
||||
const _prevPlanUsageChip = this.planUsageChipEnabled(_prev);
|
||||
const settings = {
|
||||
displayName: window.CodemanI18n?.normalizeDisplayName(
|
||||
document.getElementById('appSettingsDisplayName').value
|
||||
@@ -2280,9 +2283,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
|
||||
// also absent from SettingsUpdateSchema, which is .strict() — sending it
|
||||
// would 400 the whole settings PUT.
|
||||
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
|
||||
// ENABLE only, so a device with the chip OFF never strips the exporter that
|
||||
// another device's chip depends on — see system-routes settings handler).
|
||||
// Telemetry COLLECTION is requested out-of-band via the statusLineTelemetry
|
||||
// action field: `true` on every save while the chip is on, `false` only on the
|
||||
// save that turned it off here, nothing otherwise (statusLineTelemetryAction),
|
||||
// so a device whose chip was never on cannot strip the exporter another
|
||||
// device's chip depends on. See the system-routes settings handler.
|
||||
const {
|
||||
localEchoEnabled: _leo,
|
||||
cjkInputEnabled: _cjk,
|
||||
@@ -2316,10 +2321,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
sessionLineageLines: _sll,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
const statusLineTelemetry = this.statusLineTelemetryAction(_prevPlanUsageChip, settings.showPlanUsageLimits);
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
|
||||
...(statusLineTelemetry === undefined ? {} : { statusLineTelemetry }),
|
||||
notificationPreferences: notifPrefsToSave,
|
||||
voiceSettings,
|
||||
});
|
||||
@@ -2592,6 +2598,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
|
||||
},
|
||||
|
||||
// What a settings save tells the server about the plan-usage exporter, given
|
||||
// the chip's state before and after the save. `true` re-injects the exporter
|
||||
// into every live Claude workspace and may ride every save while the chip is
|
||||
// on. `false` REMOVES it from those workspaces, and the chip is per-device
|
||||
// while the exporter lives in each repo's shared settings.local.json, so it
|
||||
// may ride only the save that turned the chip off on this device: a phone
|
||||
// whose chip was never on must never strip what a desktop's chip depends on.
|
||||
// Pure, so test/plan-usage-telemetry-action.test.ts can pin all three cases.
|
||||
statusLineTelemetryAction(prevEnabled, nowEnabled) {
|
||||
if (nowEnabled) return true;
|
||||
if (prevEnabled) return false;
|
||||
return undefined;
|
||||
},
|
||||
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
|
||||
+91
-17
@@ -545,18 +545,21 @@ body {
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.session-tabs.tabs-two-rows {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
/* A wrapped strip GROWS the header instead of scrolling inside it. The old
|
||||
fixed caps (120px tall-tabs / 96px auto-wrap) were row counts in disguise:
|
||||
a third row of tabs was clipped into a 4px scroller, so the tabs you were
|
||||
looking for were off-screen in a container nothing tells you to scroll —
|
||||
while the header had the whole page below it to grow into (it is
|
||||
`min-height` + `flex-shrink: 0`, and terminal-ui's ResizeObserver refits
|
||||
the terminal on its own). `--tab-strip-max-height` is a safety net for an
|
||||
absurd session count, not a row limit: past it the scroller comes back,
|
||||
which beats a header that swallows the terminal. */
|
||||
.session-tabs.tabs-two-rows,
|
||||
.session-tabs.tabs-auto-wrap {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 96px;
|
||||
max-height: var(--tab-strip-max-height, 40vh);
|
||||
}
|
||||
|
||||
.session-tabs::-webkit-scrollbar {
|
||||
@@ -13617,24 +13620,95 @@ body.touch-device.cjk-input-visible .main {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.path-picker-current {
|
||||
/* The current path is an editable field: type or paste a path and press Enter
|
||||
* (or Go) to jump there. The Go button is part of the same rounded control so
|
||||
* the field keeps the width the read-only breadcrumb had. */
|
||||
.path-picker-jump {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 11px;
|
||||
overflow-x: auto;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.path-picker-status {
|
||||
padding: 0 14px 8px;
|
||||
.path-picker-jump:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.path-picker-current {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.path-picker-go {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 10px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-picker-go:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.path-picker-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px 8px;
|
||||
}
|
||||
|
||||
.path-picker-status {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-picker-sort-label {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.path-picker-sort {
|
||||
padding: 3px 6px;
|
||||
color: var(--text);
|
||||
font-size: 0.7rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.path-picker-item-meta {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 8px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.65rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-picker-status.error {
|
||||
|
||||
@@ -383,6 +383,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): in a plain shell session this is the
|
||||
// user's own job-control tool (suspend a foreground command, `fg` it back) —
|
||||
// leave it alone. In every other mode (claude/omp/pi/codex/... — Ink/TUI apps
|
||||
// that normally run in raw mode with ISIG off, so ^Z is usually inert there
|
||||
// already) suspending the CLI stops an unattended agent loop dead with no
|
||||
// visible output — the same failure shape as an XOFF freeze. Swallow it
|
||||
// before xterm can send \x1a into the PTY rather than relying on every CLI's
|
||||
// raw-mode state holding at every instant (startup, raw-mode toggles, etc).
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey
|
||||
) {
|
||||
const activeCtrlZSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
if (activeCtrlZSession && activeCtrlZSession.mode !== 'shell') {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
|
||||
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
|
||||
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
|
||||
|
||||
@@ -876,6 +876,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
|
||||
let type: FilesystemBrowseEntry['type'];
|
||||
let size: number | undefined;
|
||||
let mtimeMs: number | undefined;
|
||||
const symlink = entry.isSymbolicLink();
|
||||
if (entry.isDirectory()) {
|
||||
type = 'directory';
|
||||
@@ -886,6 +887,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const targetStat = await fs.stat(targetPath);
|
||||
type = targetStat.isDirectory() ? 'directory' : 'file';
|
||||
if (type === 'file') size = targetStat.size;
|
||||
mtimeMs = targetStat.mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -894,11 +896,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
||||
if (type === 'file' && size === undefined) {
|
||||
if (mtimeMs === undefined) {
|
||||
// One stat per entry: the modified time lets the picker sort by date, and
|
||||
// a file's size rides along on the same call.
|
||||
try {
|
||||
size = (await fs.stat(targetPath)).size;
|
||||
const targetStat = await fs.stat(targetPath);
|
||||
mtimeMs = targetStat.mtimeMs;
|
||||
if (type === 'file') size = targetStat.size;
|
||||
} catch {
|
||||
// The path is still selectable even when a size lookup races a change.
|
||||
// The path is still selectable even when a stat races a change.
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
@@ -906,6 +912,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
path: visiblePath,
|
||||
type,
|
||||
size,
|
||||
mtimeMs,
|
||||
symlink: symlink || undefined,
|
||||
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
||||
});
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
||||
*
|
||||
* Returns a compact plain-text status string for the exporter to print as the
|
||||
* in-terminal footer (print-through), so injecting our statusLine doesn't leave
|
||||
* the terminal footer blank.
|
||||
* in-terminal footer when it has no statusline of the user's own to delegate to
|
||||
* (see `statusline-shim.ts`). An unknown session gets an EMPTY body: the old
|
||||
* brand-word answer rendered as the statusline of every hand-run `claude` in a
|
||||
* managed repo, and cost discussion #405 seven repositories of debugging.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
@@ -36,10 +38,12 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
||||
|
||||
reply.type('text/plain; charset=utf-8');
|
||||
|
||||
// Unknown session — minimal footer, no broadcast.
|
||||
// Unknown session: nothing to broadcast and nothing to print. Never a brand
|
||||
// word here, it would render as the statusline (the shim treats an empty
|
||||
// answer as "no telemetry" and falls through to the delegate or to blank).
|
||||
if (!ctx.sessions.has(sessionId)) {
|
||||
lastSig.delete(sessionId);
|
||||
return 'codeman';
|
||||
return '';
|
||||
}
|
||||
|
||||
const payload = data as RawStatuslinePayload | undefined;
|
||||
|
||||
@@ -1034,20 +1034,34 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
|
||||
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
|
||||
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
|
||||
// into every ACTIVE Claude session's working dir so the live % starts flowing
|
||||
// immediately (no new session needed). We deliberately never auto-REMOVE here:
|
||||
// the exporter is benign/print-through and a per-repo settings.local.json is
|
||||
// shared by sibling sessions, so one device's "off" must not yank the exporter
|
||||
// another device's chip depends on. Each dir handled once.
|
||||
if (statusLineTelemetry === true) {
|
||||
// Telemetry COLLECTION is a per-save ACTION field in both directions. `true`
|
||||
// rides every save while the chip is on: we (re)inject our exporter into every
|
||||
// ACTIVE Claude session's working dir so the live % starts flowing immediately
|
||||
// (no new session needed), and that is also how a second device catches up.
|
||||
// `false` rides ONLY the save that turned the chip OFF on that device
|
||||
// (statusLineTelemetryAction in settings-ui.js) and takes our exporter back
|
||||
// out of those same dirs. Nothing called the disable path before, so turning
|
||||
// the chip off left the line in every repo it had ever reached (#405). A
|
||||
// per-repo settings.local.json is shared by sibling sessions and by every
|
||||
// device, so the flip-only rule is what keeps a phone whose chip was never on
|
||||
// from stripping the exporter a desktop's chip depends on; a device with the
|
||||
// chip still on re-injects on its next save or session create and shows the
|
||||
// last snapshot meanwhile. Both paths are isOurs-guarded (a hand-authored
|
||||
// statusLine is never touched), remote attaches are skipped (their workingDir
|
||||
// is a user@host:session pseudo-path the enable path would mkdir as a junk
|
||||
// local dir), and each dir is handled once.
|
||||
if (statusLineTelemetry === true || statusLineTelemetry === false) {
|
||||
const user = getAuthUser(req);
|
||||
const dirs = new Set<string>();
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir)
|
||||
dirs.add(session.workingDir);
|
||||
if (!getCli(session.mode)?.capabilities.statusLineTelemetry || !session.workingDir) continue;
|
||||
if (session.remote) continue;
|
||||
// Removal is the destructive direction: only the caller's own workspaces
|
||||
// (canAccessOwned is allow-all for admins and in single-user mode).
|
||||
if (!statusLineTelemetry && !canAccessOwned(user, session.owner)) continue;
|
||||
dirs.add(session.workingDir);
|
||||
}
|
||||
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
|
||||
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, statusLineTelemetry).catch(() => {})));
|
||||
}
|
||||
|
||||
// Handle tunnel toggle dynamically
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
updateCaseModel,
|
||||
writeHooksConfig,
|
||||
} from '../src/hooks-config.js';
|
||||
import { LEGACY_STATUSLINE_MARKER, STATUSLINE_SHIM_TOKEN } from '../src/statusline-shim.js';
|
||||
|
||||
describe('generateHooksConfig', () => {
|
||||
it('should return an object with hooks key', () => {
|
||||
@@ -1305,3 +1306,85 @@ describe('Hook Config Generation - Extended', () => {
|
||||
expect(stopHooks[0].hooks[0].command).toContain('stop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyStatusLineConfig', () => {
|
||||
const testDir = join(tmpdir(), 'codeman-statusline-config-' + Date.now());
|
||||
const settingsFile = join(testDir, '.claude', 'settings.local.json');
|
||||
|
||||
const read = () => JSON.parse(readFileSync(settingsFile, 'utf-8'));
|
||||
const write = (value: object) => {
|
||||
mkdirSync(join(testDir, '.claude'), { recursive: true });
|
||||
writeFileSync(settingsFile, JSON.stringify(value, null, 2));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('injects the guarded shim command with the inline exporter as its fallback', async () => {
|
||||
await applyStatusLineConfig(testDir, true);
|
||||
const { statusLine } = read();
|
||||
expect(statusLine.type).toBe('command');
|
||||
// The shim runs first wherever it exists: it is what gives the user their
|
||||
// own statusline back. The inline half after it is what renders where the
|
||||
// shim cannot (inside a Docker case's container), and it must not carry the
|
||||
// brand-word fallback the old exporter printed.
|
||||
expect(statusLine.command.startsWith('if [ -x ')).toBe(true);
|
||||
expect(statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||
expect(statusLine.command).toContain(LEGACY_STATUSLINE_MARKER);
|
||||
expect(statusLine.command).not.toContain('echo codeman');
|
||||
});
|
||||
|
||||
it('upgrades a pre-shim inline exporter in place', async () => {
|
||||
// Every repo a previous Codeman managed still holds this command. If the
|
||||
// ownership check missed it, the upgrade would read it as hand-authored,
|
||||
// refuse to touch it, and leave the user shadowed forever.
|
||||
write({
|
||||
statusLine: { type: 'command', command: `curl -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` },
|
||||
permissions: { allow: ['Read'] },
|
||||
});
|
||||
|
||||
await applyStatusLineConfig(testDir, true);
|
||||
|
||||
const settings = read();
|
||||
expect(settings.statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||
expect(settings.permissions).toEqual({ allow: ['Read'] });
|
||||
});
|
||||
|
||||
it('removes a pre-shim inline exporter on the disable path', async () => {
|
||||
write({ statusLine: { type: 'command', command: `curl "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` } });
|
||||
await applyStatusLineConfig(testDir, false);
|
||||
expect(read().statusLine).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes its own shim entry on the disable path', async () => {
|
||||
await applyStatusLineConfig(testDir, true);
|
||||
await applyStatusLineConfig(testDir, false);
|
||||
expect(read().statusLine).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never touches a statusLine the user wrote themselves', async () => {
|
||||
// Unchanged contract: a hand-authored entry in the repo's own file stops
|
||||
// Codeman cold, so it never owns an entry it would have to restore later.
|
||||
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
|
||||
write({ statusLine: mine });
|
||||
|
||||
await applyStatusLineConfig(testDir, true);
|
||||
expect(read().statusLine).toEqual(mine);
|
||||
|
||||
await applyStatusLineConfig(testDir, false);
|
||||
expect(read().statusLine).toEqual(mine);
|
||||
});
|
||||
|
||||
it('rewrites nothing when the shim command is already current', async () => {
|
||||
await applyStatusLineConfig(testDir, true);
|
||||
const before = readFileSync(settingsFile, 'utf-8');
|
||||
await applyStatusLineConfig(testDir, true);
|
||||
expect(readFileSync(settingsFile, 'utf-8')).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @fileoverview Path picker: sort order and the editable path field.
|
||||
*
|
||||
* Same jsdom harness as path-picker-hidden.test.ts: keyboard-accessory.js is
|
||||
* evaluated against a jsdom window with a scripted fetch, so the assertions
|
||||
* run against the real DOM the picker builds rather than string matches.
|
||||
* Port: N/A
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const SORT_KEY = 'codeman:pathPickerSort';
|
||||
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
|
||||
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
const jsdomDocument = jsdomWindow.document;
|
||||
|
||||
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): any {
|
||||
const MobileDetection = { isTouchDevice: () => false };
|
||||
const factory = new Function(
|
||||
'window',
|
||||
'document',
|
||||
'localStorage',
|
||||
'fetch',
|
||||
'MobileDetection',
|
||||
`${accessoryJs}\nreturn PathPicker;`
|
||||
);
|
||||
return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection);
|
||||
}
|
||||
|
||||
type Entry = { name: string; type: 'file' | 'directory'; mtimeMs?: number };
|
||||
|
||||
function browseResponse(entries: Entry[], path = '/home/dev/project') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
path,
|
||||
parent: path === '/home/dev' ? null : '/home/dev',
|
||||
root: '/home/dev',
|
||||
roots: [{ label: 'Home', path: '/home/dev' }],
|
||||
entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })),
|
||||
truncated: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function errorResponse(error: string) {
|
||||
return { ok: false, json: async () => ({ success: false, error }) };
|
||||
}
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const NOW = Date.now();
|
||||
const LISTING: Entry[] = [
|
||||
{ name: 'zeta.txt', type: 'file', mtimeMs: NOW - 3 * DAY },
|
||||
{ name: 'alpha.txt', type: 'file', mtimeMs: NOW - 1 * DAY },
|
||||
{ name: 'mid.txt', type: 'file', mtimeMs: NOW - 2 * DAY },
|
||||
{ name: 'old-dir', type: 'directory', mtimeMs: NOW - 30 * DAY },
|
||||
{ name: 'new-dir', type: 'directory', mtimeMs: NOW - 1000 },
|
||||
];
|
||||
|
||||
describe('PathPicker sort order', () => {
|
||||
let PathPicker: any;
|
||||
let urls: string[];
|
||||
let respond: (url: string) => unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
jsdomWindow.localStorage.clear();
|
||||
jsdomDocument.body.replaceChildren();
|
||||
urls = [];
|
||||
respond = () => browseResponse(LISTING);
|
||||
PathPicker = loadPathPicker(async (url: string) => {
|
||||
urls.push(url);
|
||||
return respond(url);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
PathPicker?.close?.(false);
|
||||
jsdomDocument.body.replaceChildren();
|
||||
});
|
||||
|
||||
const open = async (options: Record<string, unknown> = {}) => {
|
||||
PathPicker.open({ onSelect: () => {}, ...options });
|
||||
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
|
||||
};
|
||||
const names = () => Array.from(jsdomDocument.querySelectorAll('.path-picker-item-name')).map((el) => el.textContent);
|
||||
const sortSelect = () => jsdomDocument.querySelector('.path-picker-sort') as HTMLSelectElement;
|
||||
const setSort = (value: string) => {
|
||||
sortSelect().value = value;
|
||||
sortSelect().dispatchEvent(new jsdomWindow.Event('change', { bubbles: true }));
|
||||
};
|
||||
|
||||
it('sorts by name with folders first by default', async () => {
|
||||
await open();
|
||||
expect(sortSelect().value).toBe('name-asc');
|
||||
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
|
||||
});
|
||||
|
||||
it('re-orders the listing without another request, keeping folders first', async () => {
|
||||
await open();
|
||||
const requests = urls.length;
|
||||
|
||||
setSort('mtime-desc');
|
||||
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
|
||||
|
||||
setSort('mtime-asc');
|
||||
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
|
||||
|
||||
setSort('name-desc');
|
||||
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
|
||||
|
||||
expect(urls.length).toBe(requests);
|
||||
});
|
||||
|
||||
it('remembers the sort mode across reopenings', async () => {
|
||||
await open();
|
||||
setSort('mtime-desc');
|
||||
expect(jsdomWindow.localStorage.getItem(SORT_KEY)).toBe('mtime-desc');
|
||||
PathPicker.close(false);
|
||||
|
||||
await open();
|
||||
expect(sortSelect().value).toBe('mtime-desc');
|
||||
});
|
||||
|
||||
it('ignores a corrupt stored mode and a localStorage that throws', async () => {
|
||||
jsdomWindow.localStorage.setItem(SORT_KEY, 'bogus');
|
||||
await open();
|
||||
expect(sortSelect().value).toBe('name-asc');
|
||||
PathPicker.close(false);
|
||||
|
||||
const getItem = vi.spyOn(jsdomWindow.localStorage.__proto__, 'getItem').mockImplementation(() => {
|
||||
throw new Error('private mode');
|
||||
});
|
||||
try {
|
||||
await open();
|
||||
expect(sortSelect().value).toBe('name-asc');
|
||||
setSort('mtime-asc');
|
||||
expect(names()[0]).toBe('old-dir');
|
||||
} finally {
|
||||
getItem.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('places entries without a modified time after dated ones on a date sort', async () => {
|
||||
respond = () =>
|
||||
browseResponse([
|
||||
{ name: 'undated.txt', type: 'file' },
|
||||
{ name: 'dated.txt', type: 'file', mtimeMs: NOW - DAY },
|
||||
]);
|
||||
await open();
|
||||
setSort('mtime-desc');
|
||||
expect(names()).toEqual(['dated.txt', 'undated.txt']);
|
||||
setSort('mtime-asc');
|
||||
expect(names()).toEqual(['dated.txt', 'undated.txt']);
|
||||
});
|
||||
|
||||
it('shows a compact modified time only when the server supplied one', async () => {
|
||||
respond = () =>
|
||||
browseResponse([
|
||||
{ name: 'undated.txt', type: 'file' },
|
||||
{ name: 'today.txt', type: 'file', mtimeMs: NOW },
|
||||
]);
|
||||
await open();
|
||||
const rows = Array.from(jsdomDocument.querySelectorAll('.path-picker-item'));
|
||||
const meta = (row: Element) => row.querySelector('.path-picker-item-meta')?.textContent ?? null;
|
||||
expect(meta(rows[0])).toMatch(/^\d{2}:\d{2}$/);
|
||||
expect(meta(rows[1])).toBeNull();
|
||||
});
|
||||
|
||||
it('styles the sort control and the modified column', () => {
|
||||
expect(stylesCss).toContain('.path-picker-sort {');
|
||||
expect(stylesCss).toContain('.path-picker-item-meta {');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PathPicker editable path', () => {
|
||||
let PathPicker: any;
|
||||
let urls: string[];
|
||||
let respond: (url: string) => unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
jsdomWindow.localStorage.clear();
|
||||
jsdomDocument.body.replaceChildren();
|
||||
urls = [];
|
||||
respond = () => browseResponse(LISTING);
|
||||
PathPicker = loadPathPicker(async (url: string) => {
|
||||
urls.push(url);
|
||||
return respond(url);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
PathPicker?.close?.(false);
|
||||
jsdomDocument.body.replaceChildren();
|
||||
});
|
||||
|
||||
const open = async (options: Record<string, unknown> = {}) => {
|
||||
PathPicker.open({ onSelect: () => {}, ...options });
|
||||
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
|
||||
};
|
||||
const field = () => jsdomDocument.querySelector('.path-picker-current') as HTMLInputElement;
|
||||
const submit = (value: string) => {
|
||||
field().value = value;
|
||||
(jsdomDocument.querySelector('.path-picker-jump') as HTMLFormElement).dispatchEvent(
|
||||
new jsdomWindow.Event('submit', { bubbles: true, cancelable: true })
|
||||
);
|
||||
};
|
||||
const pathParam = (url: string) => new URL(url, 'https://localhost').searchParams.get('path');
|
||||
const status = () => jsdomDocument.querySelector('.path-picker-status') as HTMLElement;
|
||||
|
||||
it('shows the current folder in an editable field and jumps on Enter', async () => {
|
||||
await open({ initialPath: '/home/dev/project' });
|
||||
expect(field().value).toBe('/home/dev/project');
|
||||
const before = urls.length;
|
||||
|
||||
// Typing alone never fetches.
|
||||
field().value = '/home/dev/oth';
|
||||
field().dispatchEvent(new jsdomWindow.Event('input', { bubbles: true }));
|
||||
expect(urls.length).toBe(before);
|
||||
|
||||
respond = () => browseResponse([{ name: 'readme.md', type: 'file' }], '/home/dev/other');
|
||||
submit(' /home/dev/other ');
|
||||
await vi.waitFor(() => expect(field().value).toBe('/home/dev/other'));
|
||||
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/other');
|
||||
expect(jsdomDocument.querySelector('.path-picker-item-name')?.textContent).toBe('readme.md');
|
||||
});
|
||||
|
||||
it('keeps the current listing and reports the error when neither a typed path nor its parent resolves', async () => {
|
||||
await open({ initialPath: '/home/dev/project' });
|
||||
respond = () => errorResponse('Path not found: /home/dev/nope/deeper');
|
||||
submit('/home/dev/nope/deeper');
|
||||
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
|
||||
expect(status().textContent).toBe('Path not found: /home/dev/nope/deeper');
|
||||
// One retry on the parent, then stop: never a climb to the root.
|
||||
expect(urls.slice(-2).map(pathParam)).toEqual(['/home/dev/nope/deeper', '/home/dev/nope']);
|
||||
expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBe(LISTING.length);
|
||||
// The typed text stays in the field so the typo can be corrected in place.
|
||||
expect(field().value).toBe('/home/dev/nope/deeper');
|
||||
});
|
||||
|
||||
it('lands in the parent folder, unselected, when only the last segment is wrong', async () => {
|
||||
await open({ initialPath: '/home/dev' });
|
||||
respond = (url) =>
|
||||
pathParam(url) === '/home/dev/project/typo.txt'
|
||||
? errorResponse('Path not found: /home/dev/project/typo.txt')
|
||||
: browseResponse(LISTING);
|
||||
submit('/home/dev/project/typo.txt');
|
||||
await vi.waitFor(() => expect(field().value).toBe('/home/dev/project'));
|
||||
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
|
||||
expect(status().textContent).toBe('Path not found: /home/dev/project/typo.txt');
|
||||
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('None');
|
||||
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('lands a typed file path in its folder with the file selected', async () => {
|
||||
await open({ initialPath: '/home/dev/project' });
|
||||
respond = (url) =>
|
||||
pathParam(url) === '/home/dev/project/alpha.txt'
|
||||
? errorResponse('Path not found: /home/dev/project/alpha.txt')
|
||||
: browseResponse(LISTING);
|
||||
submit('/home/dev/project/alpha.txt');
|
||||
await vi.waitFor(() =>
|
||||
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe(
|
||||
'/home/dev/project/alpha.txt'
|
||||
)
|
||||
);
|
||||
expect(field().value).toBe('/home/dev/project');
|
||||
expect(jsdomDocument.querySelector('.path-picker-item.selected .path-picker-item-name')?.textContent).toBe(
|
||||
'alpha.txt'
|
||||
);
|
||||
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('selects the current folder from the field value and refreshes in place', async () => {
|
||||
await open({ initialPath: '/home/dev/project' });
|
||||
(jsdomDocument.querySelector('.path-picker-current-select') as HTMLButtonElement).click();
|
||||
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project');
|
||||
|
||||
const before = urls.length;
|
||||
(jsdomDocument.querySelector('.path-picker-refresh') as HTMLButtonElement).click();
|
||||
await vi.waitFor(() => expect(urls.length).toBe(before + 1));
|
||||
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/project');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,50 @@ describe('file-routes', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('stamps every entry with its modified time so the picker can sort by date', async () => {
|
||||
mockedReaddir.mockResolvedValueOnce([
|
||||
{ name: 'notes.txt', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
||||
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
||||
{ name: 'link', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
|
||||
] as never);
|
||||
mockedStat.mockImplementation(async (candidate) => {
|
||||
const target = String(candidate);
|
||||
if (target.endsWith('/notes.txt')) {
|
||||
return { size: 42, mtimeMs: 1_700_000_000_000, isFile: () => true, isDirectory: () => false } as never;
|
||||
}
|
||||
if (target.endsWith('/src')) {
|
||||
return { size: 4096, mtimeMs: 1_700_000_001_000, isFile: () => false, isDirectory: () => true } as never;
|
||||
}
|
||||
if (target.endsWith('/link')) {
|
||||
return { size: 7, mtimeMs: 1_700_000_002_000, isFile: () => true, isDirectory: () => false } as never;
|
||||
}
|
||||
return { size: 0, mtimeMs: 0, isFile: () => false, isDirectory: () => true } as never;
|
||||
});
|
||||
|
||||
const path = harness.ctx._session.workingDir;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const entries = JSON.parse(res.body).data.entries as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size?: number;
|
||||
mtimeMs?: number;
|
||||
}>;
|
||||
expect(entries.map((entry) => [entry.name, entry.type, entry.size, entry.mtimeMs])).toEqual([
|
||||
['src', 'directory', undefined, 1_700_000_001_000],
|
||||
['link', 'file', 7, 1_700_000_002_000],
|
||||
['notes.txt', 'file', 42, 1_700_000_000_000],
|
||||
]);
|
||||
// One stat per entry: the date and the size ride on the same call.
|
||||
const statsFor = (name: string) =>
|
||||
mockedStat.mock.calls.filter(([candidate]) => String(candidate).endsWith(`/${name}`)).length;
|
||||
expect([statsFor('notes.txt'), statsFor('src'), statsFor('link')]).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it('defaults to the Codeman Cases root, not Home, when linking a case with no path chosen yet', async () => {
|
||||
// The "Link Existing" case picker opens with an empty path and no
|
||||
// sessionId. `Home` and `Codeman Cases` are unrelated bind mounts under
|
||||
|
||||
@@ -49,10 +49,13 @@ describe('POST /api/status-telemetry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not broadcast for an unknown session; returns the brand footer', async () => {
|
||||
it('does not broadcast for an unknown session, and answers an empty body', async () => {
|
||||
// Never the bare brand word: the exporter prints this answer as the
|
||||
// statusline, and `codeman` on the statusline of every hand-run `claude` in
|
||||
// a managed repo is the symptom discussion #405 opened with.
|
||||
const res = await post({ sessionId: 'does-not-exist', data: REAL });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('codeman');
|
||||
expect(res.body).toBe('');
|
||||
expect(h.ctx.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* PUT /api/settings: the `statusLineTelemetry` ACTION field, both directions.
|
||||
*
|
||||
* `true` (sent on every save while the plan-usage chip is on) injects Codeman's
|
||||
* statusLine exporter into every live Claude session's workspace so the chip's
|
||||
* data starts flowing without a new session. `false` (sent only when the chip
|
||||
* was just turned OFF on a device) takes the exporter back out of those same
|
||||
* workspaces. Before this, nothing in `src/` ever called the disable path, so
|
||||
* turning the chip off left the line in every repo it had ever reached
|
||||
* (discussion #405).
|
||||
*
|
||||
* Both directions are `isOurs`-guarded in `applyStatusLineConfig`, so a
|
||||
* statusLine the user wrote themselves is never added to, replaced, or removed.
|
||||
* Remote-attach sessions are skipped in both: their `workingDir` is a
|
||||
* `user@host:session` pseudo-path that the enable path would otherwise create
|
||||
* as a junk local directory.
|
||||
*
|
||||
* Uses app.inject(), real temp workspaces under the test HOME. Port: N/A.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { createMockSession } from '../mocks/mock-session.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
import { STATUSLINE_SHIM_TOKEN } from '../../src/statusline-shim.js';
|
||||
|
||||
// The three service toggles start/stop real watchers from this handler; stub
|
||||
// them so a settings PUT in a test never starts a filesystem watcher.
|
||||
const { subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
|
||||
const makeWatcher = () => ({
|
||||
isRunning: vi.fn(() => false),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
getStats: vi.fn(() => ({})),
|
||||
watchSession: vi.fn(),
|
||||
getRecentRunSummaries: vi.fn(() => []),
|
||||
});
|
||||
return { subagentWatcher: makeWatcher(), imageWatcher: makeWatcher(), workflowRunWatcher: makeWatcher() };
|
||||
});
|
||||
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
|
||||
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
|
||||
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
|
||||
|
||||
const settingsFile = (dir: string) => join(dir, '.claude', 'settings.local.json');
|
||||
const readSettings = (dir: string) => JSON.parse(readFileSync(settingsFile(dir), 'utf-8'));
|
||||
const writeSettings = (dir: string, value: object) => {
|
||||
mkdirSync(join(dir, '.claude'), { recursive: true });
|
||||
writeFileSync(settingsFile(dir), JSON.stringify(value, null, 2));
|
||||
};
|
||||
|
||||
describe('PUT /api/settings statusLineTelemetry', () => {
|
||||
let h: RouteTestHarness;
|
||||
let root: string;
|
||||
let claudeDir: string;
|
||||
let shellDir: string;
|
||||
let remoteDir: string;
|
||||
|
||||
const put = (body: unknown) => h.app.inject({ method: 'PUT', url: '/api/settings', payload: body });
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await createRouteTestHarness(registerSystemRoutes);
|
||||
root = mkdtempSync(join(tmpdir(), 'codeman-statusline-toggle-'));
|
||||
claudeDir = join(root, 'claude-repo');
|
||||
shellDir = join(root, 'shell-repo');
|
||||
remoteDir = join(root, 'remote-attach');
|
||||
for (const dir of [claudeDir, shellDir]) mkdirSync(dir, { recursive: true });
|
||||
|
||||
const claude = createMockSession('claude-1');
|
||||
claude.workingDir = claudeDir;
|
||||
const shell = createMockSession('shell-1');
|
||||
shell.mode = 'shell';
|
||||
shell.workingDir = shellDir;
|
||||
const remote = createMockSession('remote-1');
|
||||
remote.workingDir = remoteDir;
|
||||
Object.assign(remote, { remote: { host: 'box', session: 'codeman-ssh-remote-1' } });
|
||||
h.ctx.sessions.clear();
|
||||
for (const s of [claude, shell, remote]) h.ctx.sessions.set(s.id, s);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await h.app.close();
|
||||
});
|
||||
|
||||
it('true injects the exporter into live Claude workspaces only', async () => {
|
||||
const res = await put({ statusLineTelemetry: true });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readSettings(claudeDir).statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||
// A shell session has no statusline to export from.
|
||||
expect(existsSync(settingsFile(shellDir))).toBe(false);
|
||||
// A remote attach's workingDir is a pseudo-path: nothing must be created for it.
|
||||
expect(existsSync(remoteDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('false removes the exporter it injected', async () => {
|
||||
await put({ statusLineTelemetry: true });
|
||||
expect(readSettings(claudeDir).statusLine).toBeDefined();
|
||||
|
||||
const res = await put({ statusLineTelemetry: false });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readSettings(claudeDir).statusLine).toBeUndefined();
|
||||
});
|
||||
|
||||
it('false keeps every other key in the workspace settings file', async () => {
|
||||
writeSettings(claudeDir, { permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
|
||||
await put({ statusLineTelemetry: true });
|
||||
await put({ statusLineTelemetry: false });
|
||||
expect(readSettings(claudeDir)).toEqual({ permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
|
||||
});
|
||||
|
||||
it('false never removes a statusLine the user wrote themselves', async () => {
|
||||
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
|
||||
writeSettings(claudeDir, { statusLine: mine });
|
||||
await put({ statusLineTelemetry: false });
|
||||
expect(readSettings(claudeDir).statusLine).toEqual(mine);
|
||||
});
|
||||
|
||||
it('false creates nothing in a workspace that never had the exporter', async () => {
|
||||
await put({ statusLineTelemetry: false });
|
||||
expect(existsSync(settingsFile(claudeDir))).toBe(false);
|
||||
expect(existsSync(remoteDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('is an action field, never persisted into settings.json', async () => {
|
||||
await put({ statusLineTelemetry: false, showTokenCount: true });
|
||||
const res = await h.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
const stored = JSON.parse(res.body);
|
||||
const settings = stored.data ?? stored;
|
||||
expect(settings.showTokenCount).toBe(true);
|
||||
expect('statusLineTelemetry' in settings).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* The generated plan-usage statusLine shim and the command that launches it.
|
||||
*
|
||||
* Like the DeepSeek status shim, the shim is emitted as a STRING and executed
|
||||
* by someone else, Claude Code, before every render, so tsc never sees it. The
|
||||
* assertions therefore run the real file in a real `node` process, with a real
|
||||
* temp HOME and a real listener, rather than inspecting the source text. The
|
||||
* injected command is exercised the same way, through `sh -c`, because its
|
||||
* fallback half is the only thing that renders inside a Docker case's
|
||||
* container and a typo there is invisible to every other check.
|
||||
*
|
||||
* The load-bearing property is the pair: the shim must keep forwarding plan
|
||||
* usage to Codeman AND give the user back the statusline it shadows. Losing
|
||||
* either half silently defeats the feature, in one direction by blanking the
|
||||
* header chip and in the other by stealing the terminal footer.
|
||||
*/
|
||||
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
ensureStatusLineShim,
|
||||
LEGACY_STATUSLINE_MARKER,
|
||||
resetStatusLineShimForTest,
|
||||
statusLineShimGuard,
|
||||
statusLineShimPath,
|
||||
STATUSLINE_SHIM_TOKEN,
|
||||
} from '../src/statusline-shim.js';
|
||||
import { generateStatusLineCommand, isCodemanStatusLine } from '../src/hooks-config.js';
|
||||
|
||||
const PORT = 3252;
|
||||
/** A port nothing listens on, for the unreachable-Codeman case. Claimed here so
|
||||
* the repo-wide `const PORT =` search a contributor runs finds it too. */
|
||||
const PORT_DEAD = 3253;
|
||||
|
||||
/** Point a settings file's statusLine at a shell command. */
|
||||
function writeStatusLine(file: string, command: string): void {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify({ statusLine: { type: 'command', command } }, null, 2));
|
||||
}
|
||||
|
||||
describe('statusLine shim: provisioning', () => {
|
||||
beforeEach(() => {
|
||||
resetStatusLineShimForTest();
|
||||
});
|
||||
|
||||
it('writes an executable shim that node can actually parse', () => {
|
||||
const path = ensureStatusLineShim();
|
||||
expect(path).toBeTruthy();
|
||||
expect(existsSync(path!)).toBe(true);
|
||||
expect(statSync(path!).mode & 0o777).toBe(0o700);
|
||||
// `node --check` on the real file: a template-literal typo in SHIM_SOURCE is
|
||||
// invisible to tsc, because the shim is a string as far as it is concerned.
|
||||
expect(() => execFileSync(process.execPath, ['--check', path!], { stdio: 'pipe' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('refreshes a shim written by an older Codeman, and leaves no temp file behind', () => {
|
||||
const path = statusLineShimPath();
|
||||
ensureStatusLineShim();
|
||||
const current = readFileSync(path, 'utf-8');
|
||||
|
||||
writeFileSync(path, `#!/usr/bin/env node\n// ${STATUSLINE_SHIM_TOKEN} v0\nprocess.exit(0)\n`, { mode: 0o700 });
|
||||
resetStatusLineShimForTest();
|
||||
ensureStatusLineShim();
|
||||
|
||||
expect(readFileSync(path, 'utf-8')).toBe(current);
|
||||
const strays = readdirSync(dirname(path)).filter((f) => f.startsWith(STATUSLINE_SHIM_TOKEN) && f.endsWith('.tmp'));
|
||||
expect(strays).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-asserts the exec bit even when the content already matches', () => {
|
||||
const path = ensureStatusLineShim()!;
|
||||
chmodSync(path, 0o600); // a restored backup / copied data dir
|
||||
resetStatusLineShimForTest();
|
||||
ensureStatusLineShim();
|
||||
expect(statSync(path).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
it('names the shim so the guard carries the ownership token', () => {
|
||||
// isCodemanStatusLine decides ownership on this substring. If the file is
|
||||
// ever renamed out from under it, Codeman stops recognising its own entries
|
||||
// and starts treating them as hand-authored.
|
||||
const guard = statusLineShimGuard();
|
||||
expect(guard).toBeTruthy();
|
||||
expect(guard).toContain(STATUSLINE_SHIM_TOKEN);
|
||||
// Absolute node, not a bare `node`: a managed session's PATH need not have one.
|
||||
expect(guard).toContain(process.execPath);
|
||||
});
|
||||
|
||||
it('tests both paths before exec-ing, and quotes them, so a data dir with a space still runs', () => {
|
||||
const node = `'${process.execPath}'`;
|
||||
const shim = `'${statusLineShimPath()}'`;
|
||||
expect(statusLineShimGuard()).toBe(`if [ -x ${node} ] && [ -f ${shim} ]; then exec ${node} ${shim}; fi;`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusLine shim: rendering', () => {
|
||||
let shim: string;
|
||||
let server: Server;
|
||||
let received: Array<{ url: string; body: string }> = [];
|
||||
let footer = 'CODEMAN-FOOTER';
|
||||
let workspace: string;
|
||||
let fakeHome: string;
|
||||
|
||||
/** Run the shim the way Claude Code does: a subprocess, JSON on stdin. */
|
||||
function render(
|
||||
payload: object,
|
||||
env: Record<string, string> = {},
|
||||
cwd: string = workspace
|
||||
): Promise<{ stdout: string; code: number | null }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [shim], {
|
||||
cwd,
|
||||
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (c) => (stdout += c));
|
||||
child.on('close', (code) => resolve({ stdout, code }));
|
||||
child.stdin.end(JSON.stringify(payload));
|
||||
});
|
||||
}
|
||||
|
||||
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
|
||||
|
||||
beforeAll(async () => {
|
||||
resetStatusLineShimForTest();
|
||||
shim = ensureStatusLineShim()!;
|
||||
|
||||
server = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
received.push({ url: req.url ?? '', body });
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end(footer);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
received = [];
|
||||
footer = 'CODEMAN-FOOTER';
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-statusline-'));
|
||||
fakeHome = join(root, 'home');
|
||||
workspace = join(root, 'repo');
|
||||
mkdirSync(join(fakeHome, '.claude'), { recursive: true });
|
||||
mkdirSync(join(workspace, '.claude'), { recursive: true });
|
||||
// The entry Codeman injects into the managed repo. Every case below has it,
|
||||
// because the shim must always skip its own entry while hunting a delegate.
|
||||
writeStatusLine(join(workspace, '.claude', 'settings.local.json'), `'${process.execPath}' '${shim}'`);
|
||||
});
|
||||
|
||||
it('prints the global statusline it shadows', async () => {
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||
const { stdout } = await render({ cwd: workspace });
|
||||
expect(stdout).toBe('THE-USERS-LINE');
|
||||
});
|
||||
|
||||
it('hands the delegate the same JSON Claude Code sent', async () => {
|
||||
// The delegate is only useful if it sees the payload: every statusline
|
||||
// script reads the model, the cwd or the rate limits off this blob.
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), `bash -c 'read -r j; echo "GOT:$j"'`);
|
||||
const { stdout } = await render({ cwd: workspace, model: { display_name: 'Opus 5' } });
|
||||
expect(stdout).toContain('"display_name":"Opus 5"');
|
||||
});
|
||||
|
||||
it('never delegates to its own entry, and prints nothing rather than a brand word', async () => {
|
||||
// No other statusLine exists, so the only candidate is the shim's own. If
|
||||
// the loop guard failed this would fork until something ran out. And with
|
||||
// nothing to shadow and no Codeman to ask, the line stays blank: the bare
|
||||
// word `codeman` is the symptom discussion #405 opened with.
|
||||
const { stdout, code } = await render({ cwd: workspace });
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toBe('');
|
||||
});
|
||||
|
||||
it('never delegates to the pre-shim inline exporter', async () => {
|
||||
// An upgraded install can still have the old command in a parent settings
|
||||
// file. Running it would double-report and print Codeman's footer anyway.
|
||||
writeStatusLine(
|
||||
join(fakeHome, '.claude', 'settings.json'),
|
||||
`curl -sk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" || echo codeman`
|
||||
);
|
||||
const { stdout } = await render({ cwd: workspace });
|
||||
expect(stdout).toBe('');
|
||||
});
|
||||
|
||||
it('prefers a project statusline to the global one', async () => {
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
|
||||
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
|
||||
const { stdout } = await render({ cwd: workspace });
|
||||
expect(stdout).toBe('PROJECT');
|
||||
});
|
||||
|
||||
it('reads project settings from the launch directory, not the current one', async () => {
|
||||
// Claude Code applies a project's settings from the directory it was
|
||||
// launched in (workspace.project_dir), which the blob keeps reporting after
|
||||
// the working directory changes mid-session.
|
||||
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
|
||||
const elsewhere = join(dirname(workspace), 'elsewhere');
|
||||
mkdirSync(elsewhere, { recursive: true });
|
||||
const { stdout } = await render(
|
||||
{ cwd: elsewhere, workspace: { current_dir: elsewhere, project_dir: workspace } },
|
||||
{},
|
||||
elsewhere
|
||||
);
|
||||
expect(stdout).toBe('PROJECT');
|
||||
});
|
||||
|
||||
it('does not walk up from the launch directory', async () => {
|
||||
// Claude Code reads project settings from the launch directory alone, so a
|
||||
// .claude in an ancestor is one it would have ignored. Delegating to it
|
||||
// would run a statusline the user never sees otherwise.
|
||||
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo ANCESTOR');
|
||||
const sub = join(workspace, 'packages', 'inner');
|
||||
mkdirSync(sub, { recursive: true });
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
|
||||
const { stdout } = await render({ cwd: sub, workspace: { current_dir: sub, project_dir: sub } }, {}, sub);
|
||||
expect(stdout).toBe('GLOBAL');
|
||||
});
|
||||
|
||||
it('ignores a user-level settings.local.json, which Claude Code does not read', async () => {
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.local.json'), 'echo NOT-A-REAL-FILE');
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
|
||||
const { stdout } = await render({ cwd: workspace });
|
||||
expect(stdout).toBe('GLOBAL');
|
||||
});
|
||||
|
||||
it('forwards telemetry to Codeman WHILE delegating', async () => {
|
||||
// The whole point: taking the user's line back must not cost the header chip.
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||
const { stdout } = await render(
|
||||
{ cwd: workspace, rate_limits: { five_hour: { used_percentage: 12, resets_at: 99 } } },
|
||||
managed()
|
||||
);
|
||||
|
||||
expect(stdout).toBe('THE-USERS-LINE');
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].url).toBe(LEGACY_STATUSLINE_MARKER);
|
||||
const posted = JSON.parse(received[0].body);
|
||||
expect(posted.sessionId).toBe('sess-1');
|
||||
expect(posted.data.rate_limits.five_hour.used_percentage).toBe(12);
|
||||
});
|
||||
|
||||
it("prints Codeman's own footer when there is no line to shadow", async () => {
|
||||
const { stdout } = await render({ cwd: workspace }, managed());
|
||||
expect(stdout).toBe('CODEMAN-FOOTER');
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('treats the bare brand word from an older server as no telemetry', async () => {
|
||||
// A pre-1.28 route answers `codeman` for a session it does not know. That
|
||||
// word on the statusline is what cost discussion #405 seven repositories
|
||||
// of debugging, so it must never be printed, whichever server answers.
|
||||
footer = 'codeman';
|
||||
const { stdout } = await render({ cwd: workspace }, managed());
|
||||
expect(stdout).toBe('');
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips the POST entirely outside a managed session', async () => {
|
||||
// Running `claude` by hand in a managed repo must cost nothing extra.
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||
const { stdout } = await render({ cwd: workspace });
|
||||
expect(stdout).toBe('THE-USERS-LINE');
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to the footer when the delegate fails silently', async () => {
|
||||
// A delegate that exits non-zero with no output must not win over a footer
|
||||
// Codeman can supply.
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'exit 3');
|
||||
const { stdout } = await render({ cwd: workspace }, managed());
|
||||
expect(stdout).toBe('CODEMAN-FOOTER');
|
||||
});
|
||||
|
||||
it('still prints a failing delegate that produced output', async () => {
|
||||
// Plenty of statusline scripts end on the exit code of their last command.
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo PARTIAL; exit 1');
|
||||
const { stdout } = await render({ cwd: workspace });
|
||||
expect(stdout).toBe('PARTIAL');
|
||||
});
|
||||
|
||||
it('survives an unreachable Codeman and a malformed payload', async () => {
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||
const child = spawn(process.execPath, [shim], {
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: fakeHome,
|
||||
USERPROFILE: fakeHome,
|
||||
CODEMAN_SESSION_ID: 'sess-1',
|
||||
// Nothing listens here.
|
||||
CODEMAN_API_URL: `http://127.0.0.1:${PORT_DEAD}`,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (c) => (stdout += c));
|
||||
child.stdin.end('not json at all');
|
||||
const code = await new Promise<number | null>((r) => child.on('close', r));
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toBe('THE-USERS-LINE');
|
||||
});
|
||||
|
||||
it('ignores a malformed settings file instead of dying on it', async () => {
|
||||
writeFileSync(join(workspace, '.claude', 'settings.json'), '{ broken');
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||
const { stdout, code } = await render({ cwd: workspace });
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toBe('THE-USERS-LINE');
|
||||
});
|
||||
|
||||
it('ignores a statusLine that is not a command', async () => {
|
||||
writeFileSync(
|
||||
join(fakeHome, '.claude', 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'something-else', command: 'echo NOPE' } })
|
||||
);
|
||||
const { stdout } = await render({ cwd: workspace }, managed());
|
||||
expect(stdout).toBe('CODEMAN-FOOTER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the injected statusLine command', () => {
|
||||
let server: Server;
|
||||
let received: string[] = [];
|
||||
let fakeHome: string;
|
||||
|
||||
/** Run the command the way Claude Code does: through a shell, JSON on stdin. */
|
||||
function run(command: string, env: Record<string, string>, stdin = '{"model":{"display_name":"Opus"}}') {
|
||||
return new Promise<{ stdout: string; code: number | null }>((resolve) => {
|
||||
const child = spawn('/bin/sh', ['-c', command], {
|
||||
cwd: fakeHome,
|
||||
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (c) => (stdout += c));
|
||||
child.on('close', (code) => resolve({ stdout, code }));
|
||||
child.stdin.end(stdin);
|
||||
});
|
||||
}
|
||||
|
||||
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-2', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
|
||||
|
||||
beforeAll(async () => {
|
||||
resetStatusLineShimForTest();
|
||||
server = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
received.push(body);
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('CODEMAN-FOOTER');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
received = [];
|
||||
fakeHome = mkdtempSync(join(tmpdir(), 'codeman-statusline-cmd-'));
|
||||
});
|
||||
|
||||
it('is recognised as ours by both of its halves', () => {
|
||||
const command = generateStatusLineCommand();
|
||||
expect(command.startsWith('if [ -x ')).toBe(true);
|
||||
expect(command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||
expect(command).toContain(LEGACY_STATUSLINE_MARKER);
|
||||
expect(isCodemanStatusLine(command)).toBe(true);
|
||||
// The word the whole change exists to remove.
|
||||
expect(command).not.toContain('echo codeman');
|
||||
});
|
||||
|
||||
it('runs the shim where the shim exists', async () => {
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||
const { stdout } = await run(generateStatusLineCommand(), managed());
|
||||
expect(stdout).toBe('THE-USERS-LINE');
|
||||
expect(JSON.parse(received[0]).sessionId).toBe('sess-2');
|
||||
});
|
||||
|
||||
it('falls through to the inline curl exporter where the shim does not exist', async () => {
|
||||
// Inside a Docker case's container the workspace's settings.local.json is
|
||||
// bind-mounted at the same absolute path, but neither the host's node nor
|
||||
// its data dir is. The same command must still report telemetry there.
|
||||
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
|
||||
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||
const { stdout } = await run(command, managed());
|
||||
expect(received).toHaveLength(1);
|
||||
expect(JSON.parse(received[0])).toMatchObject({ sessionId: 'sess-2', data: { model: { display_name: 'Opus' } } });
|
||||
// The inline half cannot delegate, so it prints the footer through.
|
||||
expect(stdout).toBe('CODEMAN-FOOTER');
|
||||
});
|
||||
|
||||
it('prints nothing, not a brand word, when the inline half has no Codeman to reach', async () => {
|
||||
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
|
||||
const { stdout, code } = await run(command, { CODEMAN_API_URL: '', CODEMAN_SESSION_ID: '' });
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -295,6 +295,13 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
|
||||
});
|
||||
|
||||
// Claude renders its themed backgrounds as RGB. Without this the pane inherits
|
||||
// tmux's TERM=screen, supports-color reads 16 colors, and every dark background
|
||||
// quantizes to ESC[40m — the terminal's own black — so the block goes invisible.
|
||||
it('exports truecolor for Claude sessions', () => {
|
||||
expect(callBuildEnvExports('claude')).toContain('export COLORTERM=truecolor');
|
||||
});
|
||||
|
||||
it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
|
||||
const original = process.env.CODEMAN_API_URL;
|
||||
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';
|
||||
|
||||
@@ -119,8 +119,9 @@ describe('formatSessionStatusText', () => {
|
||||
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
|
||||
});
|
||||
|
||||
it('falls back to a brand string when there is no data', () => {
|
||||
expect(formatSessionStatusText(null)).toBe('codeman');
|
||||
it('prints nothing when there is no data, never a brand string', () => {
|
||||
expect(formatSessionStatusText(null)).toBe('');
|
||||
expect(formatSessionStatusText({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user