claude-code-runrate 0.2.3 → 0.3.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/README.md CHANGED
@@ -24,6 +24,9 @@ shows you the **economy** of a session:
24
24
  - **Resume advisor** (`ccr resume`) — recent sessions ranked by what they'd cost
25
25
  to bring back (context size, share of the window, cold/warm cache), then it
26
26
  hands selection to `claude --resume`.
27
+ - **External tool panes** — the sidebar can render read-only status panes from
28
+ other tools via a strict JSON contract; **F3** cycles between the economy
29
+ panel and each configured pane (see [External tool panes](#external-tool-panes-sidebar)).
27
30
 
28
31
  For scripting and external tools (status bars, menu-bar widgets), `ccr economy
29
32
  --json` emits a stable, versioned model — see
@@ -79,6 +82,13 @@ trigger the split itself, so `ccr` does everything around it:
79
82
  Lost the banner once Claude takes the screen? Run `ccr sidecar --hint` to reprint
80
83
  the steps and re-copy the command.
81
84
 
85
+ The split is a **one-time** setup per VS Code window: an attached sidecar picks
86
+ each new `ccr` session up automatically, so relaunching prints a short note
87
+ instead of the banner. And if you do paste the one-liner into a second pane, the
88
+ older pane stands down by itself — there is never more than one live sidebar per
89
+ session. Profiles stay independent: a personal `ccr` and a work `ccr <profile>`
90
+ run side by side, each with its own state dir and its own sidebar.
91
+
82
92
  On **Windows** this is the default inside VS Code (Windows Terminal otherwise
83
93
  opens a separate window, so the in-editor split is nicer). On **Linux/macOS**,
84
94
  `ccr` defaults to `tmux` (which works inside the VS Code terminal too); set
@@ -100,14 +110,71 @@ In `~/.claude/settings.json`:
100
110
  Code calls the status line frequently, and a resolved binary avoids per-tick
101
111
  latency.)
102
112
 
113
+ ## External tool panes (sidebar)
114
+
115
+ The live sidebar can host **read-only panes from other tools**. A tool writes a
116
+ small JSON blob beside its own artifacts; you list that file's path in ccr's
117
+ config; the sidebar cycles between the economy panel and each configured pane
118
+ (**F3** under tmux — the launcher binds it; `ccr cycle-view` on any host).
119
+
120
+ Config lives at `~/.config/ccr/config.json` (`$XDG_CONFIG_HOME` respected,
121
+ `CCR_CONFIG` overrides) — deliberately *not* in ccr's state dir, and never
122
+ read from a repository:
123
+
124
+ ```json
125
+ { "panes": [ { "path": "/home/you/project/.your-tool/sidecar.json" } ] }
126
+ ```
127
+
128
+ A pane is a full-height view carrying the producing tool's own rows — here
129
+ `gherkin-trace`, whose blob ships as the golden example:
130
+
131
+ ```
132
+ trace gherkin-trace 2/2
133
+ refresh · 2026-08-01 14:10 · blob written 0s
134
+
135
+ ● attention 3 1 breach, 2 orphans
136
+ ● reviewed 8
137
+ ◌ heat withheld no natural break
138
+ ◌ binding dark no run manifest
139
+ ● fence clean ▁▅▂█
140
+ ● exceptions 0
141
+ · experimental off
142
+ ```
143
+
144
+ - ccr **reads the file, validates it, renders it** — that is the entire
145
+ integration. No subprocess, no plugin code, no schema knowledge of the
146
+ producing tool: a pane is data all the way down, and every blob string is
147
+ stripped of control bytes before it touches your terminal.
148
+ - **Producers never know ccr exists.** You wire the join by hand, exactly like
149
+ Claude Code's own `statusLine` — neither side takes a dependency on the other.
150
+ - **Config order is cycle order.** F3 goes economy panel → first pane → second
151
+ pane → back to economy; the `2/2` above is that position. Entries are never
152
+ de-duplicated, so listing one path twice gives you two panes. The config is
153
+ re-read every tick — adding a pane takes effect without relaunching.
154
+ - **The producer is trusted for content, never for behaviour.** ccr executes
155
+ nothing from a blob and draws no value it has not validated, so a hostile file
156
+ cannot crash the panel or escape into your terminal — but what a pane *says*
157
+ is the producing tool's word, not ccr's. Point it only at files you would read
158
+ yourself.
159
+ - A malformed config yields no panes and a malformed blob renders as a **named
160
+ error state** — never a crash, never a misrender.
161
+ - The blob format is specified in
162
+ [`docs/PANE-CONTRACT.md`](docs/PANE-CONTRACT.md), with a golden example at
163
+ [`docs/pane-blob.golden.json`](docs/pane-blob.golden.json). Anything that
164
+ writes a conforming blob is a producer — there is no registry.
165
+
103
166
  ## Development
104
167
 
105
168
  This project is built **BDD-first**: the Gherkin in [`features/`](features/) is
106
169
  the source of truth, executed by a hand-rolled zero-dependency harness on top of
107
- Node's built-in test runner — a ~250-line Gherkin parser + runner that supports
170
+ Node's built-in test runner — a single-file Gherkin parser + runner that supports
108
171
  the practical core of the grammar and rejects everything else loudly rather than
109
- mis-parsing it. See [`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the
110
- deliberate limits, and the API.
172
+ mis-parsing it. The harness is available standalone as
173
+ [`gherkin-node-test`](https://github.com/bingh0/gherkin-node-test) on
174
+ [npm](https://www.npmjs.com/package/gherkin-node-test) (that repo is the
175
+ canonical source; `test/gherkin.js` is a vendored copy). See
176
+ [`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the deliberate limits,
177
+ and the API.
111
178
 
112
179
  ```bash
113
180
  npm test # node --test — harness self-tests + feature scenarios
package/bin/ccr.js CHANGED
@@ -83,11 +83,27 @@ function main(argv) {
83
83
  case 'statusline': return cmdStatusline();
84
84
  case 'sidecar': return cmdSidecar(values['state-dir'], !!values.hint, !!values['exit-on-end']);
85
85
  case 'doctor': return require('../src/doctor').run();
86
+ case 'cycle-view': return cmdCycleView(values['state-dir']);
86
87
  case 'launch': return cmdLaunch(positionals[1]);
87
88
  default: return cmdLaunch(cmd); // anything else → treat as a CCS profile
88
89
  }
89
90
  }
90
91
 
92
+ /**
93
+ * `ccr cycle-view` — show the running sidecar's next view. Bound to a key by
94
+ * the launcher; the sidecar itself reads no input (see src/cycle-view.js).
95
+ * Always exits 0: a keypress that finds no live sidecar is a no-op, not an
96
+ * error worth painting over the user's terminal.
97
+ * @param {string|undefined} stateDirFlag
98
+ * @returns {number}
99
+ */
100
+ function cmdCycleView(stateDirFlag) {
101
+ const stateDir = stateDirFlag || process.env.CCR_STATE_DIR
102
+ || require('node:path').join(require('node:os').homedir(), '.ccr');
103
+ require('../src/cycle-view').cycleView(stateDir);
104
+ return 0;
105
+ }
106
+
91
107
  function readStdin() {
92
108
  try { return process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8'); } catch { return ''; }
93
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.2.3",
3
+ "version": "0.3.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>",
package/scripts/launch.sh CHANGED
@@ -12,6 +12,8 @@
12
12
  # state dirs keep concurrent profiles from colliding.
13
13
  #
14
14
  # Env overrides: CC_BIN, CCR_SESSION, CCR_STATE_DIR, CCR_SIDEBAR_PCT (default 34).
15
+ # The tmux socket name follows the session name — each instance runs its own
16
+ # tmux server, so `tmux ls` won't list ccr sessions (`tmux -L ccr-<profile> ls`).
15
17
 
16
18
  set -euo pipefail
17
19
 
@@ -25,6 +27,11 @@ if [ -n "$PROFILE" ] && ! printf '%s' "$PROFILE" | grep -qE '^[A-Za-z0-9._-]+$';
25
27
  exit 1
26
28
  fi
27
29
 
30
+ # Escape a value for a SINGLE-QUOTED shell context: ' becomes '\''.
31
+ # Correct for anything we pass through `sh -c`, which is one parsing layer.
32
+ # NOT sufficient inside a tmux config string — see the F3 binding below for why.
33
+ sq() { printf "%s" "$1" | sed "s/'/'\\\\''/g"; }
34
+
28
35
  # State lives under the user's home, never world-shared /tmp; create it
29
36
  # owner-only so other local users can't read captured status.
30
37
  umask 077
@@ -56,6 +63,16 @@ else
56
63
  STATE="${CCR_STATE_DIR:-$HOME/.ccr}"
57
64
  fi
58
65
 
66
+ # Every instance gets its OWN tmux server, on a socket named after the session
67
+ # (-L puts it under /tmp/tmux-$UID/). On a shared server, one server death —
68
+ # a kill-server (2026-08-02: an agent inside one instance ran exactly that as
69
+ # "cleanup" after a config parse check), a crash, a cgroup teardown — takes
70
+ # down every concurrent profile at once; and root-table bindings like F2 are
71
+ # server-global, so the last launch would steal the hotkey for all instances.
72
+ # Isolation costs one visible thing: `tmux ls` won't list ccr sessions —
73
+ # use `tmux -L ccr-<profile> ls`.
74
+ SOCKET="$SESSION"
75
+
59
76
  mkdir -p "$STATE"
60
77
  chmod 700 "$HOME/.ccr" "$STATE" 2>/dev/null || true
61
78
  rm -f "$STATE/exited"
@@ -69,19 +86,91 @@ trap 'rm -f "$RUN_CONF"' EXIT
69
86
  cp "$REPO/sidecar/ccr.tmux.conf" "$RUN_CONF"
70
87
 
71
88
  # Clean re-launch.
72
- tmux kill-session -t "$SESSION" 2>/dev/null || true
89
+ tmux -L "$SOCKET" kill-session -t "$SESSION" 2>/dev/null || true
73
90
 
74
- ENV_PREAMBLE="export CCR_STATE_DIR='$STATE'"
91
+ # These strings are handed to `sh -c` by tmux — ONE parsing layer, so ordinary
92
+ # shell escaping is both necessary and sufficient. $STATE and $SESSION are not
93
+ # validated the way a profile name is (they come from $HOME and the CCR_SESSION
94
+ # / CCR_STATE_DIR overrides), and an apostrophe in either would otherwise end
95
+ # the quoting and run the remainder as a command.
96
+ STATE_Q="$(sq "$STATE")"
97
+ SESSION_Q="$(sq "$SESSION")"
98
+ SOCKET_Q="$(sq "$SOCKET")"
99
+ ENV_PREAMBLE="export CCR_STATE_DIR='$STATE_Q'"
75
100
 
76
101
  # Pane 0: claude/ccs with --settings. On exit, drop the sentinel then close.
77
- tmux new-session -d -s "$SESSION" \
78
- "$ENV_PREAMBLE; $CC_CMD --settings '$SETTINGS'; touch '$STATE/exited'; sleep 2; tmux kill-session -t '$SESSION' 2>/dev/null"
79
- tmux set-environment -t "$SESSION" CCR_STATE_DIR "$STATE"
102
+ # Capture its pane id: the F2 hotkey below must target %N, never a relative
103
+ # index (see the binding comment further down).
104
+ CLAUDE_PANE="$(tmux -L "$SOCKET" new-session -d -P -F '#{pane_id}' -s "$SESSION" \
105
+ "$ENV_PREAMBLE; $CC_CMD --settings '$SETTINGS'; touch '$STATE_Q/exited'; sleep 2; tmux -L '$SOCKET_Q' kill-session -t '$SESSION_Q' 2>/dev/null")"
106
+ tmux -L "$SOCKET" set-environment -t "$SESSION" CCR_STATE_DIR "$STATE"
107
+
108
+ # Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
109
+ SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
110
+ "$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
111
+
112
+ # The sidebar is a live dashboard — there is nothing to scroll. A stray mouse-wheel
113
+ # or PageUp over its narrow pane drops tmux into copy-mode, which freezes the pane
114
+ # at a snapshot and swallows the sidecar's per-second redraws — it looks like the
115
+ # sidebar "got lost" (the grid keeps updating underneath; only the view is frozen).
116
+ # Auto-cancel copy-mode the instant this pane enters it. PANE-scoped, so every other
117
+ # pane — and the Claude pane's scrollback — keeps normal copy-mode. The cancel
118
+ # re-fires this hook with pane_in_mode=0, so the guard stops it recursing. Best-effort:
119
+ # pane-scoped hooks need tmux >= 3.2; older tmux just skips the guard (|| true).
120
+ if [ -n "$SIDEBAR_PANE" ]; then
121
+ tmux -L "$SOCKET" set-hook -p -t "$SIDEBAR_PANE" pane-mode-changed \
122
+ "if-shell -F '#{pane_in_mode}' 'send-keys -t $SIDEBAR_PANE -X cancel'" 2>/dev/null || true
123
+ fi
80
124
 
81
- # Pane 1: the live economy sidebar.
82
- tmux split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" \
83
- "$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited Enter to close '"
125
+ # F2 → /clear: the one hotkey ccr ships. The text is a CONSTANT in this script —
126
+ # never configuration, never a prompt file, never blob content (the pane
127
+ # subsystem has no path to a key binding at all; docs/PANE-CONTRACT.md). It
128
+ # targets the pane id captured above, because a relative index like `.0`
129
+ # retargets after any split or swap. confirm-before makes a stray F2 cost one
130
+ # keypress rather than a whole context. If no pane id came back (a tmux too old
131
+ # for `new-session -P`), NO hotkey is bound — never an approximate target.
132
+ if [ -n "$CLAUDE_PANE" ]; then
133
+ printf "bind-key -n F2 confirm-before -p 'send /clear to Claude? (y/n) ' \"send-keys -t %s '/clear' Enter\"\n" \
134
+ "$CLAUDE_PANE" >> "$RUN_CONF"
135
+ fi
136
+
137
+ # F3 → cycle the sidebar's view (economy ⇄ each configured external pane).
138
+ # It runs `ccr cycle-view`, which records a request the sidecar picks up on its
139
+ # next tick; the sidecar never reads a keystroke itself, because an input
140
+ # channel is precisely the capability the pane threat model denies it
141
+ # (docs/PANE-CONTRACT.md). No confirm gate: cycling costs nothing and is undone
142
+ # by pressing again.
143
+ #
144
+ # The paths go in a generated SCRIPT, not into the binding. $STATE, $REPO and
145
+ # $NODE derive from $HOME, $CCR_STATE_DIR and the checkout location — none of
146
+ # which this script validates the way it validates a profile name — and a lone
147
+ # apostrophe in any of them used to close the quoting so the rest ran as a
148
+ # command (reproduced 2026-08-02, and again after a first "fix").
149
+ #
150
+ # Shell-escaping alone is NOT enough here, which is the subtle part: a binding
151
+ # in this file passes through TWO parsers. tmux reads the config line first and
152
+ # processes backslashes inside its double quotes, so a shell-level '\'' arrives
153
+ # at sh already stripped to '' — closing the quote after all. Escaping correctly
154
+ # for both layers at once is the kind of thing that looks right and isn't.
155
+ #
156
+ # So: one parsing layer each. The helper script holds the paths with ordinary
157
+ # shell quoting (sq is exactly right for that), and the config line names only
158
+ # the helper's own mktemp path inside tmux SINGLE quotes, where tmux performs no
159
+ # escape processing at all. If that path could itself contain a quote we bind
160
+ # nothing rather than emit a line we cannot reason about.
161
+ CYCLE_SH="$(mktemp "${TMPDIR:-/tmp}/ccr-cycle.XXXXXX")"
162
+ trap 'rm -f "$RUN_CONF" "$CYCLE_SH"' EXIT
163
+ {
164
+ printf '#!/bin/sh\n'
165
+ printf "exec '%s' '%s' cycle-view --state-dir '%s'\n" \
166
+ "$(sq "$NODE")" "$(sq "$REPO/bin/ccr.js")" "$(sq "$STATE")"
167
+ } > "$CYCLE_SH"
168
+ chmod 700 "$CYCLE_SH"
169
+ case "$CYCLE_SH" in
170
+ *\'*) echo "ccr: TMPDIR contains a quote — F3 (cycle view) not bound" >&2 ;;
171
+ *) printf "bind-key -n F3 run-shell '%s'\n" "$CYCLE_SH" >> "$RUN_CONF" ;;
172
+ esac
84
173
 
85
- tmux select-pane -t "$SESSION:0.0"
86
- tmux source-file -t "$SESSION" "$RUN_CONF"
87
- tmux attach -t "$SESSION"
174
+ tmux -L "$SOCKET" select-pane -t "$SESSION:0.0"
175
+ tmux -L "$SOCKET" source-file -t "$SESSION" "$RUN_CONF"
176
+ tmux -L "$SOCKET" attach -t "$SESSION"
@@ -1,8 +1,16 @@
1
- # sidecar/ccr.tmux.conf — applies ONLY to ccr's own tmux session, never your
2
- # global tmux config. Minimal bindings just F2 /clear.
1
+ # sidecar/ccr.tmux.conf — sourced into ccr's OWN tmux server (each instance
2
+ # runs on its own -L socket; scripts/launch.sh), so the `set -g` lines and the
3
+ # root-table binding below can never touch your personal tmux server or
4
+ # another ccr profile's. Minimal bindings.
3
5
 
4
- # F2 send /clear to the Claude pane (reset context before compaction hits).
5
- bind-key -n F2 send-keys -t .0 '/clear' Enter
6
+ # The F2 /clear hotkey is deliberately NOT bound here. scripts/launch.sh
7
+ # appends it to the per-session copy of this file, because that is the only
8
+ # place that knows the Claude pane's id (%N), captured when the session is
9
+ # created. A binding written here could only name a RELATIVE index like `.0`,
10
+ # which silently retargets after any split or swap — sending /clear to
11
+ # whichever pane happens to be first at the time. See docs/PANE-CONTRACT.md
12
+ # ("Hotkeys are a host capability"): configuration chooses which key, ccr's
13
+ # own code chooses the text, and the target is always the captured id.
6
14
 
7
15
  set -g mouse on
8
16
  set -g status off
package/src/burn.js CHANGED
@@ -131,11 +131,15 @@ function clearROI(o) {
131
131
  return { boughtMinutes: 0, projectedBurn: o.rate };
132
132
  }
133
133
  let ratio;
134
- if (o.calib) {
135
- const w = (x) => Math.max(o.calib.a * x + o.calib.b, 1e-9);
134
+ // Bind the calibration outside the closure: narrowing does not survive into a
135
+ // function body (the callback could in principle run after o.calib changed),
136
+ // and binding it also means the reader need not reason about reentrancy.
137
+ const calib = o.calib;
138
+ if (calib) {
139
+ const w = (/** @type {number} */ x) => Math.max(calib.a * x + calib.b, 1e-9);
136
140
  ratio = w(o.baselineB) / w(o.contextC);
137
141
  } else {
138
- const w = (x) => READ_WEIGHT * x + K_TAIL;
142
+ const w = (/** @type {number} */ x) => READ_WEIGHT * x + K_TAIL;
139
143
  ratio = w(o.baselineB) / w(o.contextC);
140
144
  }
141
145
  // A clear can't shed the output/write tail or the retained baseline, so burn
@@ -0,0 +1,78 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/cycle-view.js — ask the running sidecar to show its next view.
4
+ //
5
+ // The sidecar reads no stdin, by construction: an input channel is exactly the
6
+ // capability the pane threat model refuses it (docs/PANE-CONTRACT.md,
7
+ // "Structural invariants"). So the host binds a key, the key runs this, and
8
+ // this leaves a REQUEST the sidecar picks up on its next tick.
9
+ //
10
+ // WHY A FILE AND NOT A SIGNAL. The first version of this read the sidecar's pid
11
+ // from its heartbeat file and sent SIGUSR1. That was wrong, and an adversarial
12
+ // review reproduced the consequence: the heartbeat lives in a directory
13
+ // anything running as the user can write (src/safe-read.js says so in its own
14
+ // header), so writing "<victim_pid>:<now>" into it redirected the signal at any
15
+ // process of the user's choosing — and SIGUSR1's default disposition is
16
+ // terminate. A cosmetic "show me the next pane" key was a kill primitive.
17
+ //
18
+ // No guard fixes that, because the pid and its freshness both come from the
19
+ // attacker's own file: a liveness probe only proves the victim exists. The
20
+ // mechanism had to change, not gain checks. Writing a request costs the same
21
+ // attacker exactly what they should get — the ability to change which pane is
22
+ // on screen — and nothing else.
23
+ //
24
+ // The cost is latency: the sidecar notices on its next tick, so up to ~1s. That
25
+ // is the honest price for not holding a loaded weapon, and a keypress that
26
+ // repaints within a second reads as responsive anyway.
27
+
28
+ const fs = require('node:fs');
29
+ const path = require('node:path');
30
+ const { readTextCapped } = require('./safe-read');
31
+
32
+ /** The request file the sidecar polls. Content is a counter, not a command. */
33
+ const REQUEST_FILE = 'view-request';
34
+
35
+ /**
36
+ * Record a request to advance the view. Never throws: a keypress that cannot
37
+ * write is a no-op, not an error worth painting over the user's terminal.
38
+ * @param {string} stateDir
39
+ * @returns {{ ok: boolean, reason?: string, count?: number }}
40
+ */
41
+ function cycleView(stateDir) {
42
+ const file = path.join(stateDir, REQUEST_FILE);
43
+ // Monotonic counter rather than a timestamp: two presses inside the same
44
+ // millisecond must still read as two requests.
45
+ // Capped, regular-files-only: a fifo planted here would otherwise block this
46
+ // process forever, and under tmux run-shell every keypress would leak another
47
+ // hung node. Same rule as every other file the sidecar reads.
48
+ let count = 0;
49
+ const cur = (readTextCapped(file, 64) || '').trim();
50
+ if (/^\d+$/.test(cur)) count = Number(cur);
51
+ if (!Number.isSafeInteger(count) || count < 0) count = 0;
52
+
53
+ try {
54
+ // Never write THROUGH a symlink planted at this path.
55
+ try { if (fs.lstatSync(file).isSymbolicLink()) fs.rmSync(file, { force: true }); } catch { /* absent */ }
56
+ fs.writeFileSync(file, String(count + 1));
57
+ return { ok: true, count: count + 1 };
58
+ } catch {
59
+ return { ok: false, reason: 'state dir not writable' };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * How many advance-requests have been recorded. The sidecar calls this each
65
+ * tick and advances its view by the DIFFERENCE since the previous tick, so a
66
+ * request that arrives while the pane is busy is never lost, and a burst of
67
+ * presses advances by the number pressed.
68
+ * @param {string} stateDir
69
+ * @returns {number}
70
+ */
71
+ function readViewRequests(stateDir) {
72
+ const cur = (readTextCapped(path.join(stateDir, REQUEST_FILE), 64) || '').trim();
73
+ if (!/^\d+$/.test(cur)) return 0;
74
+ const n = Number(cur);
75
+ return Number.isSafeInteger(n) && n >= 0 ? n : 0;
76
+ }
77
+
78
+ module.exports = { cycleView, readViewRequests, REQUEST_FILE };
package/src/doctor.js CHANGED
@@ -82,6 +82,7 @@ function run(opts = {}) {
82
82
 
83
83
  const ccs = hasFn('ccs');
84
84
  if (ccs) {
85
+ /** @type {string[]} */
85
86
  let profiles = [];
86
87
  try { profiles = fs.readdirSync(path.join(homedir, '.ccs', 'instances')).filter((p) => !p.startsWith('.')); } catch { /* none */ }
87
88
  // Profile + path come from the filesystem; sanitize before display.
@@ -106,6 +107,7 @@ function run(opts = {}) {
106
107
  }
107
108
  if (newest) {
108
109
  const ageMin = Math.round((Date.now() - newest.m) / 60000);
110
+ /** @type {string[]} */
109
111
  let keys = [];
110
112
  try { keys = Object.keys(JSON.parse(fs.readFileSync(path.join(newest.d, 'last-status.json'), 'utf8')).rate_limits || {}); } catch { /* ignore */ }
111
113
  // Defense-in-depth: sanitize the dir + bucket keys before display even
@@ -38,6 +38,9 @@ function band(min) {
38
38
  * @returns {{ rows: any[], next: any }}
39
39
  */
40
40
  function classifyWindows(view) {
41
+ // Annotated because Array.isArray does not narrow an `any`: without this the
42
+ // whole chain below decays to `any` and the row callbacks lose their types.
43
+ /** @type {any[]} */
41
44
  const windows = Array.isArray(view.windows) ? view.windows : [];
42
45
  const rows = windows.map((/** @type {any} */ wd) => {
43
46
  const est = windowEstimate({ usedPct: wd.usedPct, rate: wd.rate, minutesToReset: wd.minutesToReset, windowMinutes: wd.windowMinutes });
@@ -96,6 +96,21 @@ function copyToClipboard(text, d) {
96
96
  }
97
97
  }
98
98
 
99
+ /**
100
+ * The quiet replacement for the banner when a sidecar is ALREADY attached to
101
+ * this state dir (heartbeat fresh — see src/sidecar.js). Relaunching used to
102
+ * prompt for a new split+paste every time while every old pane on the same
103
+ * state dir came back to life too, so panes accumulated; an attached sidecar
104
+ * picks the new session up by itself, so all the user needs is one line.
105
+ * @param {{ hintCmd: string, color: boolean }} o
106
+ * @returns {string}
107
+ */
108
+ function buildAttachedNote(o) {
109
+ const c = o.color ? (/** @type {string} */ code, /** @type {string} */ s) => `\x1b[${code}m${s}\x1b[0m` : (/** @type {string} */ _code, /** @type {string} */ s) => s;
110
+ return '\n' + c('1', ' ccr') + c('2', ` · live sidecar already attached — it picks this session up automatically.`)
111
+ + '\n' + c('2', ` (split steps again: ${o.hintCmd})`) + '\n\n';
112
+ }
113
+
99
114
  /**
100
115
  * `ccr [profile]` inside a VS Code integrated terminal: wire the split-view
101
116
  * sidecar, then run Claude in the current pane. Returns Claude's exit code.
@@ -136,17 +151,29 @@ function run(profile, deps = {}) {
136
151
 
137
152
  // Show the split instructions + copy the sidecar one-liner BEFORE Claude takes
138
153
  // over the pane (the clipboard + hint make it recoverable once it scrolls off).
154
+ // Unless a sidecar is already attached to this state dir: it revives on its
155
+ // own once the exited sentinel is cleared above, and re-prompting the split
156
+ // every relaunch is exactly what piled up duplicate panes.
139
157
  const ccrBin = d.which('ccr');
140
158
  const sidecarCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
141
159
  const hintCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
142
- d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
143
- copyToClipboard(sidecarCmd, d);
160
+ if (d.sidecarAlive(st.stateDir)) {
161
+ d.out(buildAttachedNote({ hintCmd, color: d.color }));
162
+ } else {
163
+ d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
164
+ copyToClipboard(sidecarCmd, d);
165
+ }
144
166
 
145
- // Run Claude in the current pane (blocks until exit). The temp settings file is
146
- // always removed; the "session ended" sentinel is only dropped if Claude
147
- // actually ran a failed spawn must NOT flip the sidecar to "ended".
167
+ // Run Claude in the current pane (blocks until exit). CCR_STATE_DIR rides the
168
+ // spawn env so the statusline subprocess (a grandchild via Claude) snapshots
169
+ // into THIS profile's state dir without it a profile session writes to the
170
+ // default ~/.ccr, starving its own sidecar and clobbering a concurrently
171
+ // running bare session's (the wt.exe launcher injects the same var per pane).
172
+ // The temp settings file is always removed; the "session ended" sentinel is
173
+ // only dropped if Claude actually ran — a failed spawn must NOT flip the
174
+ // sidecar to "ended".
148
175
  const parts = st.ccCmd.split(' ');
149
- const r = d.spawnClaude(parts[0], [...parts.slice(1), '--settings', settingsFile]);
176
+ const r = d.spawnClaude(parts[0], [...parts.slice(1), '--settings', settingsFile], { CCR_STATE_DIR: st.stateDir });
150
177
  d.cleanup(settingsFile);
151
178
  if (r && r.error) { d.err(`ccr: failed to launch Claude: ${r.error.message}\n`); return 1; }
152
179
  d.dropExited(st.stateDir);
@@ -215,15 +242,17 @@ function buildClaudeSpawn(bin, args, o) {
215
242
  *
216
243
  * @param {string} bin
217
244
  * @param {string[]} args
245
+ * @param {Record<string, string>} [extraEnv] merged over process.env (CCR_STATE_DIR)
218
246
  * @returns {{ status: number|null, error?: Error }}
219
247
  */
220
- function defaultSpawnClaude(bin, args) {
248
+ function defaultSpawnClaude(bin, args, extraEnv) {
221
249
  const built = buildClaudeSpawn(bin, args, { platform: process.platform, which: defaultWhich });
222
250
  if ('error' in built) return { status: null, error: built.error };
223
251
  const { spawnSync } = require('node:child_process');
252
+ const env = { ...process.env, ...extraEnv };
224
253
  return built.shell
225
- ? spawnSync(built.command, { stdio: 'inherit', shell: true })
226
- : spawnSync(built.command, built.args || [], { stdio: 'inherit' });
254
+ ? spawnSync(built.command, { stdio: 'inherit', shell: true, env })
255
+ : spawnSync(built.command, built.args || [], { stdio: 'inherit', env });
227
256
  }
228
257
 
229
258
  /** @param {string} name @returns {string|null} */
@@ -260,6 +289,9 @@ function withDefaults(deps) {
260
289
  cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
261
290
  spawnClaude: deps.spawnClaude || defaultSpawnClaude,
262
291
  spawnCopy: deps.spawnCopy || ((cmd, args, input) => require('node:child_process').spawnSync(cmd, args, { input, stdio: ['pipe', 'ignore', 'ignore'] })),
292
+ // Lazy require: the heartbeat check single-sources file name + freshness in
293
+ // src/sidecar.js without loading the render stack on the launch path.
294
+ sidecarAlive: deps.sidecarAlive || ((dir) => require('./sidecar').sidecarAlive(dir)),
263
295
  };
264
296
  }
265
297
 
@@ -281,8 +313,9 @@ function withDefaults(deps) {
281
313
  * @property {(dir: string) => void} dropExited
282
314
  * @property {(settings: object) => string} writeSettings
283
315
  * @property {(file: string) => void} cleanup
284
- * @property {(bin: string, args: string[]) => {status: number|null, error?: Error}} spawnClaude
316
+ * @property {(bin: string, args: string[], extraEnv?: Record<string, string>) => {status: number|null, error?: Error}} spawnClaude
285
317
  * @property {(cmd: string, args: string[], input: string) => {status: number|null, error?: Error}} spawnCopy
318
+ * @property {(stateDir: string) => boolean} sidecarAlive
286
319
  */
287
320
 
288
- module.exports = { splitKeybinding, sidecarPasteCommand, osc52, buildBanner, copyToClipboard, buildClaudeSpawn, run, hint };
321
+ module.exports = { splitKeybinding, sidecarPasteCommand, osc52, buildBanner, buildAttachedNote, copyToClipboard, buildClaudeSpawn, run, hint };
package/src/liveness.js CHANGED
@@ -20,6 +20,23 @@ function envStaleMs() {
20
20
  return Number.isFinite(v) && v > 0 ? v : null;
21
21
  }
22
22
 
23
+ /**
24
+ * Snapshot age for the freshness note. Minutes roll into hours and hours into
25
+ * days: an overnight-idle pane used to read "updated 1500m ago", which is
26
+ * accurate and unreadable.
27
+ * @param {number} ageMs
28
+ * @returns {string}
29
+ */
30
+ function fmtAge(ageMs) {
31
+ const m = Math.floor(ageMs / 60000);
32
+ if (m < 60) return `${m}m`;
33
+ const h = Math.floor(m / 60);
34
+ if (h < 24) { const r = m % 60; return r ? `${h}h${String(r).padStart(2, '0')}m` : `${h}h`; }
35
+ const d = Math.floor(h / 24);
36
+ const rh = h % 24;
37
+ return rh ? `${d}d${rh}h` : `${d}d`;
38
+ }
39
+
23
40
  /**
24
41
  * @param {{ exited?: boolean, ageMs?: number, staleMs?: number }} input
25
42
  * @returns {{ mode: 'ended' | 'live', marker: string | null }}
@@ -32,7 +49,7 @@ function liveness(input) {
32
49
 
33
50
  const ageMs = input.ageMs ?? 0;
34
51
  const staleMs = input.staleMs ?? envStaleMs() ?? DEFAULT_STALE_MS;
35
- const marker = ageMs >= staleMs ? `updated ${Math.floor(ageMs / 60000)}m ago` : null;
52
+ const marker = ageMs >= staleMs ? `updated ${fmtAge(ageMs)} ago` : null;
36
53
  return { mode: 'live', marker };
37
54
  }
38
55
 
package/src/normalize.js CHANGED
@@ -7,6 +7,21 @@
7
7
  const { discoverWindows } = require('./rate-limits');
8
8
  const { stripControl } = require('./sanitize');
9
9
 
10
+ /**
11
+ * A finite number, or null. The snapshot is a JSON file on disk: it chooses its
12
+ * own value types, and a `!= null` check accepts the string "1.5" as happily as
13
+ * 1.5. Downstream does arithmetic and calls `.toFixed()`, so one wrong type is
14
+ * a TypeError inside the draw loop — which the sidecar catches, but only by
15
+ * replacing the whole economy panel with an error line, every tick, until the
16
+ * file changes. Type-check at ingestion; a bad field costs itself and nothing
17
+ * else. (NaN/Infinity are excluded too — they render as "NaN%" meters.)
18
+ * @param {any} v
19
+ * @returns {number|null}
20
+ */
21
+ function num(v) {
22
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
23
+ }
24
+
10
25
  /**
11
26
  * @param {any} state CC status-line JSON
12
27
  * @param {number} [nowSec] override for testing
@@ -15,16 +30,20 @@ const { stripControl } = require('./sanitize');
15
30
  function normalizeStatus(state, nowSec) {
16
31
  const rl = (state && state.rate_limits) || {};
17
32
  const cw = (state && state.context_window) || {};
33
+ const cost = (state && state.cost) || {};
34
+ const durationMs = num(cost.total_duration_ms);
18
35
  return {
19
36
  model: stripControl((state && state.model && state.model.display_name) || null),
20
- windowSize: cw.context_window_size || 200000,
37
+ // Must be positive: it is a divisor for the ctx meter, and `?? ` (unlike the
38
+ // `||` this replaced) would let a literal 0 through to divide by zero.
39
+ windowSize: (num(cw.context_window_size) || 0) > 0 ? cw.context_window_size : 200000,
21
40
  windows: discoverWindows(rl, nowSec),
22
- contextTokens: cw.total_input_tokens
23
- ?? (cw.current_usage && cw.current_usage.cache_read_input_tokens) ?? null,
41
+ contextTokens: num(cw.total_input_tokens)
42
+ ?? num(cw.current_usage && cw.current_usage.cache_read_input_tokens),
24
43
  cachedPct: null,
25
44
  baselineTok: 14000,
26
- costUsd: state && state.cost && state.cost.total_cost_usd != null ? state.cost.total_cost_usd : null,
27
- durationMin: state && state.cost && state.cost.total_duration_ms != null ? state.cost.total_duration_ms / 60000 : null,
45
+ costUsd: num(cost.total_cost_usd),
46
+ durationMin: durationMs != null ? durationMs / 60000 : null,
28
47
  branch: null,
29
48
  };
30
49
  }