mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +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
|
# 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
|
## 1.27.0
|
||||||
|
|
||||||
### Minor Changes
|
### 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.
|
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
|
## 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)
|
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||||
|
|
||||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine, and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry` and never overwrites a user's hand-authored statusLine. ⚠️ **Injecting a statusLine SHADOWS the user's own** (#405): a repo's `.claude/settings.local.json` outranks `~/.claude/settings.json`, and the old inline exporter printed Codeman's footer in its place, so a hand-run `claude` in any managed repo showed the bare word `codeman`. The exporter is therefore a generated, delegating shim (`src/statusline-shim.ts` writes `dataPath('codeman-statusline-shim.mjs')`, the `deepseek-status-shim` pattern) that forwards the blob and, concurrently, runs the statusline it shadows and prints THAT; the route's footer fills in only when there is nothing to shadow, and with neither it prints NOTHING (the route answers an unknown session with an empty body and `formatSessionStatusText(null)` is `''`: never a brand word). ⚠️ The delegate resolves at RENDER time from exactly the three documented files, `.claude/settings.local.json` + `.claude/settings.json` under `workspace.project_dir` (the launch dir), then `~/.claude/settings.json`, first non-ours wins: no ancestor walk and no user-level `settings.local.json`, since delegating to a command Claude Code would have ignored is the original failure in a new coat. ⚠️ **The injected command stays self-contained shell**: `if [ -x <node> ] && [ -f <shim> ]; then exec …; fi;` followed by the inline curl exporter, so the SAME bind-mounted `settings.local.json` renders the shim on the host and the curl inside a Docker case's container, where neither the host's node nor `~/.codeman` exists. Ownership (`isCodemanStatusLine()`) accepts the version-free `codeman-statusline-shim` token OR the `/api/status-telemetry` path; dropping the second makes every repo an older Codeman managed read as hand-authored. ⚠️ `statusLineTelemetry` is a settings-save ACTION field in BOTH directions: `true` on every save while the chip is on (re-injects into every live Claude workspace, remote pseudo-paths skipped), `false` ONLY on the save that turned the chip OFF on that device (`statusLineTelemetryAction()` in settings-ui.js), which removes our entry from those workspaces. Nothing called `applyStatusLineConfig(dir, false)` before, so turning the chip off left the line in every repo it had ever reached. The flip-only rule is what keeps a phone whose chip was never on from stripping the exporter a desktop depends on; a second device with the chip still on re-injects on its next save or session create and shows the last snapshot meanwhile. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||||
|
|
||||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
# Plan Usage Limits Display — Design & As-Built
|
# Plan Usage Limits Display — Design & As-Built
|
||||||
|
|
||||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
> **Status: SHIPPED. Deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. **The exporter changed shape in 1.28 (discussion #405)**: it is now a self-selecting shell guard that runs a generated, delegating shim (`src/statusline-shim.ts`) where the shim exists and the inline curl below where it does not (inside a Docker case's container). The shim prints the statusline it shadows and falls back to the footer below only when there is nothing to shadow; with neither it prints nothing, and the route now answers an unknown session with an empty body, so the bare word `codeman` never renders. Turning the chip OFF now also removes the exporter from live workspaces (`statusLineTelemetry:false`, sent only on the save that flips the chip off on a device), so the "removal only via the toggle" sentences below are current again and the "never remove" ones record the 1.9-1.27 shape. See `docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry`. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||||
>
|
>
|
||||||
> Two surfaces from one `statusLine` callback:
|
> Two surfaces from one `statusLine` callback:
|
||||||
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
||||||
|
|||||||
@@ -101,10 +101,14 @@ subscription plan.
|
|||||||
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
|
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
|
||||||
off on phones.
|
off on phones.
|
||||||
|
|
||||||
It works by installing a status line exporter into Claude Code, which posts Claude's own
|
It works by installing a status line exporter into each managed repo's
|
||||||
rate limit data back to Codeman. The exporter is marker-identified, so it only ever touches
|
`.claude/settings.local.json`, which posts Claude's own rate limit data back to Codeman. The
|
||||||
a status line Codeman installed, never one you wrote yourself, and it prints your footer
|
exporter is marker-identified, so it only ever touches a status line Codeman installed, never
|
||||||
through so the in-terminal status line still works.
|
one you wrote yourself. A repo's status line outranks the one in `~/.claude/settings.json`,
|
||||||
|
so the exporter also runs the status line it shadows and prints that instead of its own
|
||||||
|
footer: your global status line keeps rendering in managed repos, and in a repo with no
|
||||||
|
status line of your own you get Codeman's compact session footer. Turning the chip off takes
|
||||||
|
the exporter back out of the repos of your live sessions.
|
||||||
|
|
||||||
The chip and the exporter are the same setting. Turning the chip on without the exporter
|
The chip and the exporter are the same setting. Turning the chip on without the exporter
|
||||||
would leave it showing a dash forever, so resolve it in one place: **App Settings**.
|
would leave it showing a dash forever, so resolve it in one place: **App Settings**.
|
||||||
|
|||||||
@@ -117,10 +117,14 @@ For choosing a path rather than typing one. It appears in two places:
|
|||||||
- **Browse** in **Add Case → Link Existing**.
|
- **Browse** in **Add Case → Link Existing**.
|
||||||
- The **📁 Path** key on the mobile keyboard bar.
|
- The **📁 Path** key on the mobile keyboard bar.
|
||||||
|
|
||||||
It browses one directory at a time and can show hidden entries on request. The picker
|
It browses one directory at a time and can show hidden entries on request. The current
|
||||||
inserts the path into your prompt **without** pressing Enter, so nothing is submitted by
|
folder is an editable field: type or paste a path and press Enter (or **Go**) to jump
|
||||||
accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's
|
straight there, and a full file path lands in its folder with that file selected. The
|
||||||
`/clear` command.
|
**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
|
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
|
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.27.0",
|
"version": "1.28.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.27.0",
|
"version": "1.28.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -174,8 +174,19 @@ const CLAUDE: CliEntry = {
|
|||||||
legacyConfigAliases: { resumeId: 'resumeSessionId' },
|
legacyConfigAliases: { resumeId: 'resumeSessionId' },
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
exports: [],
|
// Claude asks for truecolor, like every CLI here except `shell` and `opencode`.
|
||||||
unset: ['CLAUDECODE', 'COLORTERM'],
|
// 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: [],
|
tmuxSetenvKeys: [],
|
||||||
dockerExecEnvNames: [],
|
dockerExecEnvNames: [],
|
||||||
allowedPrefixes: ['CLAUDE_CODE_'],
|
allowedPrefixes: ['CLAUDE_CODE_'],
|
||||||
|
|||||||
+57
-19
@@ -39,6 +39,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import type { HookEventType } from './types.js';
|
import type { HookEventType } from './types.js';
|
||||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||||
import { dataPath } from './config/instance.js';
|
import { dataPath } from './config/instance.js';
|
||||||
|
import { LEGACY_STATUSLINE_MARKER, statusLineShimGuard, STATUSLINE_SHIM_TOKEN } from './statusline-shim.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||||
@@ -844,38 +845,75 @@ async function readWorkspaceHooksEnabled(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
|
/**
|
||||||
const STATUSLINE_MARKER = '/api/status-telemetry';
|
* Is this statusLine command one Codeman wrote?
|
||||||
|
*
|
||||||
|
* Two markers count, and every command Codeman has ever injected carries at
|
||||||
|
* least one. The version-free `codeman-statusline-shim` token names the shim
|
||||||
|
* file the current guarded command runs; the `/api/status-telemetry` path is
|
||||||
|
* what the inline exporter posts to, in the pre-shim command AND in the
|
||||||
|
* fallback half of the current one. Both must be read as ours, or the upgrade
|
||||||
|
* mistakes an old injected command for a hand-authored line, refuses to touch
|
||||||
|
* it, and leaves the user with the shadowing exporter.
|
||||||
|
*/
|
||||||
|
export function isCodemanStatusLine(command: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof command === 'string' &&
|
||||||
|
(command.includes(STATUSLINE_SHIM_TOKEN) || command.includes(LEGACY_STATUSLINE_MARKER))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
|
* The inline exporter: env vars plus curl, portable by construction. It POSTs
|
||||||
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
|
* the statusline JSON and prints Codeman's answer, which means it SHADOWS
|
||||||
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
|
* whatever statusline the user configured globally. That is the cost the shim
|
||||||
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
|
* exists to remove, so this half only renders where the shim cannot run: inside
|
||||||
* (present in every managed session via tmux setenv), so the config is static.
|
* a Docker case's container (the workspace is bind-mounted, `~/.codeman` and
|
||||||
|
* the host's node are not), or on a host whose data dir could not be written.
|
||||||
|
*
|
||||||
|
* `curl -sfk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
|
||||||
|
* production setup, so without -k curl returns 000 (-k is safe here, loopback
|
||||||
|
* only); -f keeps an HTTP error body off the statusline. On any failure it
|
||||||
|
* prints NOTHING: the old `|| echo codeman` is the bare word that a hand-run
|
||||||
|
* `claude` in a managed repo rendered, and that reads as a broken config.
|
||||||
*/
|
*/
|
||||||
export function generateStatusLineCommand(): string {
|
function generateInlineStatusLineCommand(): string {
|
||||||
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
|
|
||||||
// production setup; without -k curl returns 000 and the statusline shows
|
|
||||||
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
|
|
||||||
// footer is never blank if Codeman is unreachable.
|
|
||||||
return (
|
return (
|
||||||
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
||||||
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||||
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
`curl -sfk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" ` +
|
||||||
`-H 'Content-Type: application/json' ` +
|
`-H 'Content-Type: application/json' ` +
|
||||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||||
`--data @- 2>/dev/null || echo codeman`
|
`--data @- 2>/dev/null || true`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan-usage statusLine exporter command.
|
||||||
|
*
|
||||||
|
* A self-selecting guard followed by the inline exporter: where the shim and
|
||||||
|
* the node binary both exist the guard `exec`s the delegating shim, which
|
||||||
|
* forwards the same JSON to Codeman and then prints the statusline its own
|
||||||
|
* entry shadows; anywhere else the shell falls through to the inline curl.
|
||||||
|
* The SAME injected string therefore renders correctly from the host and from
|
||||||
|
* inside a Docker case's container, which is what lets a bind-mounted
|
||||||
|
* `settings.local.json` carry it. See `statusLineShimGuard()`.
|
||||||
|
*/
|
||||||
|
export function generateStatusLineCommand(): string {
|
||||||
|
const guard = statusLineShimGuard();
|
||||||
|
const inline = generateInlineStatusLineCommand();
|
||||||
|
return guard ? `${guard} ${inline}` : inline;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or remove Codeman's plan-usage statusLine exporter in
|
* Add or remove Codeman's plan-usage statusLine exporter in
|
||||||
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
|
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
|
||||||
* (command targets `/api/status-telemetry`), so a user's hand-authored
|
* (see `isCodemanStatusLine`, which accepts the shim and the pre-shim inline
|
||||||
* statusLine is never removed OR overwritten — on both the enable and disable
|
* form), so a user's hand-authored statusLine is never removed OR overwritten
|
||||||
* paths we bail out when an existing statusLine isn't ours. Callers gate on
|
* — on both the enable and disable paths we bail out when an existing
|
||||||
* Claude mode. Merges, preserving all other keys (hooks, env, model).
|
* statusLine isn't ours. An enable on a repo still carrying the old inline
|
||||||
|
* command upgrades it to the shim in place. Callers gate on Claude mode.
|
||||||
|
* Merges, preserving all other keys (hooks, env, model).
|
||||||
*/
|
*/
|
||||||
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
||||||
@@ -889,7 +927,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = existing.statusLine as { command?: unknown } | undefined;
|
const current = existing.statusLine as { command?: unknown } | undefined;
|
||||||
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
|
const isOurs = !!current && isCodemanStatusLine(current.command);
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
const desired = generateStatusLineCommand();
|
const desired = generateStatusLineCommand();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { isEffortLevel } from './types.js';
|
|||||||
import { getAugmentedPath } from './utils/index.js';
|
import { getAugmentedPath } from './utils/index.js';
|
||||||
import { compareVersions } from './utils/dependency-checker.js';
|
import { compareVersions } from './utils/dependency-checker.js';
|
||||||
import { dataPath } from './config/instance.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.
|
* 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,
|
...process.env,
|
||||||
LANG: 'en_US.UTF-8',
|
LANG: 'en_US.UTF-8',
|
||||||
LC_ALL: '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(),
|
PATH: getAugmentedPath(),
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
// 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).
|
// as the literal "CODEMAN_API_URL=undefined" (COD-115).
|
||||||
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||||
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
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;
|
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;
|
path: string;
|
||||||
type: 'file' | 'directory';
|
type: 'file' | 'directory';
|
||||||
size?: number;
|
size?: number;
|
||||||
|
/** Last-modified time (ms since epoch) of the entry's target; lets the picker sort by date. */
|
||||||
|
mtimeMs?: number;
|
||||||
symlink?: boolean;
|
symlink?: boolean;
|
||||||
previewKind?: FilesystemPreviewKind;
|
previewKind?: FilesystemPreviewKind;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,10 +173,14 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
|
|||||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
||||||
* which live in the Codeman header chip. Claude requires a statusLine command to
|
* which live in the Codeman header chip. Claude requires a statusLine command to
|
||||||
* emit the rate_limits JSON at all, so this is what that command prints back.
|
* emit the rate_limits JSON at all, so this is what that command prints back
|
||||||
|
* when it has no statusline of the user's own to delegate to. With nothing to
|
||||||
|
* show it returns '' rather than a brand word: the exporter's shim reads an
|
||||||
|
* empty footer as "no telemetry", and a bare `codeman` on the statusline is the
|
||||||
|
* symptom discussion #405 opened with.
|
||||||
*/
|
*/
|
||||||
export function formatSessionStatusText(s: SessionStatus | null): string {
|
export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||||
if (!s) return 'codeman';
|
if (!s) return '';
|
||||||
const groups: string[] = [];
|
const groups: string[] = [];
|
||||||
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
||||||
const tok: string[] = [];
|
const tok: string[] = [];
|
||||||
@@ -184,7 +188,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
|
|||||||
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
||||||
if (tok.length) groups.push(tok.join(' '));
|
if (tok.length) groups.push(tok.join(' '));
|
||||||
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
||||||
return groups.length ? groups.join(' ') : 'codeman';
|
return groups.length ? groups.join(' ') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,11 +47,24 @@
|
|||||||
// the picker browses Home and every configured root, so wanting dotfiles in a
|
// the picker browses Home and every configured root, so wanting dotfiles in a
|
||||||
// project does not imply wanting them in ~.
|
// project does not imply wanting them in ~.
|
||||||
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
|
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 = {
|
const PathPicker = {
|
||||||
overlay: null,
|
overlay: null,
|
||||||
_options: null,
|
_options: null,
|
||||||
_selectedPath: '',
|
_selectedPath: '',
|
||||||
|
_currentPath: '',
|
||||||
|
_entries: [],
|
||||||
|
_truncated: false,
|
||||||
_previousFocus: null,
|
_previousFocus: null,
|
||||||
_keydownHandler: null,
|
_keydownHandler: null,
|
||||||
_loadSequence: 0,
|
_loadSequence: 0,
|
||||||
@@ -59,6 +72,7 @@ const PathPicker = {
|
|||||||
_previewRequestSequence: 0,
|
_previewRequestSequence: 0,
|
||||||
_previewPreviousFocus: null,
|
_previewPreviousFocus: null,
|
||||||
_showHidden: false,
|
_showHidden: false,
|
||||||
|
_sortMode: PATH_PICKER_DEFAULT_SORT,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the lazy filesystem browser.
|
* Open the lazy filesystem browser.
|
||||||
@@ -69,7 +83,10 @@ const PathPicker = {
|
|||||||
this.close(false);
|
this.close(false);
|
||||||
this._options = options;
|
this._options = options;
|
||||||
this._selectedPath = '';
|
this._selectedPath = '';
|
||||||
|
this._currentPath = '';
|
||||||
|
this._entries = [];
|
||||||
this._showHidden = this._loadShowHidden();
|
this._showHidden = this._loadShowHidden();
|
||||||
|
this._sortMode = this._loadSortMode();
|
||||||
this._previousFocus = document.activeElement;
|
this._previousFocus = document.activeElement;
|
||||||
this._previousFocus?.blur?.();
|
this._previousFocus?.blur?.();
|
||||||
|
|
||||||
@@ -90,11 +107,19 @@ const PathPicker = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="path-picker-nav">
|
<div class="path-picker-nav">
|
||||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
<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-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>
|
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||||
</div>
|
</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-list" role="listbox"></div>
|
||||||
<div class="path-picker-selection">
|
<div class="path-picker-selection">
|
||||||
<span class="path-picker-selection-label">Selected</span>
|
<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-cancel').addEventListener('click', () => this.close(true));
|
||||||
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
|
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
|
||||||
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
|
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
|
||||||
const current = overlay.querySelector('.path-picker-current').textContent;
|
if (this._currentPath) this.select(this._currentPath);
|
||||||
if (current) this.select(current);
|
|
||||||
});
|
});
|
||||||
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());
|
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
|
||||||
this._syncHiddenButton();
|
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', () => {
|
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||||
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||||
if (parent) this.load(parent);
|
if (parent) this.load(parent);
|
||||||
@@ -169,15 +209,97 @@ const PathPicker = {
|
|||||||
// OFF inside a hidden folder makes the current path unbrowsable again; the
|
// 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,
|
// server answers 403 and load()'s catch falls back to the default root,
|
||||||
// which is the only place left to stand.
|
// 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;
|
if (!this.overlay || !this._options) return;
|
||||||
const loadSequence = ++this._loadSequence;
|
const loadSequence = ++this._loadSequence;
|
||||||
const list = this.overlay.querySelector('.path-picker-list');
|
const list = this.overlay.querySelector('.path-picker-list');
|
||||||
const status = this.overlay.querySelector('.path-picker-status');
|
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...';
|
status.textContent = 'Loading...';
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -194,13 +316,40 @@ const PathPicker = {
|
|||||||
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
|
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
|
||||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||||
this.render(result.data);
|
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) {
|
} catch (error) {
|
||||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
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) {
|
if (path) {
|
||||||
this.load('');
|
this.load('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
status.textContent = error.message || 'Failed to browse this folder';
|
status.textContent = message;
|
||||||
status.classList.add('error');
|
status.classList.add('error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -216,19 +365,29 @@ const PathPicker = {
|
|||||||
rootSelect.appendChild(option);
|
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');
|
const up = this.overlay.querySelector('.path-picker-up');
|
||||||
up.dataset.parent = data.parent || '';
|
up.dataset.parent = data.parent || '';
|
||||||
up.disabled = !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');
|
const status = this.overlay.querySelector('.path-picker-status');
|
||||||
status.classList.remove('error');
|
status.classList.remove('error');
|
||||||
status.textContent = data.entries.length === 0
|
status.textContent = entries.length === 0
|
||||||
? 'This folder is empty'
|
? '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');
|
const list = this.overlay.querySelector('.path-picker-list');
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
for (const entry of data.entries) {
|
for (const entry of entries) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'path-picker-item';
|
row.className = 'path-picker-item';
|
||||||
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
|
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
|
||||||
@@ -248,6 +407,14 @@ const PathPicker = {
|
|||||||
name.className = 'path-picker-item-name';
|
name.className = 'path-picker-item-name';
|
||||||
name.textContent = entry.name;
|
name.textContent = entry.name;
|
||||||
open.append(icon, 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) {
|
if (entry.symlink) {
|
||||||
const link = document.createElement('span');
|
const link = document.createElement('span');
|
||||||
link.className = 'path-picker-item-link';
|
link.className = 'path-picker-item-link';
|
||||||
@@ -417,6 +584,8 @@ const PathPicker = {
|
|||||||
this._previousFocus = null;
|
this._previousFocus = null;
|
||||||
this._options = null;
|
this._options = null;
|
||||||
this._selectedPath = '';
|
this._selectedPath = '';
|
||||||
|
this._currentPath = '';
|
||||||
|
this._entries = [];
|
||||||
if (restoreFocus) previousFocus?.focus?.();
|
if (restoreFocus) previousFocus?.focus?.();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2314,6 +2314,27 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return;
|
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.
|
// Wait for terminal renderer to fully initialize before any writes.
|
||||||
// xterm.js needs a few frames after open() before write() is safe.
|
// xterm.js needs a few frames after open() before write() is safe.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -2050,6 +2050,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
|
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
|
||||||
// as "previously off" — used below to detect a real OFF→ON flip.
|
// as "previously off" — used below to detect a real OFF→ON flip.
|
||||||
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
||||||
|
// Plan-usage chip: the exporter it depends on is removed from live workspaces
|
||||||
|
// ONLY on the save that turns the chip off (see statusLineTelemetryAction).
|
||||||
|
const _prevPlanUsageChip = this.planUsageChipEnabled(_prev);
|
||||||
const settings = {
|
const settings = {
|
||||||
displayName: window.CodemanI18n?.normalizeDisplayName(
|
displayName: window.CodemanI18n?.normalizeDisplayName(
|
||||||
document.getElementById('appSettingsDisplayName').value
|
document.getElementById('appSettingsDisplayName').value
|
||||||
@@ -2280,9 +2283,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
|
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
|
||||||
// also absent from SettingsUpdateSchema, which is .strict() — sending it
|
// also absent from SettingsUpdateSchema, which is .strict() — sending it
|
||||||
// would 400 the whole settings PUT.
|
// would 400 the whole settings PUT.
|
||||||
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
|
// Telemetry COLLECTION is requested out-of-band via the statusLineTelemetry
|
||||||
// ENABLE only, so a device with the chip OFF never strips the exporter that
|
// action field: `true` on every save while the chip is on, `false` only on the
|
||||||
// another device's chip depends on — see system-routes settings handler).
|
// save that turned it off here, nothing otherwise (statusLineTelemetryAction),
|
||||||
|
// so a device whose chip was never on cannot strip the exporter another
|
||||||
|
// device's chip depends on. See the system-routes settings handler.
|
||||||
const {
|
const {
|
||||||
localEchoEnabled: _leo,
|
localEchoEnabled: _leo,
|
||||||
cjkInputEnabled: _cjk,
|
cjkInputEnabled: _cjk,
|
||||||
@@ -2316,10 +2321,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
sessionLineageLines: _sll,
|
sessionLineageLines: _sll,
|
||||||
...serverSettings
|
...serverSettings
|
||||||
} = settings;
|
} = settings;
|
||||||
|
const statusLineTelemetry = this.statusLineTelemetryAction(_prevPlanUsageChip, settings.showPlanUsageLimits);
|
||||||
try {
|
try {
|
||||||
const res = await this._apiPut('/api/settings', {
|
const res = await this._apiPut('/api/settings', {
|
||||||
...serverSettings,
|
...serverSettings,
|
||||||
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
|
...(statusLineTelemetry === undefined ? {} : { statusLineTelemetry }),
|
||||||
notificationPreferences: notifPrefsToSave,
|
notificationPreferences: notifPrefsToSave,
|
||||||
voiceSettings,
|
voiceSettings,
|
||||||
});
|
});
|
||||||
@@ -2592,6 +2598,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
|
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// What a settings save tells the server about the plan-usage exporter, given
|
||||||
|
// the chip's state before and after the save. `true` re-injects the exporter
|
||||||
|
// into every live Claude workspace and may ride every save while the chip is
|
||||||
|
// on. `false` REMOVES it from those workspaces, and the chip is per-device
|
||||||
|
// while the exporter lives in each repo's shared settings.local.json, so it
|
||||||
|
// may ride only the save that turned the chip off on this device: a phone
|
||||||
|
// whose chip was never on must never strip what a desktop's chip depends on.
|
||||||
|
// Pure, so test/plan-usage-telemetry-action.test.ts can pin all three cases.
|
||||||
|
statusLineTelemetryAction(prevEnabled, nowEnabled) {
|
||||||
|
if (nowEnabled) return true;
|
||||||
|
if (prevEnabled) return false;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
|
||||||
applyHeaderVisibilitySettings() {
|
applyHeaderVisibilitySettings() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
const defaults = this.getDefaultSettings();
|
const defaults = this.getDefaultSettings();
|
||||||
|
|||||||
+91
-17
@@ -545,18 +545,21 @@ body {
|
|||||||
contain: layout;
|
contain: layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-tabs.tabs-two-rows {
|
/* A wrapped strip GROWS the header instead of scrolling inside it. The old
|
||||||
flex-wrap: wrap;
|
fixed caps (120px tall-tabs / 96px auto-wrap) were row counts in disguise:
|
||||||
overflow-x: hidden;
|
a third row of tabs was clipped into a 4px scroller, so the tabs you were
|
||||||
overflow-y: auto;
|
looking for were off-screen in a container nothing tells you to scroll —
|
||||||
max-height: 120px;
|
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 {
|
.session-tabs.tabs-auto-wrap {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 96px;
|
max-height: var(--tab-strip-max-height, 40vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-tabs::-webkit-scrollbar {
|
.session-tabs::-webkit-scrollbar {
|
||||||
@@ -13617,24 +13620,95 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
cursor: default;
|
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;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 9px 11px;
|
margin: 0;
|
||||||
overflow-x: auto;
|
|
||||||
color: var(--accent);
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-picker-status {
|
.path-picker-jump:focus-within {
|
||||||
padding: 0 14px 8px;
|
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);
|
color: var(--text-dim);
|
||||||
font-size: 0.7rem;
|
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 {
|
.path-picker-status.error {
|
||||||
|
|||||||
@@ -383,6 +383,29 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return false;
|
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.
|
// 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
|
// 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)
|
// 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 type: FilesystemBrowseEntry['type'];
|
||||||
let size: number | undefined;
|
let size: number | undefined;
|
||||||
|
let mtimeMs: number | undefined;
|
||||||
const symlink = entry.isSymbolicLink();
|
const symlink = entry.isSymbolicLink();
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
type = 'directory';
|
type = 'directory';
|
||||||
@@ -886,6 +887,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
const targetStat = await fs.stat(targetPath);
|
const targetStat = await fs.stat(targetPath);
|
||||||
type = targetStat.isDirectory() ? 'directory' : 'file';
|
type = targetStat.isDirectory() ? 'directory' : 'file';
|
||||||
if (type === 'file') size = targetStat.size;
|
if (type === 'file') size = targetStat.size;
|
||||||
|
mtimeMs = targetStat.mtimeMs;
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -894,11 +896,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
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 {
|
try {
|
||||||
size = (await fs.stat(targetPath)).size;
|
const targetStat = await fs.stat(targetPath);
|
||||||
|
mtimeMs = targetStat.mtimeMs;
|
||||||
|
if (type === 'file') size = targetStat.size;
|
||||||
} catch {
|
} 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({
|
entries.push({
|
||||||
@@ -906,6 +912,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
path: visiblePath,
|
path: visiblePath,
|
||||||
type,
|
type,
|
||||||
size,
|
size,
|
||||||
|
mtimeMs,
|
||||||
symlink: symlink || undefined,
|
symlink: symlink || undefined,
|
||||||
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
||||||
*
|
*
|
||||||
* Returns a compact plain-text status string for the exporter to print as the
|
* Returns a compact plain-text status string for the exporter to print as the
|
||||||
* in-terminal footer (print-through), so injecting our statusLine doesn't leave
|
* in-terminal footer when it has no statusline of the user's own to delegate to
|
||||||
* the terminal footer blank.
|
* (see `statusline-shim.ts`). An unknown session gets an EMPTY body: the old
|
||||||
|
* brand-word answer rendered as the statusline of every hand-run `claude` in a
|
||||||
|
* managed repo, and cost discussion #405 seven repositories of debugging.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
@@ -36,10 +38,12 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
|||||||
|
|
||||||
reply.type('text/plain; charset=utf-8');
|
reply.type('text/plain; charset=utf-8');
|
||||||
|
|
||||||
// Unknown session — minimal footer, no broadcast.
|
// Unknown session: nothing to broadcast and nothing to print. Never a brand
|
||||||
|
// word here, it would render as the statusline (the shim treats an empty
|
||||||
|
// answer as "no telemetry" and falls through to the delegate or to blank).
|
||||||
if (!ctx.sessions.has(sessionId)) {
|
if (!ctx.sessions.has(sessionId)) {
|
||||||
lastSig.delete(sessionId);
|
lastSig.delete(sessionId);
|
||||||
return 'codeman';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = data as RawStatuslinePayload | undefined;
|
const payload = data as RawStatuslinePayload | undefined;
|
||||||
|
|||||||
@@ -1034,20 +1034,34 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
|
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
|
||||||
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
|
// Telemetry COLLECTION is a per-save ACTION field in both directions. `true`
|
||||||
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
|
// rides every save while the chip is on: we (re)inject our exporter into every
|
||||||
// into every ACTIVE Claude session's working dir so the live % starts flowing
|
// ACTIVE Claude session's working dir so the live % starts flowing immediately
|
||||||
// immediately (no new session needed). We deliberately never auto-REMOVE here:
|
// (no new session needed), and that is also how a second device catches up.
|
||||||
// the exporter is benign/print-through and a per-repo settings.local.json is
|
// `false` rides ONLY the save that turned the chip OFF on that device
|
||||||
// shared by sibling sessions, so one device's "off" must not yank the exporter
|
// (statusLineTelemetryAction in settings-ui.js) and takes our exporter back
|
||||||
// another device's chip depends on. Each dir handled once.
|
// out of those same dirs. Nothing called the disable path before, so turning
|
||||||
if (statusLineTelemetry === true) {
|
// the chip off left the line in every repo it had ever reached (#405). A
|
||||||
|
// per-repo settings.local.json is shared by sibling sessions and by every
|
||||||
|
// device, so the flip-only rule is what keeps a phone whose chip was never on
|
||||||
|
// from stripping the exporter a desktop's chip depends on; a device with the
|
||||||
|
// chip still on re-injects on its next save or session create and shows the
|
||||||
|
// last snapshot meanwhile. Both paths are isOurs-guarded (a hand-authored
|
||||||
|
// statusLine is never touched), remote attaches are skipped (their workingDir
|
||||||
|
// is a user@host:session pseudo-path the enable path would mkdir as a junk
|
||||||
|
// local dir), and each dir is handled once.
|
||||||
|
if (statusLineTelemetry === true || statusLineTelemetry === false) {
|
||||||
|
const user = getAuthUser(req);
|
||||||
const dirs = new Set<string>();
|
const dirs = new Set<string>();
|
||||||
for (const session of ctx.sessions.values()) {
|
for (const session of ctx.sessions.values()) {
|
||||||
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir)
|
if (!getCli(session.mode)?.capabilities.statusLineTelemetry || !session.workingDir) continue;
|
||||||
dirs.add(session.workingDir);
|
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
|
// Handle tunnel toggle dynamically
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
updateCaseModel,
|
updateCaseModel,
|
||||||
writeHooksConfig,
|
writeHooksConfig,
|
||||||
} from '../src/hooks-config.js';
|
} from '../src/hooks-config.js';
|
||||||
|
import { LEGACY_STATUSLINE_MARKER, STATUSLINE_SHIM_TOKEN } from '../src/statusline-shim.js';
|
||||||
|
|
||||||
describe('generateHooksConfig', () => {
|
describe('generateHooksConfig', () => {
|
||||||
it('should return an object with hooks key', () => {
|
it('should return an object with hooks key', () => {
|
||||||
@@ -1305,3 +1306,85 @@ describe('Hook Config Generation - Extended', () => {
|
|||||||
expect(stopHooks[0].hooks[0].command).toContain('stop');
|
expect(stopHooks[0].hooks[0].command).toContain('stop');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('applyStatusLineConfig', () => {
|
||||||
|
const testDir = join(tmpdir(), 'codeman-statusline-config-' + Date.now());
|
||||||
|
const settingsFile = join(testDir, '.claude', 'settings.local.json');
|
||||||
|
|
||||||
|
const read = () => JSON.parse(readFileSync(settingsFile, 'utf-8'));
|
||||||
|
const write = (value: object) => {
|
||||||
|
mkdirSync(join(testDir, '.claude'), { recursive: true });
|
||||||
|
writeFileSync(settingsFile, JSON.stringify(value, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects the guarded shim command with the inline exporter as its fallback', async () => {
|
||||||
|
await applyStatusLineConfig(testDir, true);
|
||||||
|
const { statusLine } = read();
|
||||||
|
expect(statusLine.type).toBe('command');
|
||||||
|
// The shim runs first wherever it exists: it is what gives the user their
|
||||||
|
// own statusline back. The inline half after it is what renders where the
|
||||||
|
// shim cannot (inside a Docker case's container), and it must not carry the
|
||||||
|
// brand-word fallback the old exporter printed.
|
||||||
|
expect(statusLine.command.startsWith('if [ -x ')).toBe(true);
|
||||||
|
expect(statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||||
|
expect(statusLine.command).toContain(LEGACY_STATUSLINE_MARKER);
|
||||||
|
expect(statusLine.command).not.toContain('echo codeman');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades a pre-shim inline exporter in place', async () => {
|
||||||
|
// Every repo a previous Codeman managed still holds this command. If the
|
||||||
|
// ownership check missed it, the upgrade would read it as hand-authored,
|
||||||
|
// refuse to touch it, and leave the user shadowed forever.
|
||||||
|
write({
|
||||||
|
statusLine: { type: 'command', command: `curl -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` },
|
||||||
|
permissions: { allow: ['Read'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await applyStatusLineConfig(testDir, true);
|
||||||
|
|
||||||
|
const settings = read();
|
||||||
|
expect(settings.statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||||
|
expect(settings.permissions).toEqual({ allow: ['Read'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a pre-shim inline exporter on the disable path', async () => {
|
||||||
|
write({ statusLine: { type: 'command', command: `curl "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` } });
|
||||||
|
await applyStatusLineConfig(testDir, false);
|
||||||
|
expect(read().statusLine).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes its own shim entry on the disable path', async () => {
|
||||||
|
await applyStatusLineConfig(testDir, true);
|
||||||
|
await applyStatusLineConfig(testDir, false);
|
||||||
|
expect(read().statusLine).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches a statusLine the user wrote themselves', async () => {
|
||||||
|
// Unchanged contract: a hand-authored entry in the repo's own file stops
|
||||||
|
// Codeman cold, so it never owns an entry it would have to restore later.
|
||||||
|
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
|
||||||
|
write({ statusLine: mine });
|
||||||
|
|
||||||
|
await applyStatusLineConfig(testDir, true);
|
||||||
|
expect(read().statusLine).toEqual(mine);
|
||||||
|
|
||||||
|
await applyStatusLineConfig(testDir, false);
|
||||||
|
expect(read().statusLine).toEqual(mine);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites nothing when the shim command is already current', async () => {
|
||||||
|
await applyStatusLineConfig(testDir, true);
|
||||||
|
const before = readFileSync(settingsFile, 'utf-8');
|
||||||
|
await applyStatusLineConfig(testDir, true);
|
||||||
|
expect(readFileSync(settingsFile, 'utf-8')).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,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 () => {
|
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
|
// The "Link Existing" case picker opens with an empty path and no
|
||||||
// sessionId. `Home` and `Codeman Cases` are unrelated bind mounts under
|
// 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 });
|
const res = await post({ sessionId: 'does-not-exist', data: REAL });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body).toBe('codeman');
|
expect(res.body).toBe('');
|
||||||
expect(h.ctx.broadcast).not.toHaveBeenCalled();
|
expect(h.ctx.broadcast).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* PUT /api/settings: the `statusLineTelemetry` ACTION field, both directions.
|
||||||
|
*
|
||||||
|
* `true` (sent on every save while the plan-usage chip is on) injects Codeman's
|
||||||
|
* statusLine exporter into every live Claude session's workspace so the chip's
|
||||||
|
* data starts flowing without a new session. `false` (sent only when the chip
|
||||||
|
* was just turned OFF on a device) takes the exporter back out of those same
|
||||||
|
* workspaces. Before this, nothing in `src/` ever called the disable path, so
|
||||||
|
* turning the chip off left the line in every repo it had ever reached
|
||||||
|
* (discussion #405).
|
||||||
|
*
|
||||||
|
* Both directions are `isOurs`-guarded in `applyStatusLineConfig`, so a
|
||||||
|
* statusLine the user wrote themselves is never added to, replaced, or removed.
|
||||||
|
* Remote-attach sessions are skipped in both: their `workingDir` is a
|
||||||
|
* `user@host:session` pseudo-path that the enable path would otherwise create
|
||||||
|
* as a junk local directory.
|
||||||
|
*
|
||||||
|
* Uses app.inject(), real temp workspaces under the test HOME. Port: N/A.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { createMockSession } from '../mocks/mock-session.js';
|
||||||
|
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||||
|
import { STATUSLINE_SHIM_TOKEN } from '../../src/statusline-shim.js';
|
||||||
|
|
||||||
|
// The three service toggles start/stop real watchers from this handler; stub
|
||||||
|
// them so a settings PUT in a test never starts a filesystem watcher.
|
||||||
|
const { subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
|
||||||
|
const makeWatcher = () => ({
|
||||||
|
isRunning: vi.fn(() => false),
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
getStats: vi.fn(() => ({})),
|
||||||
|
watchSession: vi.fn(),
|
||||||
|
getRecentRunSummaries: vi.fn(() => []),
|
||||||
|
});
|
||||||
|
return { subagentWatcher: makeWatcher(), imageWatcher: makeWatcher(), workflowRunWatcher: makeWatcher() };
|
||||||
|
});
|
||||||
|
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
|
||||||
|
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
|
||||||
|
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
|
||||||
|
|
||||||
|
const settingsFile = (dir: string) => join(dir, '.claude', 'settings.local.json');
|
||||||
|
const readSettings = (dir: string) => JSON.parse(readFileSync(settingsFile(dir), 'utf-8'));
|
||||||
|
const writeSettings = (dir: string, value: object) => {
|
||||||
|
mkdirSync(join(dir, '.claude'), { recursive: true });
|
||||||
|
writeFileSync(settingsFile(dir), JSON.stringify(value, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PUT /api/settings statusLineTelemetry', () => {
|
||||||
|
let h: RouteTestHarness;
|
||||||
|
let root: string;
|
||||||
|
let claudeDir: string;
|
||||||
|
let shellDir: string;
|
||||||
|
let remoteDir: string;
|
||||||
|
|
||||||
|
const put = (body: unknown) => h.app.inject({ method: 'PUT', url: '/api/settings', payload: body });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createRouteTestHarness(registerSystemRoutes);
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'codeman-statusline-toggle-'));
|
||||||
|
claudeDir = join(root, 'claude-repo');
|
||||||
|
shellDir = join(root, 'shell-repo');
|
||||||
|
remoteDir = join(root, 'remote-attach');
|
||||||
|
for (const dir of [claudeDir, shellDir]) mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const claude = createMockSession('claude-1');
|
||||||
|
claude.workingDir = claudeDir;
|
||||||
|
const shell = createMockSession('shell-1');
|
||||||
|
shell.mode = 'shell';
|
||||||
|
shell.workingDir = shellDir;
|
||||||
|
const remote = createMockSession('remote-1');
|
||||||
|
remote.workingDir = remoteDir;
|
||||||
|
Object.assign(remote, { remote: { host: 'box', session: 'codeman-ssh-remote-1' } });
|
||||||
|
h.ctx.sessions.clear();
|
||||||
|
for (const s of [claude, shell, remote]) h.ctx.sessions.set(s.id, s);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await h.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('true injects the exporter into live Claude workspaces only', async () => {
|
||||||
|
const res = await put({ statusLineTelemetry: true });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(readSettings(claudeDir).statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||||
|
// A shell session has no statusline to export from.
|
||||||
|
expect(existsSync(settingsFile(shellDir))).toBe(false);
|
||||||
|
// A remote attach's workingDir is a pseudo-path: nothing must be created for it.
|
||||||
|
expect(existsSync(remoteDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false removes the exporter it injected', async () => {
|
||||||
|
await put({ statusLineTelemetry: true });
|
||||||
|
expect(readSettings(claudeDir).statusLine).toBeDefined();
|
||||||
|
|
||||||
|
const res = await put({ statusLineTelemetry: false });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(readSettings(claudeDir).statusLine).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false keeps every other key in the workspace settings file', async () => {
|
||||||
|
writeSettings(claudeDir, { permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
|
||||||
|
await put({ statusLineTelemetry: true });
|
||||||
|
await put({ statusLineTelemetry: false });
|
||||||
|
expect(readSettings(claudeDir)).toEqual({ permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false never removes a statusLine the user wrote themselves', async () => {
|
||||||
|
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
|
||||||
|
writeSettings(claudeDir, { statusLine: mine });
|
||||||
|
await put({ statusLineTelemetry: false });
|
||||||
|
expect(readSettings(claudeDir).statusLine).toEqual(mine);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false creates nothing in a workspace that never had the exporter', async () => {
|
||||||
|
await put({ statusLineTelemetry: false });
|
||||||
|
expect(existsSync(settingsFile(claudeDir))).toBe(false);
|
||||||
|
expect(existsSync(remoteDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is an action field, never persisted into settings.json', async () => {
|
||||||
|
await put({ statusLineTelemetry: false, showTokenCount: true });
|
||||||
|
const res = await h.app.inject({ method: 'GET', url: '/api/settings' });
|
||||||
|
const stored = JSON.parse(res.body);
|
||||||
|
const settings = stored.data ?? stored;
|
||||||
|
expect(settings.showTokenCount).toBe(true);
|
||||||
|
expect('statusLineTelemetry' in settings).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* The generated plan-usage statusLine shim and the command that launches it.
|
||||||
|
*
|
||||||
|
* Like the DeepSeek status shim, the shim is emitted as a STRING and executed
|
||||||
|
* by someone else, Claude Code, before every render, so tsc never sees it. The
|
||||||
|
* assertions therefore run the real file in a real `node` process, with a real
|
||||||
|
* temp HOME and a real listener, rather than inspecting the source text. The
|
||||||
|
* injected command is exercised the same way, through `sh -c`, because its
|
||||||
|
* fallback half is the only thing that renders inside a Docker case's
|
||||||
|
* container and a typo there is invisible to every other check.
|
||||||
|
*
|
||||||
|
* The load-bearing property is the pair: the shim must keep forwarding plan
|
||||||
|
* usage to Codeman AND give the user back the statusline it shadows. Losing
|
||||||
|
* either half silently defeats the feature, in one direction by blanking the
|
||||||
|
* header chip and in the other by stealing the terminal footer.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
|
import { createServer, type Server } from 'node:http';
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import {
|
||||||
|
ensureStatusLineShim,
|
||||||
|
LEGACY_STATUSLINE_MARKER,
|
||||||
|
resetStatusLineShimForTest,
|
||||||
|
statusLineShimGuard,
|
||||||
|
statusLineShimPath,
|
||||||
|
STATUSLINE_SHIM_TOKEN,
|
||||||
|
} from '../src/statusline-shim.js';
|
||||||
|
import { generateStatusLineCommand, isCodemanStatusLine } from '../src/hooks-config.js';
|
||||||
|
|
||||||
|
const PORT = 3252;
|
||||||
|
/** A port nothing listens on, for the unreachable-Codeman case. Claimed here so
|
||||||
|
* the repo-wide `const PORT =` search a contributor runs finds it too. */
|
||||||
|
const PORT_DEAD = 3253;
|
||||||
|
|
||||||
|
/** Point a settings file's statusLine at a shell command. */
|
||||||
|
function writeStatusLine(file: string, command: string): void {
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
writeFileSync(file, JSON.stringify({ statusLine: { type: 'command', command } }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('statusLine shim: provisioning', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStatusLineShimForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes an executable shim that node can actually parse', () => {
|
||||||
|
const path = ensureStatusLineShim();
|
||||||
|
expect(path).toBeTruthy();
|
||||||
|
expect(existsSync(path!)).toBe(true);
|
||||||
|
expect(statSync(path!).mode & 0o777).toBe(0o700);
|
||||||
|
// `node --check` on the real file: a template-literal typo in SHIM_SOURCE is
|
||||||
|
// invisible to tsc, because the shim is a string as far as it is concerned.
|
||||||
|
expect(() => execFileSync(process.execPath, ['--check', path!], { stdio: 'pipe' })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes a shim written by an older Codeman, and leaves no temp file behind', () => {
|
||||||
|
const path = statusLineShimPath();
|
||||||
|
ensureStatusLineShim();
|
||||||
|
const current = readFileSync(path, 'utf-8');
|
||||||
|
|
||||||
|
writeFileSync(path, `#!/usr/bin/env node\n// ${STATUSLINE_SHIM_TOKEN} v0\nprocess.exit(0)\n`, { mode: 0o700 });
|
||||||
|
resetStatusLineShimForTest();
|
||||||
|
ensureStatusLineShim();
|
||||||
|
|
||||||
|
expect(readFileSync(path, 'utf-8')).toBe(current);
|
||||||
|
const strays = readdirSync(dirname(path)).filter((f) => f.startsWith(STATUSLINE_SHIM_TOKEN) && f.endsWith('.tmp'));
|
||||||
|
expect(strays).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-asserts the exec bit even when the content already matches', () => {
|
||||||
|
const path = ensureStatusLineShim()!;
|
||||||
|
chmodSync(path, 0o600); // a restored backup / copied data dir
|
||||||
|
resetStatusLineShimForTest();
|
||||||
|
ensureStatusLineShim();
|
||||||
|
expect(statSync(path).mode & 0o777).toBe(0o700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the shim so the guard carries the ownership token', () => {
|
||||||
|
// isCodemanStatusLine decides ownership on this substring. If the file is
|
||||||
|
// ever renamed out from under it, Codeman stops recognising its own entries
|
||||||
|
// and starts treating them as hand-authored.
|
||||||
|
const guard = statusLineShimGuard();
|
||||||
|
expect(guard).toBeTruthy();
|
||||||
|
expect(guard).toContain(STATUSLINE_SHIM_TOKEN);
|
||||||
|
// Absolute node, not a bare `node`: a managed session's PATH need not have one.
|
||||||
|
expect(guard).toContain(process.execPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tests both paths before exec-ing, and quotes them, so a data dir with a space still runs', () => {
|
||||||
|
const node = `'${process.execPath}'`;
|
||||||
|
const shim = `'${statusLineShimPath()}'`;
|
||||||
|
expect(statusLineShimGuard()).toBe(`if [ -x ${node} ] && [ -f ${shim} ]; then exec ${node} ${shim}; fi;`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('statusLine shim: rendering', () => {
|
||||||
|
let shim: string;
|
||||||
|
let server: Server;
|
||||||
|
let received: Array<{ url: string; body: string }> = [];
|
||||||
|
let footer = 'CODEMAN-FOOTER';
|
||||||
|
let workspace: string;
|
||||||
|
let fakeHome: string;
|
||||||
|
|
||||||
|
/** Run the shim the way Claude Code does: a subprocess, JSON on stdin. */
|
||||||
|
function render(
|
||||||
|
payload: object,
|
||||||
|
env: Record<string, string> = {},
|
||||||
|
cwd: string = workspace
|
||||||
|
): Promise<{ stdout: string; code: number | null }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(process.execPath, [shim], {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
|
||||||
|
stdio: ['pipe', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
child.stdout.on('data', (c) => (stdout += c));
|
||||||
|
child.on('close', (code) => resolve({ stdout, code }));
|
||||||
|
child.stdin.end(JSON.stringify(payload));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
resetStatusLineShimForTest();
|
||||||
|
shim = ensureStatusLineShim()!;
|
||||||
|
|
||||||
|
server = createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => (body += c));
|
||||||
|
req.on('end', () => {
|
||||||
|
received.push({ url: req.url ?? '', body });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end(footer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((r) => server.close(() => r()));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
received = [];
|
||||||
|
footer = 'CODEMAN-FOOTER';
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-statusline-'));
|
||||||
|
fakeHome = join(root, 'home');
|
||||||
|
workspace = join(root, 'repo');
|
||||||
|
mkdirSync(join(fakeHome, '.claude'), { recursive: true });
|
||||||
|
mkdirSync(join(workspace, '.claude'), { recursive: true });
|
||||||
|
// The entry Codeman injects into the managed repo. Every case below has it,
|
||||||
|
// because the shim must always skip its own entry while hunting a delegate.
|
||||||
|
writeStatusLine(join(workspace, '.claude', 'settings.local.json'), `'${process.execPath}' '${shim}'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints the global statusline it shadows', async () => {
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||||
|
const { stdout } = await render({ cwd: workspace });
|
||||||
|
expect(stdout).toBe('THE-USERS-LINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands the delegate the same JSON Claude Code sent', async () => {
|
||||||
|
// The delegate is only useful if it sees the payload: every statusline
|
||||||
|
// script reads the model, the cwd or the rate limits off this blob.
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), `bash -c 'read -r j; echo "GOT:$j"'`);
|
||||||
|
const { stdout } = await render({ cwd: workspace, model: { display_name: 'Opus 5' } });
|
||||||
|
expect(stdout).toContain('"display_name":"Opus 5"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never delegates to its own entry, and prints nothing rather than a brand word', async () => {
|
||||||
|
// No other statusLine exists, so the only candidate is the shim's own. If
|
||||||
|
// the loop guard failed this would fork until something ran out. And with
|
||||||
|
// nothing to shadow and no Codeman to ask, the line stays blank: the bare
|
||||||
|
// word `codeman` is the symptom discussion #405 opened with.
|
||||||
|
const { stdout, code } = await render({ cwd: workspace });
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(stdout).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never delegates to the pre-shim inline exporter', async () => {
|
||||||
|
// An upgraded install can still have the old command in a parent settings
|
||||||
|
// file. Running it would double-report and print Codeman's footer anyway.
|
||||||
|
writeStatusLine(
|
||||||
|
join(fakeHome, '.claude', 'settings.json'),
|
||||||
|
`curl -sk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" || echo codeman`
|
||||||
|
);
|
||||||
|
const { stdout } = await render({ cwd: workspace });
|
||||||
|
expect(stdout).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers a project statusline to the global one', async () => {
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
|
||||||
|
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
|
||||||
|
const { stdout } = await render({ cwd: workspace });
|
||||||
|
expect(stdout).toBe('PROJECT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads project settings from the launch directory, not the current one', async () => {
|
||||||
|
// Claude Code applies a project's settings from the directory it was
|
||||||
|
// launched in (workspace.project_dir), which the blob keeps reporting after
|
||||||
|
// the working directory changes mid-session.
|
||||||
|
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
|
||||||
|
const elsewhere = join(dirname(workspace), 'elsewhere');
|
||||||
|
mkdirSync(elsewhere, { recursive: true });
|
||||||
|
const { stdout } = await render(
|
||||||
|
{ cwd: elsewhere, workspace: { current_dir: elsewhere, project_dir: workspace } },
|
||||||
|
{},
|
||||||
|
elsewhere
|
||||||
|
);
|
||||||
|
expect(stdout).toBe('PROJECT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not walk up from the launch directory', async () => {
|
||||||
|
// Claude Code reads project settings from the launch directory alone, so a
|
||||||
|
// .claude in an ancestor is one it would have ignored. Delegating to it
|
||||||
|
// would run a statusline the user never sees otherwise.
|
||||||
|
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo ANCESTOR');
|
||||||
|
const sub = join(workspace, 'packages', 'inner');
|
||||||
|
mkdirSync(sub, { recursive: true });
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
|
||||||
|
const { stdout } = await render({ cwd: sub, workspace: { current_dir: sub, project_dir: sub } }, {}, sub);
|
||||||
|
expect(stdout).toBe('GLOBAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a user-level settings.local.json, which Claude Code does not read', async () => {
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.local.json'), 'echo NOT-A-REAL-FILE');
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
|
||||||
|
const { stdout } = await render({ cwd: workspace });
|
||||||
|
expect(stdout).toBe('GLOBAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards telemetry to Codeman WHILE delegating', async () => {
|
||||||
|
// The whole point: taking the user's line back must not cost the header chip.
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||||
|
const { stdout } = await render(
|
||||||
|
{ cwd: workspace, rate_limits: { five_hour: { used_percentage: 12, resets_at: 99 } } },
|
||||||
|
managed()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stdout).toBe('THE-USERS-LINE');
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
expect(received[0].url).toBe(LEGACY_STATUSLINE_MARKER);
|
||||||
|
const posted = JSON.parse(received[0].body);
|
||||||
|
expect(posted.sessionId).toBe('sess-1');
|
||||||
|
expect(posted.data.rate_limits.five_hour.used_percentage).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints Codeman's own footer when there is no line to shadow", async () => {
|
||||||
|
const { stdout } = await render({ cwd: workspace }, managed());
|
||||||
|
expect(stdout).toBe('CODEMAN-FOOTER');
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the bare brand word from an older server as no telemetry', async () => {
|
||||||
|
// A pre-1.28 route answers `codeman` for a session it does not know. That
|
||||||
|
// word on the statusline is what cost discussion #405 seven repositories
|
||||||
|
// of debugging, so it must never be printed, whichever server answers.
|
||||||
|
footer = 'codeman';
|
||||||
|
const { stdout } = await render({ cwd: workspace }, managed());
|
||||||
|
expect(stdout).toBe('');
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the POST entirely outside a managed session', async () => {
|
||||||
|
// Running `claude` by hand in a managed repo must cost nothing extra.
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||||
|
const { stdout } = await render({ cwd: workspace });
|
||||||
|
expect(stdout).toBe('THE-USERS-LINE');
|
||||||
|
expect(received).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the footer when the delegate fails silently', async () => {
|
||||||
|
// A delegate that exits non-zero with no output must not win over a footer
|
||||||
|
// Codeman can supply.
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'exit 3');
|
||||||
|
const { stdout } = await render({ cwd: workspace }, managed());
|
||||||
|
expect(stdout).toBe('CODEMAN-FOOTER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still prints a failing delegate that produced output', async () => {
|
||||||
|
// Plenty of statusline scripts end on the exit code of their last command.
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo PARTIAL; exit 1');
|
||||||
|
const { stdout } = await render({ cwd: workspace });
|
||||||
|
expect(stdout).toBe('PARTIAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives an unreachable Codeman and a malformed payload', async () => {
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||||
|
const child = spawn(process.execPath, [shim], {
|
||||||
|
cwd: workspace,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: fakeHome,
|
||||||
|
USERPROFILE: fakeHome,
|
||||||
|
CODEMAN_SESSION_ID: 'sess-1',
|
||||||
|
// Nothing listens here.
|
||||||
|
CODEMAN_API_URL: `http://127.0.0.1:${PORT_DEAD}`,
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
child.stdout.on('data', (c) => (stdout += c));
|
||||||
|
child.stdin.end('not json at all');
|
||||||
|
const code = await new Promise<number | null>((r) => child.on('close', r));
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(stdout).toBe('THE-USERS-LINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a malformed settings file instead of dying on it', async () => {
|
||||||
|
writeFileSync(join(workspace, '.claude', 'settings.json'), '{ broken');
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||||
|
const { stdout, code } = await render({ cwd: workspace });
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(stdout).toBe('THE-USERS-LINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a statusLine that is not a command', async () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(fakeHome, '.claude', 'settings.json'),
|
||||||
|
JSON.stringify({ statusLine: { type: 'something-else', command: 'echo NOPE' } })
|
||||||
|
);
|
||||||
|
const { stdout } = await render({ cwd: workspace }, managed());
|
||||||
|
expect(stdout).toBe('CODEMAN-FOOTER');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the injected statusLine command', () => {
|
||||||
|
let server: Server;
|
||||||
|
let received: string[] = [];
|
||||||
|
let fakeHome: string;
|
||||||
|
|
||||||
|
/** Run the command the way Claude Code does: through a shell, JSON on stdin. */
|
||||||
|
function run(command: string, env: Record<string, string>, stdin = '{"model":{"display_name":"Opus"}}') {
|
||||||
|
return new Promise<{ stdout: string; code: number | null }>((resolve) => {
|
||||||
|
const child = spawn('/bin/sh', ['-c', command], {
|
||||||
|
cwd: fakeHome,
|
||||||
|
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
|
||||||
|
stdio: ['pipe', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
child.stdout.on('data', (c) => (stdout += c));
|
||||||
|
child.on('close', (code) => resolve({ stdout, code }));
|
||||||
|
child.stdin.end(stdin);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-2', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
resetStatusLineShimForTest();
|
||||||
|
server = createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => (body += c));
|
||||||
|
req.on('end', () => {
|
||||||
|
received.push(body);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('CODEMAN-FOOTER');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((r) => server.close(() => r()));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
received = [];
|
||||||
|
fakeHome = mkdtempSync(join(tmpdir(), 'codeman-statusline-cmd-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is recognised as ours by both of its halves', () => {
|
||||||
|
const command = generateStatusLineCommand();
|
||||||
|
expect(command.startsWith('if [ -x ')).toBe(true);
|
||||||
|
expect(command).toContain(STATUSLINE_SHIM_TOKEN);
|
||||||
|
expect(command).toContain(LEGACY_STATUSLINE_MARKER);
|
||||||
|
expect(isCodemanStatusLine(command)).toBe(true);
|
||||||
|
// The word the whole change exists to remove.
|
||||||
|
expect(command).not.toContain('echo codeman');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the shim where the shim exists', async () => {
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||||
|
const { stdout } = await run(generateStatusLineCommand(), managed());
|
||||||
|
expect(stdout).toBe('THE-USERS-LINE');
|
||||||
|
expect(JSON.parse(received[0]).sessionId).toBe('sess-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to the inline curl exporter where the shim does not exist', async () => {
|
||||||
|
// Inside a Docker case's container the workspace's settings.local.json is
|
||||||
|
// bind-mounted at the same absolute path, but neither the host's node nor
|
||||||
|
// its data dir is. The same command must still report telemetry there.
|
||||||
|
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
|
||||||
|
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
|
||||||
|
const { stdout } = await run(command, managed());
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
expect(JSON.parse(received[0])).toMatchObject({ sessionId: 'sess-2', data: { model: { display_name: 'Opus' } } });
|
||||||
|
// The inline half cannot delegate, so it prints the footer through.
|
||||||
|
expect(stdout).toBe('CODEMAN-FOOTER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints nothing, not a brand word, when the inline half has no Codeman to reach', async () => {
|
||||||
|
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
|
||||||
|
const { stdout, code } = await run(command, { CODEMAN_API_URL: '', CODEMAN_SESSION_ID: '' });
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(stdout).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -295,6 +295,13 @@ describe('TmuxManager (unit)', () => {
|
|||||||
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
|
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', () => {
|
it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
|
||||||
const original = process.env.CODEMAN_API_URL;
|
const original = process.env.CODEMAN_API_URL;
|
||||||
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';
|
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)');
|
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a brand string when there is no data', () => {
|
it('prints nothing when there is no data, never a brand string', () => {
|
||||||
expect(formatSessionStatusText(null)).toBe('codeman');
|
expect(formatSessionStatusText(null)).toBe('');
|
||||||
|
expect(formatSessionStatusText({})).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user