claude-code-runrate 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,154 @@
1
+ # Changelog
2
+
3
+ Every release also has a [GitHub release](https://github.com/bingh0/ccr/releases)
4
+ and a release commit, and those carry the full notes — what was measured, what
5
+ was rejected, and why. This file is the index: enough to decide whether a
6
+ version is worth upgrading to, and where to read the rest.
7
+
8
+ Dates are npm publish dates, because that is when a version reached anyone.
9
+
10
+ ## 0.6.0 — 2026-09-01
11
+
12
+ The remote-session release. Over mosh, two things had been degrading in
13
+ silence — every clipboard copy was dropped in transit, and 24-bit colour was
14
+ quietly flattened to 256 — and the tmux version floor the sidecar had grown
15
+ was written down nowhere. All three were measured at the terminal, reading
16
+ the raw bytes that reach a mosh client, not inferred.
17
+
18
+ - **The clipboard survives mosh.** tmux's built-in OSC 52 emits an empty
19
+ selection field and mosh's parser accepts only an explicit `c`, so every
20
+ copy vanished between the two. The shipped tmux config now spells the
21
+ selection out — and names `c` explicitly, which is *more* compatible than
22
+ tmux's default everywhere, not a mosh-only workaround.
23
+ - **True colour survives mosh.** mosh forces `TERM=xterm-256color` and drops
24
+ `COLORTERM`, which tmux reads as "no RGB" and silently downgrades every
25
+ 24-bit colour to the nearest 256. The config now declares RGB for that
26
+ TERM, restoring what mosh was carrying fine all along.
27
+ - **The tmux floor is declared: 3.2.** Percentage splits and
28
+ `terminal-features` need 3.1; the pane-scoped hooks that keep the sidebar
29
+ out of copy-mode need 3.2. Below the floor each failure is silent and looks
30
+ like something else, so `ccr doctor` now reports the tmux version and names
31
+ one that is too old — and an unreadable version is reported as unreadable,
32
+ never manufactured into a failure.
33
+ - **The sidebar split speaks tmux 3.4's dialect.** `-p 34` was deprecated in
34
+ 3.1 and rejected outright in 3.4; the launcher sizes with `-l 34%`, which
35
+ every supported tmux accepts.
36
+ - **The Node floor rises to 22.17.** Node 18 and 20 are past their end of
37
+ life, and the vendored test runner's own floor is 22.17 — testing below it
38
+ produced a lane that hung rather than failed. `engines`, the CI matrix,
39
+ `ccr doctor`'s check, and the spec all state the same floor now, and the
40
+ matrix pins 22.17 exactly so the floor is tested, not just declared.
41
+ - **The acceptance suite hardened underneath.** The vendored Gherkin runner
42
+ moved to gherkin-node-test 0.11.0, whose step linter enforces a rule the
43
+ suite now lives by: a negative assertion over a literal needle passes
44
+ forever once the needle rots, so *absence must be earned* — by a control
45
+ proving the needle can still find, or a sanction naming what proves it.
46
+ Every structural refusal in the step layer (the renderer spawns nothing,
47
+ the launcher reads no blob, the sidecar has no input channel) now carries
48
+ one or the other, and both of 0.11's linters gate the suite.
49
+
50
+ ## 0.5.0 — 2026-08-22
51
+
52
+ The Windows release. The things that could not be reasoned about were measured
53
+ on a real Windows 11 machine, because none of them are reachable from CI: every
54
+ Windows test injects the launcher, so the suite proves the argv `ccr` builds and
55
+ nothing about what Windows Terminal does when handed it.
56
+
57
+ - **The panes open where `ccr` was run.** `wt.exe` does not inherit the working
58
+ directory the way `tmux new-session` does, so every pane opened in
59
+ `%USERPROFILE%` while the launch record claimed otherwise — and the sidecar
60
+ prefers that record, so the git pane described the project in full confidence
61
+ for a terminal that was not in it. One source now feeds both.
62
+ - **Measured limits, and they are real.** Past 256 characters `wt` opens *no
63
+ tab at all*; a UNC path opens a tab and silently lands in `%SystemRoot%`; a
64
+ semicolon costs the whole tab; a backtick does not, so refusing it was wrong
65
+ and no longer happens. Each unusable path degrades — the panes open in the
66
+ default directory and stderr names which of the four reasons applied.
67
+ - **The sidecar pane has a cycle key** on Windows Terminal. F3 or Space. F3 has
68
+ three encodings and on Windows which one arrives depends on the *Node*
69
+ version, so `ccr` answers all three rather than pinning a Node floor for a
70
+ keystroke.
71
+ - **A pane config that says what is wrong.** A UTF-8 BOM is stripped; UTF-16 —
72
+ what PowerShell 5.1 writes for `>` and `Out-File` — is named as an encoding
73
+ problem instead of reported as bad JSON; `~\` expands as well as `~/`; and a
74
+ broken config says so on the panel and in `ccr doctor`, which reports pane
75
+ paths as *resolved*.
76
+ - **`ccr sidecar` resolves the instance from the working directory**, which the
77
+ feature files and the README had both described for a while.
78
+ - **The economy screen earns a decimal** where the next tenth is a decision: at
79
+ or past 95% used, one truncated digit, so `floor(shown)` still matches
80
+ `/usage`. A snapshot that stopped refreshing dims rather than blanks.
81
+
82
+ ## 0.4.0 — 2026-08-07
83
+
84
+ [The instance layer and the git pane.](https://github.com/bingh0/ccr/releases/tag/v0.4.0)
85
+
86
+ - **Instances.** Every launch is a named, slotted instance under one container
87
+ home; the name is derived from the project or given with `-i`, and travels
88
+ into the window title, status line and sidebar.
89
+ - **A git pane** on F3: which repository the tab is in, the working tree, and
90
+ recent history in lanes. Read-only — it never writes to the repository.
91
+ - The three launchers (tmux, Windows Terminal, VS Code) take naming, titles and
92
+ retention from one shared path rather than the tmux path owning them.
93
+
94
+ ## 0.3.0 — 2026-08-04
95
+
96
+ [External tool panes in the sidebar.](https://github.com/bingh0/ccr/releases/tag/v0.3.0)
97
+
98
+ - **Panes from other tools.** The sidebar hosts full-height read-only panes
99
+ rendered from small JSON blobs another tool writes beside its own artifacts.
100
+ You list the path in `~/.config/ccr/config.json`; F3 cycles.
101
+ - A rate-limit correctness fix, and `strict` type checking across the codebase.
102
+
103
+ ## 0.2.4 — 2026-07-12
104
+
105
+ [VS Code sidecars stop piling up; profile state-dir fix.](https://github.com/bingh0/ccr/releases/tag/v0.2.4)
106
+
107
+ - One live sidecar per session: relaunching inside VS Code no longer
108
+ accumulates identical panes.
109
+ - Profile sessions write their snapshots to the right state dir.
110
+
111
+ ## 0.2.3 — 2026-07-03
112
+
113
+ [Sidecars agree on 5h/weekly across profiles.](https://github.com/bingh0/ccr/releases/tag/v0.2.3)
114
+
115
+ - The 5h and weekly walls are one account-wide resource, but each profile only
116
+ captures its own; two sidecars on the same account could disagree. They
117
+ reconcile now.
118
+
119
+ ## 0.2.2 — 2026-07-02
120
+
121
+ [Aligned meter bars + a staleness marker.](https://github.com/bingh0/ccr/releases/tag/v0.2.2)
122
+
123
+ - A barely-used window projects an enormous time-to-exhaust, which overflowed
124
+ the fixed time column and shoved that row's bar out of line. Long horizons
125
+ are capped, so the bars stay aligned and the figure reads more honestly.
126
+ - A stale snapshot is annotated (`· updated Nm ago`) rather than silently
127
+ frozen. Never a wipe — Claude Code legitimately stops ticking during a long
128
+ operation.
129
+
130
+ ## 0.2.1 — 2026-06-26
131
+
132
+ [Windows Terminal teardown and sidecar width fixes.](https://github.com/bingh0/ccr/releases/tag/v0.2.1)
133
+
134
+ - ConPTY makes a split pane's reported width unreliable, so the panel
135
+ soft-wrapped. The launcher injects the computed width and the sidecar clamps
136
+ to the smaller of the two.
137
+ - On exit the sidecar pane collapses first and the border sweeps left to right,
138
+ fixing an ordering bug where Claude's pane could close first.
139
+
140
+ ## 0.2.0 — 2026-06-26
141
+
142
+ [Native Windows and VS Code live sidecars.](https://github.com/bingh0/ccr/releases/tag/v0.2.0)
143
+
144
+ - **Native Windows** — the live sidecar hosted in Windows Terminal split panes,
145
+ no tmux, bash or WSL, with a graceful fallback when `wt.exe` is absent.
146
+ - **VS Code integrated terminal on any OS** — split-pane sidecar via a
147
+ clipboard one-liner.
148
+ - `statusLine` injected per launch through a temp settings file, so `~/.claude`
149
+ is never mutated.
150
+
151
+ ## 0.1.0 — 2026-06-24
152
+
153
+ First publish: the CLI and status line — `economy`, `resume`, `statusline` —
154
+ with the live sidebar on tmux. Native Windows arrived in 0.2.0.
package/README.md CHANGED
@@ -69,8 +69,8 @@ On Linux/macOS the default host is `tmux`. No WSL required.
69
69
 
70
70
  ## Requirements
71
71
 
72
- - **Node ≥ 18.3** — that's it for the core. **Zero runtime dependencies.**
73
- - For the optional live `ccr sidecar`: `tmux` on Linux/macOS, or **Windows
72
+ - **Node ≥ 22.17** — that's it for the core. **Zero runtime dependencies.**
73
+ - For the optional live `ccr sidecar`: **`tmux` ≥ 3.2** on Linux/macOS, or **Windows
74
74
  Terminal** / **VS Code's integrated terminal** on Windows (no WSL).
75
75
 
76
76
  ## VS Code (split terminal)
@@ -173,11 +173,11 @@ small JSON blob beside its own artifacts; you list that file's path in ccr's
173
173
  config; the sidebar cycles between the economy panel, the built-in git pane, and
174
174
  each configured pane.
175
175
 
176
- **Cycling views.** Under tmux the launcher binds **F3**. VS Code and its forks
177
- (Cursor, Positron, Antigravity) bind no key and leave both split panes running a
178
- foreground process, so the pasted sidecar one-liner carries `--keys`: click that
179
- pane and press **Space** (or **F3**). The renderer still reads no input — the key
180
- lives in a separate parent process that runs the panel as a child, the same
176
+ **Cycling views.** Under tmux the launcher binds **F3** at the host. **Windows
177
+ Terminal** binds no key of its own, and neither do VS Code and its forks (Cursor,
178
+ Positron, Antigravity) — so on those the sidecar pane carries its own key: click
179
+ the pane and press **Space** or **F3**. The renderer still reads no input — the
180
+ key lives in a separate parent process that runs the panel as a child, the same
181
181
  separation tmux enforces. Anywhere else, `ccr cycle-view -i <name>` (or
182
182
  `--state-dir <dir>`); a bare `ccr cycle-view` resolves the instance from your
183
183
  working directory, like `ccr economy` does.
@@ -191,6 +191,19 @@ read from a repository:
191
191
  { "panes": [ { "path": "/home/you/project/.your-tool/sidecar.json" } ] }
192
192
  ```
193
193
 
194
+ A leading `~` expands, with either separator (`~/tools/blob.json`,
195
+ `~\tools\blob.json`), and a relative path resolves against the config file's own
196
+ directory. **On Windows, save the file as UTF-8** — `>` and `Out-File` in Windows
197
+ PowerShell 5.1 write UTF-16, which is not JSON as far as any parser is concerned:
198
+
199
+ ```powershell
200
+ Set-Content -Encoding utf8 $env:USERPROFILE\.config\ccr\config.json $json
201
+ ```
202
+
203
+ `ccr doctor` reports what it read out of the config — the paths as **resolved**,
204
+ not as written — and names the problem when there is one, so a pane that never
205
+ appears is a question you can answer rather than a silence.
206
+
194
207
  A pane is a full-height view carrying the producing tool's own rows — here
195
208
  `gherkin-trace`, whose blob ships as the golden example:
196
209
 
@@ -253,10 +266,16 @@ method has followed since, and is honest about which point on that line this
253
266
  repository represents.
254
267
 
255
268
  ```bash
256
- npm test # node --test — harness self-tests + feature scenarios
257
- npm run typecheck # tsc --noEmit over @ts-check'd JS (needs: npm i first)
269
+ npm test # node --test — harness self-tests + feature scenarios
270
+ npm run typecheck # tsc --noEmit over @ts-check'd JS (needs: npm i first)
271
+ npm run install-hooks # copy .githooks/ into this clone (run once)
258
272
  ```
259
273
 
274
+ `install-hooks` installs a fail-closed `pre-push` guard for the public remote:
275
+ only `main`, fast-forward only, and a scan of the commits the push would make
276
+ public. It is worth running in any clone you push from — a push is the moment
277
+ history stops being private, and it is the last moment the check can help.
278
+
260
279
  - **No runtime dependencies**, ever — it's what lets `npx claude-code-runrate` install
261
280
  instantly on every OS, including native Windows.
262
281
  - **Dev-only** tooling (`typescript`, `@types/node`) exists solely for
package/bin/ccr.js CHANGED
@@ -323,8 +323,18 @@ function cmdResume(arg) {
323
323
  * @returns {number | undefined}
324
324
  */
325
325
  function cmdSidecar(stateDir, showHint, exitOnEnd, useKeys, view, target = null) {
326
- if (!stateDir && target != null) {
327
- // -i: attach to a live instance by name, through the resolution chain.
326
+ // Resolve whether or not -i was typed, which is what `ccr cycle-view` has
327
+ // always done. Consulting the chain ONLY for -i meant a bare `ccr sidecar`
328
+ // skipped every other link in it — including the one that matters most here,
329
+ // "the live instance whose launch directory contains the cwd" — and landed on
330
+ // the CONTAINER (~/.ccr) instead. The container holds no session, so the pane
331
+ // sat empty next to a running instance, and the fix looked like naming the
332
+ // instance exactly right when the whole point of the chain is not having to.
333
+ //
334
+ // --hint is left out: it prints the VS Code split instructions, which are
335
+ // worth printing before any instance exists, so requiring a live one to
336
+ // explain how to start one would be backwards.
337
+ if (!stateDir && !showHint) {
328
338
  const res = require('../src/instance-resolve').resolveInstance({ target, command: 'sidecar' });
329
339
  if (!res.ok) { process.stderr.write(res.error + '\n'); return 1; }
330
340
  stateDir = res.stateDir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Bing Ho <reps-attic-riot@duck.com>",
@@ -17,7 +17,7 @@
17
17
  "ccr": "bin/ccr.js"
18
18
  },
19
19
  "engines": {
20
- "node": ">=18.3"
20
+ "node": ">=22.17"
21
21
  },
22
22
  "files": [
23
23
  "bin",
@@ -25,6 +25,7 @@
25
25
  "scripts/launch.sh",
26
26
  "sidecar",
27
27
  "README.md",
28
+ "CHANGELOG.md",
28
29
  "LICENSE"
29
30
  ],
30
31
  "scripts": {
@@ -32,7 +33,10 @@
32
33
  "typecheck": "tsc --noEmit -p jsconfig.json",
33
34
  "lint": "oxlint",
34
35
  "release-gate": "node scripts/release-gate.js",
35
- "prepublishOnly": "npm run release-gate && npm run lint && npm run typecheck && npm test"
36
+ "install-hooks": "node scripts/install-hooks.js",
37
+ "probe:wt": "node scripts/probe-wt.js",
38
+ "scan-package": "node scripts/scan-package.js",
39
+ "prepublishOnly": "npm run release-gate && npm run scan-package && npm run lint && npm run typecheck && npm test"
36
40
  },
37
41
  "keywords": [
38
42
  "claude-code",
package/scripts/launch.sh CHANGED
@@ -135,7 +135,14 @@ if [ -n "${CCR_TITLE:-}" ]; then
135
135
  fi
136
136
 
137
137
  # Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
138
- SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
138
+ # The width is `-l <pct>%`, NOT `-p <pct>`. Both spell the same split, but `-p` is the
139
+ # deprecated form and tmux 3.4 — what Ubuntu 24.04 LTS ships, i.e. every stock Ubuntu
140
+ # Server — rejects it with `size missing`. That killed the launcher AFTER new-session
141
+ # had already succeeded, so the failure mode was silent and confusing: a live Claude
142
+ # pane, no sidebar, no error on screen (launch.sh's stderr goes to the terminal it was
143
+ # spawned from, which is gone by then). `-l N%` is accepted by 3.1 through 3.6+ alike
144
+ # and produces an identical split, so there is no version guard to maintain.
145
+ SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -l "${CCR_SIDEBAR_PCT:-34}%" -P -F '#{pane_id}' \
139
146
  "$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
140
147
 
141
148
  # The sidebar is a live dashboard — there is nothing to scroll. A stray mouse-wheel
@@ -22,3 +22,41 @@ set -g set-clipboard on
22
22
 
23
23
  # prefix + P — dump the whole pane scrollback to a file, regardless of length.
24
24
  bind-key P command-prompt -p 'save pane to:' 'capture-pane -S - ; save-buffer %1'
25
+
26
+ # --- Remote sessions: true colour and a clipboard that survives mosh ---------
27
+ #
28
+ # Both lines matter only over a remote transport, and mosh is the case that
29
+ # breaks without them. Measured over mosh -> tmux, reading the raw bytes that
30
+ # reach the client; neither is guesswork.
31
+ #
32
+ # 1. TRUE COLOUR. mosh forces TERM=xterm-256color and DROPS COLORTERM. tmux
33
+ # infers RGB support from COLORTERM, so with it gone tmux silently
34
+ # downgrades 24-bit colour to the nearest 256 (38;2;R;G;B -> 38;5;N). mosh
35
+ # itself carries 24-bit fine; tmux is the one degrading it. Declaring the
36
+ # feature restores it.
37
+ #
38
+ # 2. CLIPBOARD. tmux's built-in OSC 52 emits an EMPTY selection field
39
+ # (`\033]52;;<base64>`), and mosh's OSC 52 parser accepts ONLY an explicit
40
+ # `c`. So every copy is silently dropped in transit. This override spells
41
+ # the selection out. Three details are load-bearing:
42
+ # * `%p1%.0s` CONSUMES the selection parameter while printing nothing.
43
+ # Omitting it leaves p1 unconsumed and the expansion is invalid.
44
+ # * `\007`, never `\7`. tmux 3.x SILENTLY REJECTS a terminal-overrides
45
+ # value containing `\7` — the option is discarded whole with no error,
46
+ # and `tmux show -s terminal-overrides` simply will not list it. Older
47
+ # guides on the web all use `\7`; that spelling was fine on tmux 2.x.
48
+ # * terminal-overrides keys on the client's TERM, so this matches when
49
+ # TERM is xterm-256color — which is exactly what mosh sets.
50
+ # Emitting an explicit `c` is also strictly more compatible than tmux's
51
+ # default empty field, so this is not a mosh-only workaround.
52
+ #
53
+ # Both require tmux 3.1+; ccr asks for 3.2+ overall (see `ccr doctor`).
54
+ #
55
+ # RGB is declared unconditionally. 24-bit has been table stakes for years —
56
+ # iTerm2, Ghostty, WezTerm, kitty, Alacritty, Konsole, every libvte terminal
57
+ # (GNOME Terminal et al), Windows Terminal and PuTTY all handle it. The lone
58
+ # holdout that reports TERM=xterm-256color without it is macOS Terminal.app
59
+ # before macOS 26, which degrades rather than breaking. Optimising the default
60
+ # for that shrinking case would cost every other user their colours.
61
+ set -as terminal-features ',xterm-256color:RGB'
62
+ set -as terminal-overrides ",xterm-256color:Ms=\E]52;c%p1%.0s;%p2%s\007"
package/src/doctor.js CHANGED
@@ -9,6 +9,7 @@ const path = require('node:path');
9
9
  const os = require('node:os');
10
10
  const { spawnSync } = require('node:child_process');
11
11
  const { stripControl } = require('./sanitize');
12
+ const { loadPaneConfig } = require('./pane-config');
12
13
 
13
14
  const ok = (/** @type {string} */ s) => `\x1b[32m✓\x1b[0m ${s}`;
14
15
  const bad = (/** @type {string} */ s) => `\x1b[31m✗\x1b[0m ${s}`;
@@ -34,15 +35,36 @@ function isExec(/** @type {string} */ f) {
34
35
  try { return (fs.statSync(f).mode & 0o111) !== 0; } catch { return false; }
35
36
  }
36
37
 
38
+ // ccr's tmux dialect has a floor. 3.1 is HARD: percentage splits
39
+ // (`split-window -l 34%`, since `-p` was deprecated there and 3.4+ rejects it)
40
+ // and `terminal-features`, which sidecar/ccr.tmux.conf needs for true colour
41
+ // over mosh. 3.2 adds pane-scoped hooks, which the launcher uses to keep the
42
+ // sidebar out of copy-mode; below that it degrades quietly rather than
43
+ // breaking, so 3.2 is what we ask for and 3.1 is what we tolerate.
44
+ const TMUX_MIN = { major: 3, minor: 2 };
45
+ /** @param {string} bin @returns {{major:number,minor:number}|null} */
46
+ function tmuxVersion(bin) {
47
+ try {
48
+ const r = spawnSync(bin, ['-V'], { encoding: 'utf8' });
49
+ if (r.status !== 0) return null;
50
+ // "tmux 3.7c", "tmux 3.2a", "tmux next-3.6" — only the numbers matter.
51
+ const m = /(\d+)\.(\d+)/.exec(r.stdout || '');
52
+ return m ? { major: Number(m[1]), minor: Number(m[2]) } : null;
53
+ } catch { return null; }
54
+ }
55
+
37
56
  /**
38
57
  * @param {{ platform?: string, has?: (cmd: string) => (string|null),
39
- * homedir?: string, repo?: string, write?: (s: string) => void }} [opts]
58
+ * tmuxVersion?: (bin: string) => ({major:number,minor:number}|null),
59
+ * homedir?: string, repo?: string, write?: (s: string) => void,
60
+ * env?: Record<string, string|undefined> }} [opts]
40
61
  * side effects are injectable for testing; defaults hit the real environment
41
62
  * @returns {number} exit code (0 = healthy)
42
63
  */
43
64
  function run(opts = {}) {
44
65
  const platform = opts.platform || process.platform;
45
66
  const hasFn = opts.has || has;
67
+ const tmuxVerFn = opts.tmuxVersion || tmuxVersion;
46
68
  const homedir = opts.homedir || os.homedir();
47
69
  const REPO = opts.repo || path.join(__dirname, '..');
48
70
  const write = opts.write || ((s) => { process.stdout.write(s); });
@@ -51,8 +73,8 @@ function run(opts = {}) {
51
73
  let problems = 0;
52
74
 
53
75
  const [maj, min] = process.versions.node.split('.').map(Number);
54
- const nodeOk = maj > 18 || (maj === 18 && min >= 3);
55
- out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 18.3`));
76
+ const nodeOk = maj > 22 || (maj === 22 && min >= 17);
77
+ out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 22.17`));
56
78
  if (!nodeOk) problems++;
57
79
 
58
80
  const ccr = hasFn('ccr');
@@ -72,8 +94,24 @@ function run(opts = {}) {
72
94
  // temp settings file, so there's no shipped shim asset to check on Windows.
73
95
  } else {
74
96
  const tmux = hasFn('tmux');
75
- out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for the `ccr [profile]` sidebar'));
76
- if (!tmux) problems++;
97
+ if (!tmux) {
98
+ out.push(warn('tmux missing — needed for the `ccr [profile]` sidebar'));
99
+ problems++;
100
+ } else {
101
+ const v = tmuxVerFn(tmux);
102
+ // An unreadable version is not evidence of a problem — say so and move on
103
+ // rather than manufacturing a failure out of a parse miss.
104
+ if (!v) {
105
+ out.push(ok(`tmux (${stripControl(tmux)})`) + dim(' — version unreadable'));
106
+ } else if (v.major < TMUX_MIN.major
107
+ || (v.major === TMUX_MIN.major && v.minor < TMUX_MIN.minor)) {
108
+ out.push(warn(`tmux ${v.major}.${v.minor} (${stripControl(tmux)}) — ccr wants `
109
+ + `${TMUX_MIN.major}.${TMUX_MIN.minor}+; below 3.1 the sidebar split fails outright`));
110
+ problems++;
111
+ } else {
112
+ out.push(ok(`tmux ${v.major}.${v.minor} (${stripControl(tmux)})`));
113
+ }
114
+ }
77
115
  out.push(hasFn('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
78
116
 
79
117
  const sl = path.join(REPO, 'sidecar', 'ccr-statusline');
@@ -91,6 +129,24 @@ function run(opts = {}) {
91
129
  out.push(dim('· ccs not installed (optional — only for `ccr <profile>`)'));
92
130
  }
93
131
 
132
+ // Pane wiring. This command exists to diagnose "nothing happens", and a pane
133
+ // config the user wrote and got wrong is exactly that — the sidecar has room
134
+ // for a one-line marker and no more. Here there is room for the path, the
135
+ // reason, and what ccr actually read out of the file, which is the question
136
+ // someone whose pane never appeared is really asking.
137
+ const cfg = loadPaneConfig({ env: opts.env, home: homedir });
138
+ if (cfg.error) {
139
+ out.push(bad(`pane config: ${cfg.error} — ${stripControl(cfg.configPath)}`));
140
+ problems++;
141
+ } else if (cfg.panes.length) {
142
+ out.push(ok(`pane config: ${cfg.panes.length} pane(s) (${stripControl(cfg.configPath)})`));
143
+ // The path as ccr resolved it, not as written: a tilde that did not expand
144
+ // is invisible in the source string and obvious in the resolved one.
145
+ for (const pane of cfg.panes) out.push(dim(` · ${stripControl(pane.path)}`));
146
+ } else {
147
+ out.push(dim(`· no panes configured (optional — ${stripControl(cfg.configPath)})`));
148
+ }
149
+
94
150
  // newest captured snapshot across the container. Instances live TWO levels
95
151
  // down under the 0.4.0 layout (~/.ccr/instances/<n>/last-status.json) — the
96
152
  // one-level scan alone would report "no status captured" while instances run
package/src/launch-win.js CHANGED
@@ -39,6 +39,109 @@ function isWtArgSafe(value) {
39
39
  return !WT_UNSAFE_RE.test(String(value));
40
40
  }
41
41
 
42
+ // The starting directory is a DIFFERENT problem from the values above, and
43
+ // reusing WT_UNSAFE_RE for it would be a bug in both directions.
44
+ //
45
+ // `%` is LEGAL in a Windows path (C:\100%done) and harmless here: -d's value
46
+ // is its own argv token handed to wt, never interpolated into a cmd /c
47
+ // payload, so cmd's variable expansion never sees it. WT_UNSAFE_RE rejects
48
+ // `%`, so reusing it would refuse a perfectly valid directory.
49
+ //
50
+ // `;` is legal in a Windows path AND is wt's own command separator — wt
51
+ // re-parses its command line, and Node only quotes an argv token that
52
+ // contains whitespace, so `C:\my;dir` would arrive unquoted and split the
53
+ // command. WT_UNSAFE_RE does NOT reject `;`, so it misses the one character
54
+ // that actually matters here.
55
+ //
56
+ // A directory that fails this does not fail the launch: run() drops -d and
57
+ // says where the panes landed (features/windows-launcher.feature, "A launch
58
+ // directory Windows Terminal cannot be given still launches"). Refusing to
59
+ // start over a legal directory name would trade one broken launch for another.
60
+ //
61
+ // MEASURED on Windows 11 (10.0.26200) with scripts/probe-wt.js, because none of
62
+ // this was knowable from the documentation and none of it is covered by CI —
63
+ // every test injects spawnWt, so the suite proves the argv and nothing about
64
+ // what Windows Terminal does with it.
65
+ const WT_PATH_UNSAFE_RE = /[";\r\n]/;
66
+
67
+ // The backtick used to be in that class and is NOT: a path containing one was
68
+ // passed through and the tab landed exactly where it was asked to. wt is not
69
+ // PowerShell. Refusing it cost a launch that would have worked.
70
+ //
71
+ // The semicolon earns its place — wt splits its own command line on it, and a
72
+ // path containing one opens NO TAB AT ALL.
73
+
74
+ // Longest path wt will accept as `-d`. Measured: 256 characters opens a tab,
75
+ // 259 opens none — so the true limit is 257 or 258, and 256 is the longest
76
+ // length known to work. Two characters of headroom is not worth another round
77
+ // trip on someone else's machine.
78
+ //
79
+ // This is NOT the filesystem's limit. The machine that measured it has long
80
+ // paths enabled: a 299-character directory was created successfully and then
81
+ // refused a tab. The refusal is Windows Terminal's.
82
+ const WT_PATH_MAX = 256;
83
+
84
+ // Naming the character rather than echoing it. Two reasons: a raw CR or LF
85
+ // interpolated into a diagnostic would break the diagnostic, and "a semicolon"
86
+ // is what the reader needs anyway — they are looking at the path already.
87
+ /** @type {Record<string, string>} */
88
+ const WT_PATH_CHAR_NAMES = {
89
+ '"': 'a double quote',
90
+ ';': 'a semicolon',
91
+ '\r': 'a carriage return',
92
+ '\n': 'a newline',
93
+ };
94
+
95
+ /**
96
+ * Why `dir` cannot be given to wt as `-d`, or null when it can.
97
+ *
98
+ * Every branch here degrades rather than refuses to launch: the panes open in
99
+ * Windows Terminal's default directory and stderr says so. The point of the
100
+ * REASON is that "we would not pass this one" is a different sentence for each
101
+ * cause, and the previous single message named characters the path did not
102
+ * contain.
103
+ *
104
+ * @param {string|null|undefined} dir
105
+ * @returns {string|null} a clause completing "the path …", or null if usable
106
+ */
107
+ function wtPathProblem(dir) {
108
+ if (typeof dir !== 'string' || dir.length === 0) return 'is not a directory ccr could read';
109
+ // UNC is the dangerous one, and the only case here that is SILENT. The tab
110
+ // OPENS — cmd.exe simply refuses a UNC working directory and starts in
111
+ // %SystemRoot% without a word. Measured: \\localhost\c$\Users reported
112
+ // C:\Windows. Left unhandled, the git pane would describe the project in
113
+ // full confidence for a terminal sitting in C:\Windows.
114
+ if (/^[\\/]{2}/.test(dir)) return 'is a UNC path — cmd.exe refuses those and starts in %SystemRoot% without saying so';
115
+ if (dir.length > WT_PATH_MAX) return `is ${dir.length} characters — Windows Terminal opens no tab at all past ${WT_PATH_MAX}`;
116
+ const m = WT_PATH_UNSAFE_RE.exec(dir);
117
+ if (m) return `contains ${WT_PATH_CHAR_NAMES[m[0]] || 'a character'} — Windows Terminal parses it and opens no tab at all`;
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * What the user could DO about it, when there is anything.
123
+ *
124
+ * Only the UNC case has an answer: cmd.exe will take a mapped drive letter
125
+ * where it refuses the UNC form, so the directory is reachable — just not by
126
+ * that name. The other causes are the path they have, and telling someone to
127
+ * rename their project or shorten it below 256 characters is not advice.
128
+ *
129
+ * @param {string|null|undefined} dir
130
+ * @returns {string|null}
131
+ */
132
+ function wtPathHint(dir) {
133
+ if (typeof dir !== 'string' || !/^[\\/]{2}/.test(dir)) return null;
134
+ return 'map the share to a drive letter (net use Z: \\\\server\\share) and run ccr from there';
135
+ }
136
+
137
+ /**
138
+ * @param {string|null|undefined} dir
139
+ * @returns {boolean} true if `dir` can be passed to wt.exe as `-d <dir>`
140
+ */
141
+ function isWtPathSafe(dir) {
142
+ return wtPathProblem(dir) === null;
143
+ }
144
+
42
145
  // Upstream default split: the sidecar gets ~34% of the width.
43
146
  const DEFAULT_SIDEBAR_PCT = 34;
44
147
 
@@ -181,9 +284,16 @@ function sidecarCols(termCols, fracNum, splitFlag) {
181
284
  * the case of %, hijack) the cmd /c payload — see isWtArgSafe. run() catches
182
285
  * this and reports a clean error instead of spawning a broken command.
183
286
  *
287
+ * `cwd` is the directory BOTH panes start in. Windows Terminal does not
288
+ * inherit it the way `tmux new-session` does: without `-d` a pane opens in the
289
+ * WT profile's own `startingDirectory`, which defaults to %USERPROFILE% — so
290
+ * omitting it silently moved Claude Code out of the user's project. Absent or
291
+ * unpassable (see isWtPathSafe), `-d` is left off entirely and the panes fall
292
+ * back to that profile default; run() is what tells the user.
293
+ *
184
294
  * @param {{ ccCmd: string, settingsFile: string, stateDir: string,
185
295
  * node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
186
- * termCols?: number, title?: string }} o
296
+ * termCols?: number, title?: string, cwd?: string|null }} o
187
297
  * @returns {string[]}
188
298
  */
189
299
  function buildWtArgs(o) {
@@ -227,13 +337,17 @@ function buildWtArgs(o) {
227
337
  const cols = sidecarCols(o.termCols, Number(frac), splitFlag);
228
338
  const sidecarBody =
229
339
  (cols != null ? `set "CCR_SIDECAR_COLS=${cols}"&& ` : '') +
230
- `"${node}" "${ccrJs}" sidecar --exit-on-end`;
340
+ `"${node}" "${ccrJs}" sidecar --exit-on-end --keys`;
231
341
  const pane1 = paneCommand(stateDir, sidecarBody);
232
342
 
343
+ // Both panes get the same starting directory, or neither does. `-d` sits
344
+ // with the other options, before each pane's `cmd` payload.
345
+ const startIn = isWtPathSafe(o.cwd) ? ['-d', String(o.cwd)] : [];
346
+
233
347
  return [
234
- '-w', '0', 'new-tab', '--title', title, 'cmd', '/c', pane0,
348
+ '-w', '0', 'new-tab', '--title', title, ...startIn, 'cmd', '/c', pane0,
235
349
  ';',
236
- 'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
350
+ 'split-pane', splitFlag, '-s', frac, ...startIn, 'cmd', '/c', pane1,
237
351
  ];
238
352
  }
239
353
 
@@ -263,7 +377,7 @@ function defaultWhere(name) {
263
377
 
264
378
  const inject = require('./settings-inject');
265
379
  const slots = require('./instance-slot');
266
- const { ensureSecureDir, recordLaunchDir } = require('./state-dir');
380
+ const { ensureSecureDir, recordLaunchDir, clearLaunchDir } = require('./state-dir');
267
381
 
268
382
  /**
269
383
  * Fill in real-environment implementations for anything the caller didn't
@@ -282,6 +396,15 @@ function withDefaults(deps) {
282
396
  // The launcher's own stdout reports the live terminal width — the sidecar's
283
397
  // does not, inside its cmd /c pane (see sidecarCols). undefined on non-TTY.
284
398
  cols: deps.cols != null ? deps.cols : process.stdout.columns,
399
+ // The launch directory, injected like every other external effect. It is
400
+ // the INPUT to that decision, not the answer: run() narrows it to paneCwd —
401
+ // the directory wt will actually be given — and both the record and `-d`
402
+ // read that, so the pane and the record cannot disagree about where this
403
+ // session is. This comment claimed the invariant before the code held it.
404
+ // process.cwd() throws only if the cwd has been deleted.
405
+ cwd: deps.cwd != null ? deps.cwd : (() => {
406
+ try { return process.cwd(); } catch { return null; }
407
+ })(),
285
408
  node: deps.node || process.execPath,
286
409
  ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
287
410
  out: deps.out || ((s) => { process.stdout.write(s); }),
@@ -292,6 +415,7 @@ function withDefaults(deps) {
292
415
  listDir: deps.listDir || defaultListDir,
293
416
  ensureDir: deps.ensureDir || ensureSecureDir,
294
417
  recordLaunchDir: deps.recordLaunchDir || recordLaunchDir,
418
+ clearLaunchDir: deps.clearLaunchDir || clearLaunchDir,
295
419
  allocateSlot: deps.allocateSlot || ((o) => slots.allocateSlot(o)),
296
420
  prepareInstance: deps.prepareInstance
297
421
  || ((/** @type {{slot:number,stateDir:string}} */ s, /** @type {any} */ o) =>
@@ -409,11 +533,39 @@ function run(profile, deps = {}, opts = {}) {
409
533
 
410
534
  // 4. Prepare the per-profile state dir; clear a stale sentinel.
411
535
  try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
412
- // The tab's stable identity for the git pane. Recorded here because only the
413
- // launcher knows where ccr was started (src/state-dir.js).
414
- try { d.recordLaunchDir(st.stateDir, process.cwd()); } catch { /* best effort */ }
536
+ // ONE source for both consumers: the directory the panes will ACTUALLY start
537
+ // in. Everything below reads paneCwd the record that gives the git pane its
538
+ // identity, and wt's `-d` so the two cannot describe different directories.
539
+ //
540
+ // Recording d.cwd unconditionally is what CREATED the divergence rather than
541
+ // merely failing to prevent it: src/sidecar.js launchDir() PREFERS the record
542
+ // over the pane's own cwd. readGitRepo self-heals after Claude's first status
543
+ // tick, but before that tick — the state every tab starts in — the pane draws
544
+ // the project's repo, branch, tree and history in full confidence for a
545
+ // session sitting somewhere else entirely.
546
+ const paneCwd = isWtPathSafe(d.cwd) ? d.cwd : null;
547
+ if (paneCwd) {
548
+ try { d.recordLaunchDir(st.stateDir, paneCwd); } catch { /* best effort */ }
549
+ } else {
550
+ // Clearing, not skipping. Slots are reused, so the record from whatever ran
551
+ // in this slot before would otherwise win (src/state-dir.js clearLaunchDir).
552
+ try { d.clearLaunchDir(st.stateDir); } catch { /* best effort */ }
553
+ }
415
554
  d.removeExited(st.stateDir);
416
555
 
556
+ // The panes must open where `ccr` was run. wt does not inherit that, so it
557
+ // is passed explicitly below; when the directory cannot be passed the launch
558
+ // still goes ahead, and saying so is the whole difference between a surprise
559
+ // and a known limitation.
560
+ const pathProblem = d.cwd ? wtPathProblem(d.cwd) : null;
561
+ if (pathProblem) {
562
+ d.err(`ccr: cannot open the panes in ${d.cwd}\n`);
563
+ d.err(` the path ${pathProblem}\n`);
564
+ d.err(" — they will open in Windows Terminal's default directory instead.\n");
565
+ const hint = wtPathHint(d.cwd);
566
+ if (hint) d.err(` ${hint}.\n`);
567
+ }
568
+
417
569
  // 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
418
570
  const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
419
571
  const settingsFile = d.writeSettings(inject.buildSettings(command));
@@ -432,6 +584,7 @@ function run(profile, deps = {}, opts = {}) {
432
584
  sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
433
585
  termCols: d.cols,
434
586
  title: inst ? inst.title : undefined,
587
+ cwd: paneCwd,
435
588
  });
436
589
  } catch (e) {
437
590
  d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
@@ -453,6 +606,8 @@ function run(profile, deps = {}, opts = {}) {
453
606
  * @property {NodeJS.ProcessEnv} env
454
607
  * @property {string} home
455
608
  * @property {number|undefined} cols
609
+ * @property {string|null} cwd the directory `ccr` was run in — recorded as the
610
+ * tab's launch dir AND passed to wt as the panes' starting directory
456
611
  * @property {string} node
457
612
  * @property {string} ccrJs
458
613
  * @property {(s: string) => void} out
@@ -463,6 +618,7 @@ function run(profile, deps = {}, opts = {}) {
463
618
  * @property {(dir: string) => string[]} listDir
464
619
  * @property {(dir: string) => void} ensureDir
465
620
  * @property {(dir: string, cwd: string) => void} recordLaunchDir
621
+ * @property {(dir: string) => void} clearLaunchDir
466
622
  * @property {(o: {profile?: string, env: NodeJS.ProcessEnv, home: string}) => ({slot: number, session: string, stateDir: string, attached: boolean}|{exhausted: true}|null)} allocateSlot
467
623
  * @property {(slot: {slot: number, stateDir: string}, o: {profile?: string, name?: string|null}) => {name: string, title: string}} prepareInstance
468
624
  * @property {(dir: string) => void} removeExited
@@ -477,6 +633,10 @@ module.exports = {
477
633
  DEFAULT_SIDEBAR_SIDE,
478
634
  validateProfile,
479
635
  isWtArgSafe,
636
+ isWtPathSafe,
637
+ wtPathProblem,
638
+ wtPathHint,
639
+ WT_PATH_MAX,
480
640
  resolveProfileState,
481
641
  sidebarFraction,
482
642
  sidebarSplitFlag,
@@ -64,8 +64,14 @@ function configPath(env) {
64
64
  */
65
65
  function resolvePanePath(p, baseDir, home) {
66
66
  let out = p;
67
+ // `~\` as well as `~/`: a Windows user writes the separator their shell shows
68
+ // them, and accepting only the forward slash left `~\tools\blob.json` to
69
+ // resolve against the config directory — a path that cannot exist, whose only
70
+ // symptom is a pane that never appears. The tilde means home on the machine
71
+ // the config was written for; the separator it is followed by does not
72
+ // change that.
67
73
  if (out === '~') out = home;
68
- else if (out.startsWith('~/')) out = path.join(home, out.slice(2));
74
+ else if (out.startsWith('~/') || out.startsWith('~\\')) out = path.join(home, out.slice(2));
69
75
  return path.resolve(baseDir, out);
70
76
  }
71
77
 
@@ -75,22 +81,50 @@ function resolvePanePath(p, baseDir, home) {
75
81
  * economy sidebar exactly as before this feature existed.
76
82
  *
77
83
  * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
78
- * @returns {{ panes: Array<{ path: string, source: string }>, configPath: string }}
84
+ * @returns {{ panes: Array<{ path: string, source: string }>, configPath: string,
85
+ * error: string|null }}
79
86
  * `path` is absolute and ready to read; `source` is the string the user wrote
80
87
  * (what error states name, so the message matches their config, not ours).
88
+ * `error` names why a config that EXISTS produced no panes. A config that is
89
+ * simply absent is not an error — that is most users — but one the user wrote
90
+ * and got wrong must say so somewhere, or the only symptom of a typo is panes
91
+ * that never appear.
81
92
  */
82
93
  function loadPaneConfig(opts = {}) {
83
94
  const env = opts.env || process.env;
84
95
  const home = opts.home || os.homedir();
85
96
  const file = configPath(env);
86
- const empty = { panes: [], configPath: file };
97
+ const empty = { panes: [], configPath: file, error: null };
87
98
 
88
99
  const raw = readTextCapped(file, MAX_CONFIG_BYTES);
89
100
  if (raw == null || !raw.trim()) return empty;
90
101
 
102
+ // A UTF-8 byte-order mark makes JSON.parse throw. PowerShell writes one by
103
+ // default — `Set-Content`, `Out-File`, and `>` under Windows PowerShell all
104
+ // do — so a config written the obvious way on Windows is malformed on
105
+ // arrival, and the only symptom is panes that never appear. Strip it rather
106
+ // than diagnose it later: there is no config for which a leading BOM is
107
+ // content. (`.trim()` above already treats a BOM-only file as empty; U+FEFF
108
+ // is whitespace to the trimmer but not to the parser.)
109
+ const text = raw.replace(/^\uFEFF/, '');
110
+
111
+ // UTF-16 is the likelier Windows mistake, and it is NOT what the strip above
112
+ // catches. Windows PowerShell 5.1 writes UTF-16LE for `>` and `Out-File` by
113
+ // default (Set-Content writes ANSI; only `-Encoding utf8` gives the UTF-8 BOM;
114
+ // PowerShell 7+ writes UTF-8 without one). Read as UTF-8 those bytes survive
115
+ // trim() and reach the parser as a leading U+FFFD pair and NUL-interleaved
116
+ // text, so the parse fails and the honest-looking report is "not valid JSON"
117
+ // — sending someone to hunt for a syntax error in a file whose syntax is
118
+ // fine. A NUL is never content in a JSON config, so it names the real cause.
119
+ if (text.includes('\u0000')) {
120
+ return { ...empty, error: 'looks like UTF-16 — save it as UTF-8' };
121
+ }
122
+
91
123
  let parsed;
92
- try { parsed = JSON.parse(raw); } catch { return empty; }
93
- if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.panes)) return empty;
124
+ try { parsed = JSON.parse(text); } catch { return { ...empty, error: 'not valid JSON' }; }
125
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.panes)) {
126
+ return { ...empty, error: 'no panes array' };
127
+ }
94
128
 
95
129
  const baseDir = path.dirname(file);
96
130
  /** @type {Array<{ path: string, source: string }>} */
@@ -103,7 +137,7 @@ function loadPaneConfig(opts = {}) {
103
137
  if (!source.trim()) continue;
104
138
  panes.push({ path: resolvePanePath(source, baseDir, home), source });
105
139
  }
106
- return { panes, configPath: file };
140
+ return { panes, configPath: file, error: null };
107
141
  }
108
142
 
109
143
  module.exports = { loadPaneConfig, configPath, resolvePanePath, MAX_CONFIG_BYTES };
@@ -10,16 +10,23 @@
10
10
  const { clearROI } = require('../burn');
11
11
  const { classifyWindows, band } = require('../economy-model');
12
12
  const { resolveTheme, lexicon } = require('../theme');
13
- const { dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins, fmtReset } = require('./shared');
13
+ const { dim, bold, green, red, yellow, cyan, flash, pctColor, CRIT_PCT, usedLabel, bar, tok, fmtMins, fmtReset } = require('./shared');
14
+ const { liveness } = require('../liveness');
14
15
 
15
16
  const bandColor = { imminent: red, warn: yellow, ok: cyan };
16
17
 
17
- function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolean} */ tick, /** @type {number} */ labelW) {
18
+ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolean} */ tick, /** @type {number} */ labelW, /** @type {boolean} */ stale, /** @type {number} */ numW) {
18
19
  // Truncate, don't round: Claude's own surfaces (`/usage`, claude.ai usage)
19
20
  // floor the fractional `used_percentage` (e.g. 41.6 → "41%"). Math.round here
20
21
  // read ~1pt high on values past the half-point. Display only — the burn/ROI
21
22
  // math below still uses the raw fractional `row.est.usedPct`.
23
+ //
24
+ // `used` (whole %) drives the bar fill and the colour band; `usedTxt` is the
25
+ // number actually shown, which gains one truncated decimal in the critical
26
+ // zone. Both floor, so they can never disagree about which side of a whole
27
+ // number the reading falls on.
22
28
  const used = Math.floor(row.est.usedPct);
29
+ const usedTxt = usedLabel(row.est.usedPct);
23
30
  const ml = row.est.minutesLeft;
24
31
  const b = band(ml);
25
32
  // Per-row colour dot: green when the window resets before you'd hit it,
@@ -37,7 +44,11 @@ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolea
37
44
  const leftTxt = (ml != null ? '~' + fmtMins(ml) : '—').padEnd(7);
38
45
  const left = row.binding ? bold(leftTxt) : dim(leftTxt);
39
46
  const resets = row.reset != null ? dim('resets ' + fmtReset(row.reset)) : '';
40
- const meter = pctColor(used)(bar(used)) + ' ' + String(used).padStart(2) + '% used';
47
+ // A stale snapshot dims the figure so a number frozen between chat rounds
48
+ // never reads as live. The bar colour stays, so the band still pops, and the
49
+ // sidecar appends the "updated …" note that says why (see src/liveness.js).
50
+ const numTxt = usedTxt.padStart(numW) + '% used';
51
+ const meter = pctColor(used)(bar(used)) + ' ' + (stale ? dim(numTxt) : numTxt);
41
52
  const main = ' ' + dot + ' ' + label + ' ' + left + ' ' + meter + ' ' + resets;
42
53
 
43
54
  // The binding window's "wall" call-out drops to its own indented line below —
@@ -51,16 +62,30 @@ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolea
51
62
 
52
63
  /**
53
64
  * @param {any} view normalized economy data
54
- * @param {{ theme?: 'plain'|'mary', now?: Date, tick?: boolean, env?: any }} [opts]
65
+ * @param {{ theme?: 'plain'|'mary', now?: Date, tick?: boolean, env?: any,
66
+ * ageMs?: number, staleMs?: number }} [opts]
67
+ * ageMs/staleMs: how old the captured snapshot is, and the threshold past
68
+ * which it counts as stale. The snapshot only refreshes per chat round, so
69
+ * between rounds it ages — past the threshold the used figures dim, so they
70
+ * read as last-known rather than live. The threshold decision itself stays
71
+ * in src/liveness.js, which also owns the "updated …" note the sidecar
72
+ * appends; this renderer only asks whether the data is stale.
55
73
  * @returns {string}
56
74
  */
57
75
  function renderEconomy(view, opts = {}) {
58
76
  const themeName = opts.theme || resolveTheme(opts.now, opts.env);
59
77
  const L = lexicon(themeName);
60
78
  const tick = !!opts.tick;
79
+ const stale = !!liveness({ ageMs: opts.ageMs ?? 0, staleMs: opts.staleMs }).marker;
61
80
  const out = [bold('economy') + dim(' ' + (view.model || '')), ''];
62
81
 
63
82
  const { rows, next } = classifyWindows(view);
83
+ // The used% column widens to fit a decimal only when some row actually has
84
+ // one. Widening it unconditionally would shift every meter two columns right
85
+ // for the whole time you are nowhere near the wall — which both costs a
86
+ // narrow sidebar two columns it needs and blunts the point of the decimal,
87
+ // whose appearing is itself the "you are in the zone" cue.
88
+ const numW = rows.some((/** @type {any} */ r) => r.est.usedPct >= CRIT_PCT && r.est.usedPct < 100) ? 4 : 2;
64
89
  // Cap the label column. `labelW` multiplies: every row pads to it, so cost is
65
90
  // rows × longest-label, and BOTH come from the snapshot's rate_limits keys. A
66
91
  // planted file with many buckets and one very long key built a string large
@@ -86,7 +111,7 @@ function renderEconomy(view, opts = {}) {
86
111
  }
87
112
  out.push('');
88
113
 
89
- for (const r of rows) out.push(wallRow(r, L, tick, labelW));
114
+ for (const r of rows) out.push(wallRow(r, L, tick, labelW, stale, numW));
90
115
  if (rows.length) out.push('');
91
116
 
92
117
  // CLEAR — plain language, framed against the binding wall, only when it looms.
@@ -16,6 +16,26 @@ const flash = (/** @type {boolean} */ tick, /** @type {string} */ s) => (tick ?
16
16
 
17
17
  const pctColor = (/** @type {number} */ p) => (p >= 75 ? red : p >= 60 ? yellow : green);
18
18
 
19
+ // At/above this used%, surface one extra digit of Claude's own (fractional)
20
+ // `used_percentage`. Near the wall the next tenth is a decision input; below it
21
+ // it is noise. A decimal appearing IS the "you are in the zone" salience signal.
22
+ const CRIT_PCT = 95;
23
+
24
+ /**
25
+ * Used% display string. Below the critical zone: the whole number. In the zone
26
+ * (and under 100): one TRUNCATED decimal — the same downward direction as the
27
+ * integer floor, so `floor(shown)` still equals what `/usage` reports. We never
28
+ * tick above Claude's own number, only out-resolve it. The +1e-9 guards float
29
+ * representation: 98.7 * 10 is 986.9999… and would otherwise truncate to 98.6.
30
+ * @param {number} pct raw fractional used_percentage
31
+ * @returns {string}
32
+ */
33
+ function usedLabel(pct) {
34
+ if (pct < CRIT_PCT || pct >= 100) return String(Math.floor(pct));
35
+ const tenths = Math.floor(pct * 10 + 1e-9);
36
+ return `${Math.floor(tenths / 10)}.${tenths % 10}`;
37
+ }
38
+
19
39
  function bar(/** @type {number} */ p, w = 10) {
20
40
  const f = Math.max(0, Math.min(w, Math.round((p / 100) * w)));
21
41
  return '▓'.repeat(f) + '░'.repeat(w - f);
@@ -186,6 +206,6 @@ function ellipsize(s, cols) {
186
206
  }
187
207
 
188
208
  module.exports = {
189
- e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, clampVisible, tok, fmtMins, fmtReset,
209
+ e, dim, bold, green, red, yellow, cyan, flash, pctColor, CRIT_PCT, usedLabel, bar, clampVisible, tok, fmtMins, fmtReset,
190
210
  charWidth, visibleWidth, ellipsize,
191
211
  };
@@ -4,7 +4,7 @@
4
4
  // Plain text (no ANSI) so it renders cleanly wherever the status line appears.
5
5
 
6
6
  const { windowEstimate, binding } = require('../burn');
7
- const { fmtMins } = require('./shared');
7
+ const { fmtMins, usedLabel, CRIT_PCT } = require('./shared');
8
8
 
9
9
  /**
10
10
  * Deterministic middle ellipsis: the same input shortens the same way at
@@ -66,12 +66,19 @@ function renderStatusline(view, identity = {}) {
66
66
  const b = binding(live);
67
67
  if (b && b.minutesLeft != null) {
68
68
  const row = rows.find((r) => r.key === b.window);
69
+ const label = row ? row.label : b.window;
70
+ // Progressive disclosure: the one-line summary earns the precise used%
71
+ // ONLY in the critical zone, and truncated, so floor() still matches
72
+ // /usage. Below the zone the line stays a single glanceable beat — the
73
+ // time-to-limit already answers "am I near the wall?", and a percentage
74
+ // that is always present stops being a signal when it starts to matter.
75
+ const pct = row && row.est.usedPct >= CRIT_PCT ? ` ${usedLabel(row.est.usedPct)}%` : '';
69
76
  if (b.minutesLeft <= 30) {
70
77
  // "About to hit the wall" outranks orientation for the next thing the
71
78
  // user types: the warning jumps ahead of everything, identity included.
72
- parts.unshift(`⚠ ${row ? row.label : b.window} ~${fmtMins(b.minutesLeft)}`);
79
+ parts.unshift(`⚠ ${label}${pct} ~${fmtMins(b.minutesLeft)}`);
73
80
  } else {
74
- parts.push(`${row ? row.label : b.window} ~${fmtMins(b.minutesLeft)}`);
81
+ parts.push(`${label}${pct} ~${fmtMins(b.minutesLeft)}`);
75
82
  }
76
83
  } else {
77
84
  parts.push('within limits');
@@ -41,15 +41,28 @@ const path = require('node:path');
41
41
  const { spawn } = require('node:child_process');
42
42
  const { cycleView } = require('./cycle-view');
43
43
 
44
- // F3 in the two encodings terminals actually send SS3 on xterm and VS Code's
45
- // xterm.js, CSI on the Linux console — plus SPACE.
44
+ // F3 in every encoding a terminal actually sends it, plus SPACE.
45
+ //
46
+ // \x1bOR SS3. What xterm, screen, tmux and vt220 all send (infocmp: kf3).
47
+ // \x1b[[C The Linux console — infocmp gives `linux: kf3=\E[[C`, and it is
48
+ // the ONLY entry that differs. It is also what arrives on Windows
49
+ // from Node before v22.17.0 / v24.2.0: until "tty: use terminal VT
50
+ // mode on Windows" (db2aae802) setRawMode passed UV_TTY_MODE_RAW,
51
+ // where libuv translates the keypress itself rather than letting
52
+ // the terminal's own sequence through. From UV_TTY_MODE_RAW_VT on
53
+ // it sets ENABLE_VIRTUAL_TERMINAL_INPUT and Windows Terminal sends
54
+ // SS3 like everyone else. Which of the two arrives on Windows is
55
+ // therefore a property of the NODE VERSION, not the terminal.
56
+ // \x1b[13~ The CSI-tilde form VS Code's xterm.js sends. An earlier version
57
+ // of this comment called it the Linux console encoding; it is not,
58
+ // and the console form above was missing entirely.
46
59
  //
47
60
  // Space is not a fallback for tidiness: an editor that keeps F3 for its own
48
61
  // "find next" while the terminal is focused would otherwise leave this pane with
49
62
  // no key at all, and that behavior differs across VS Code, Cursor, Positron and
50
63
  // Antigravity. The pane is dedicated to the sidecar, so nothing else there is
51
64
  // waiting for a space.
52
- const CYCLE_KEYS = ['\x1bOR', '\x1b[13~', ' '];
65
+ const CYCLE_KEYS = ['\x1bOR', '\x1b[[C', '\x1b[13~', ' '];
53
66
 
54
67
  // In raw mode Ctrl-C arrives as a BYTE, not a signal. Without handling it the
55
68
  // pane could not be closed from the keyboard at all.
package/src/sidecar.js CHANGED
@@ -167,6 +167,7 @@ function updateFeed(tpath) {
167
167
 
168
168
  const dim = (/** @type {string} */ s) => `\x1b[2m${s}\x1b[0m`;
169
169
  const bold = (/** @type {string} */ s) => `\x1b[1m${s}\x1b[0m`;
170
+ const yellow = (/** @type {string} */ s) => `\x1b[33m${s}\x1b[0m`;
170
171
 
171
172
  let prev = '';
172
173
  function draw(/** @type {string} */ s) {
@@ -204,7 +205,15 @@ function sessionDir(stateDir) {
204
205
  /**
205
206
  * The directory ccr was LAUNCHED in — the tab's stable identity. Written by the
206
207
  * launcher (src/state-dir.js: recordLaunchDir); the sidecar's own cwd is the
207
- * fallback, which is right under tmux and wt.exe where the pane inherits it.
208
+ * fallback.
209
+ *
210
+ * That fallback is right under tmux, where `new-session` inherits the caller's
211
+ * cwd. It is NOT free under wt.exe: a pane opens in the Windows Terminal
212
+ * profile's own startingDirectory unless the launcher passes `-d`, which it
213
+ * now does (src/launch-win.js: buildWtArgs). This comment previously claimed
214
+ * wt.exe inherited it, and that claim was the only place the assumption
215
+ * surfaced anywhere in the codebase — see features/windows-launcher.feature,
216
+ * "Claude Code opens in the directory ccr was launched from".
208
217
  *
209
218
  * `process.cwd()` throws when the directory it names has been deleted, which is
210
219
  * not hypothetical here: it is the "repository is deleted while the pane is
@@ -263,7 +272,8 @@ function composeFrame(stateDir, opts = {}) {
263
272
  // External panes. Config is re-read per tick so adding a pane needs no
264
273
  // relaunch, and it is best-effort: a broken config costs the panes, never the
265
274
  // panel (loadPaneConfig is total — see src/pane-config.js).
266
- const panes = opts.panes || loadPaneConfig().panes;
275
+ const cfg = opts.panes ? { panes: opts.panes, error: null } : loadPaneConfig();
276
+ const panes = cfg.panes;
267
277
  // View order: 0 economy, 1 the git pane, 2… external panes. The git pane is
268
278
  // BUILT IN and therefore always in the cycle — including in a directory that
269
279
  // is not a repository at all, where it says so. A view that appeared and
@@ -341,6 +351,12 @@ function composeFrame(stateDir, opts = {}) {
341
351
  if (!raw.trim()) return clamp(dim('ccr · waiting for the first status tick…') + '\n');
342
352
  let state;
343
353
  try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
354
+ // Snapshot age drives BOTH the dimming of the used figures inside the panel
355
+ // and the freshness marker appended below — read ONCE so the two can never
356
+ // disagree about whether what you are looking at is live. An unreadable mtime
357
+ // reads as fresh: a missing timestamp is not evidence of staleness.
358
+ let ageMs = 0;
359
+ try { ageMs = Math.max(0, now - fs.statSync(snapshot).mtimeMs); } catch { /* unknown → fresh */ }
344
360
  let out;
345
361
  try {
346
362
  // 5h/weekly are ACCOUNT-WIDE but captured per-profile, so an idle sibling's
@@ -348,7 +364,7 @@ function composeFrame(stateDir, opts = {}) {
348
364
  // SAME account (see src/account-limits.js) before rendering — best-effort, and
349
365
  // strictly guarded so a different account is never mixed in.
350
366
  const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir, opts.home ? { home: opts.home } : {}) };
351
- out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0 });
367
+ out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0, ageMs });
352
368
  // ccr's own view is position 1 of the cycle, named only when the cycle is
353
369
  // long enough for the position to tell you something (see showPosition).
354
370
  if (showPosition) out = out.replace(/\n/, dim(` ${positionAt(0)}`) + '\n');
@@ -375,11 +391,17 @@ function composeFrame(stateDir, opts = {}) {
375
391
  // quiet "updated Nm ago" so a stale panel reads as stale rather than broken —
376
392
  // otherwise a long agent run (or a CC statusLine that stopped firing) looks like
377
393
  // the sidecar just froze. See src/liveness.js + features/liveness.feature.
378
- try {
379
- const ageMs = now - fs.statSync(snapshot).mtimeMs;
380
- const mark = liveness({ exited: false, ageMs }).marker;
381
- if (mark) out += (out.endsWith('\n') ? '' : '\n') + ' ' + dim('· ' + mark);
382
- } catch { /* snapshot mtime unknown no marker */ }
394
+ const mark = liveness({ exited: false, ageMs }).marker;
395
+ if (mark) out += (out.endsWith('\n') ? '' : '\n') + ' ' + dim('· ' + mark);
396
+ // A config the user wrote and got wrong used to cost the panes in silence —
397
+ // the panel rendered exactly as it does for someone who configured nothing,
398
+ // so a typo was indistinguishable from never having tried. Name it here and
399
+ // send them to `ccr doctor`, which has room for the path and the reason.
400
+ // Still never fatal: the panel is whole, only the panes are missing.
401
+ if (cfg.error) {
402
+ out += (out.endsWith('\n') ? '' : '\n')
403
+ + ' ' + yellow('· config: ' + cfg.error) + dim(' — see `ccr doctor`');
404
+ }
383
405
  return clamp(out.endsWith('\n') ? out : out + '\n');
384
406
  }
385
407
 
package/src/state-dir.js CHANGED
@@ -61,4 +61,21 @@ function recordLaunchDir(dir, cwd) {
61
61
  } catch { /* best effort */ }
62
62
  }
63
63
 
64
- module.exports = { ensureSecureDir, recordLaunchDir };
64
+ /**
65
+ * Forget the recorded launch directory.
66
+ *
67
+ * NOT the same as declining to write one. Slots are REUSED, so a record left by
68
+ * whatever ran in this slot before would still be sitting there — and
69
+ * src/sidecar.js launchDir() PREFERS the record over the pane's own cwd, so a
70
+ * stale one wins outright. The launcher calls this whenever it could not
71
+ * deliver the directory to the panes, which is the only moment it knows the
72
+ * record would be a lie.
73
+ *
74
+ * @param {string} dir The state directory.
75
+ */
76
+ function clearLaunchDir(dir) {
77
+ const path = require('node:path');
78
+ try { fs.rmSync(path.join(dir, 'launch-cwd'), { force: true }); } catch { /* best effort */ }
79
+ }
80
+
81
+ module.exports = { ensureSecureDir, recordLaunchDir, clearLaunchDir };