claude-code-runrate 0.2.4 → 0.4.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/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
@@ -35,7 +42,14 @@ chmod +x "$REPO/sidecar/ccr-statusline" 2>/dev/null || true
35
42
 
36
43
  # Prefer the newest nvm-installed node; `sort -V` is a GNU-ism, so suppress its
37
44
  # error on BSD/macOS and fall back to PATH node below.
38
- NODE="$(ls -d "$HOME"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V 2>/dev/null | tail -1)"
45
+ #
46
+ # The `|| true` is load-bearing, not defensive habit: with `set -e` and
47
+ # `pipefail`, `ls` failing on a machine with no ~/.nvm makes the whole pipeline
48
+ # non-zero, and a failing command substitution in an assignment aborts the
49
+ # script. That killed the launcher outright — exit 2, no message, no sidebar —
50
+ # for exactly the user who has plain Claude Code and no nvm, who then never
51
+ # reaches the PATH fallback two lines below. Reproduced 2026-08-04.
52
+ NODE="$(ls -d "$HOME"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V 2>/dev/null | tail -1 || true)"
39
53
  [ -x "$NODE" ] || NODE="$(command -v node || true)"
40
54
  [ -n "$NODE" ] || { echo "ccr: node not found" >&2; exit 1; }
41
55
  command -v tmux >/dev/null 2>&1 || { echo "ccr: tmux not found (required for the sidebar)" >&2; exit 1; }
@@ -48,17 +62,40 @@ if [ -n "$PROFILE" ]; then
48
62
  exit 1
49
63
  fi
50
64
  CC_CMD="ccs $PROFILE"
51
- SESSION="${CCR_SESSION:-ccr-$PROFILE}"
52
- STATE="${CCR_STATE_DIR:-$HOME/.ccr/$PROFILE}"
53
65
  else
54
66
  CC_CMD="${CC_BIN:-claude}"
55
- SESSION="${CCR_SESSION:-ccr}"
56
- STATE="${CCR_STATE_DIR:-$HOME/.ccr}"
57
67
  fi
68
+ # The launcher normally arrives with CCR_SESSION/CCR_STATE_DIR already set by
69
+ # the slot allocator (bin/ccr.js — every launch slots, profiled or bare). The
70
+ # fallbacks cover only a direct invocation of this script, and they point at
71
+ # slot 1's member dir: ~/.ccr itself is a container now, never a state dir.
72
+ SESSION="${CCR_SESSION:-ccr}"
73
+ STATE="${CCR_STATE_DIR:-$HOME/.ccr/instances/1}"
74
+
75
+ # Every instance gets its OWN tmux server, on a socket named after the session
76
+ # (-L puts it under /tmp/tmux-$UID/). On a shared server, one server death —
77
+ # a kill-server (2026-08-02: an agent inside one instance ran exactly that as
78
+ # "cleanup" after a config parse check), a crash, a cgroup teardown — takes
79
+ # down every concurrent profile at once; and root-table bindings like F2 are
80
+ # server-global, so the last launch would steal the hotkey for all instances.
81
+ # Isolation costs one visible thing: `tmux ls` won't list ccr sessions —
82
+ # use `tmux -L ccr-<profile> ls`.
83
+ SOCKET="$SESSION"
58
84
 
59
85
  mkdir -p "$STATE"
60
86
  chmod 700 "$HOME/.ccr" "$STATE" 2>/dev/null || true
61
87
  rm -f "$STATE/exited"
88
+ # The directory ccr was launched in — the tab's stable identity for the git
89
+ # pane (src/state-dir.js: recordLaunchDir does the same for the other two
90
+ # launchers). Best-effort: a tab that cannot record it falls back to naming
91
+ # only the repo the session is in.
92
+ # The rm is load-bearing, not tidiness: `>` on a planted FIFO blocks until a
93
+ # reader appears and hangs the launcher here, before Claude is ever spawned, and
94
+ # on a planted symlink it overwrites the link's target. Removing first turns both
95
+ # into an ordinary create. src/state-dir.js does the same for the other two
96
+ # launchers, and both sibling writers in <stateDir> already guard this way.
97
+ rm -f "$STATE/launch-cwd" 2>/dev/null || true
98
+ printf '%s\n' "$PWD" > "$STATE/launch-cwd" 2>/dev/null || true
62
99
 
63
100
  SETTINGS='{"statusLine":{"type":"command","command":"'"$REPO/sidecar/ccr-statusline"'"}}'
64
101
 
@@ -69,17 +106,36 @@ trap 'rm -f "$RUN_CONF"' EXIT
69
106
  cp "$REPO/sidecar/ccr.tmux.conf" "$RUN_CONF"
70
107
 
71
108
  # Clean re-launch.
72
- tmux kill-session -t "$SESSION" 2>/dev/null || true
73
-
74
- ENV_PREAMBLE="export CCR_STATE_DIR='$STATE'"
109
+ tmux -L "$SOCKET" kill-session -t "$SESSION" 2>/dev/null || true
110
+
111
+ # These strings are handed to `sh -c` by tmux — ONE parsing layer, so ordinary
112
+ # shell escaping is both necessary and sufficient. $STATE and $SESSION are not
113
+ # validated the way a profile name is (they come from $HOME and the CCR_SESSION
114
+ # / CCR_STATE_DIR overrides), and an apostrophe in either would otherwise end
115
+ # the quoting and run the remainder as a command.
116
+ STATE_Q="$(sq "$STATE")"
117
+ SESSION_Q="$(sq "$SESSION")"
118
+ SOCKET_Q="$(sq "$SOCKET")"
119
+ ENV_PREAMBLE="export CCR_STATE_DIR='$STATE_Q'"
75
120
 
76
121
  # 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"
122
+ # Capture its pane id: the F2 hotkey below must target %N, never a relative
123
+ # index (see the binding comment further down).
124
+ CLAUDE_PANE="$(tmux -L "$SOCKET" new-session -d -P -F '#{pane_id}' -s "$SESSION" \
125
+ "$ENV_PREAMBLE; $CC_CMD --settings '$SETTINGS'; touch '$STATE_Q/exited'; sleep 2; tmux -L '$SOCKET_Q' kill-session -t '$SESSION_Q' 2>/dev/null")"
126
+ tmux -L "$SOCKET" set-environment -t "$SESSION" CCR_STATE_DIR "$STATE"
127
+
128
+ # The tab's ADDRESS: composed once by the launcher (CCR_TITLE = "[profile / ]name",
129
+ # both halves allow-listed), never retitled mid-session. set-titles-string is a
130
+ # literal — deliberately NOT a tmux format that would follow the session; the
131
+ # pane and the status line are the surfaces honest about movement.
132
+ if [ -n "${CCR_TITLE:-}" ]; then
133
+ tmux -L "$SOCKET" set-option -t "$SESSION" set-titles on
134
+ tmux -L "$SOCKET" set-option -t "$SESSION" set-titles-string "$CCR_TITLE"
135
+ fi
80
136
 
81
137
  # Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
82
- SIDEBAR_PANE="$(tmux split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
138
+ SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
83
139
  "$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
84
140
 
85
141
  # The sidebar is a live dashboard — there is nothing to scroll. A stray mouse-wheel
@@ -91,10 +147,59 @@ SIDEBAR_PANE="$(tmux split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}"
91
147
  # re-fires this hook with pane_in_mode=0, so the guard stops it recursing. Best-effort:
92
148
  # pane-scoped hooks need tmux >= 3.2; older tmux just skips the guard (|| true).
93
149
  if [ -n "$SIDEBAR_PANE" ]; then
94
- tmux set-hook -p -t "$SIDEBAR_PANE" pane-mode-changed \
150
+ tmux -L "$SOCKET" set-hook -p -t "$SIDEBAR_PANE" pane-mode-changed \
95
151
  "if-shell -F '#{pane_in_mode}' 'send-keys -t $SIDEBAR_PANE -X cancel'" 2>/dev/null || true
96
152
  fi
97
153
 
98
- tmux select-pane -t "$SESSION:0.0"
99
- tmux source-file -t "$SESSION" "$RUN_CONF"
100
- tmux attach -t "$SESSION"
154
+ # F2 /clear: the one hotkey ccr ships. The text is a CONSTANT in this script —
155
+ # never configuration, never a prompt file, never blob content (the pane
156
+ # subsystem has no path to a key binding at all; docs/PANE-CONTRACT.md). It
157
+ # targets the pane id captured above, because a relative index like `.0`
158
+ # retargets after any split or swap. confirm-before makes a stray F2 cost one
159
+ # keypress rather than a whole context. If no pane id came back (a tmux too old
160
+ # for `new-session -P`), NO hotkey is bound — never an approximate target.
161
+ if [ -n "$CLAUDE_PANE" ]; then
162
+ printf "bind-key -n F2 confirm-before -p 'send /clear to Claude? (y/n) ' \"send-keys -t %s '/clear' Enter\"\n" \
163
+ "$CLAUDE_PANE" >> "$RUN_CONF"
164
+ fi
165
+
166
+ # F3 → cycle the sidebar's view (economy ⇄ each configured external pane).
167
+ # It runs `ccr cycle-view`, which records a request the sidecar picks up on its
168
+ # next tick; the sidecar never reads a keystroke itself, because an input
169
+ # channel is precisely the capability the pane threat model denies it
170
+ # (docs/PANE-CONTRACT.md). No confirm gate: cycling costs nothing and is undone
171
+ # by pressing again.
172
+ #
173
+ # The paths go in a generated SCRIPT, not into the binding. $STATE, $REPO and
174
+ # $NODE derive from $HOME, $CCR_STATE_DIR and the checkout location — none of
175
+ # which this script validates the way it validates a profile name — and a lone
176
+ # apostrophe in any of them used to close the quoting so the rest ran as a
177
+ # command (reproduced 2026-08-02, and again after a first "fix").
178
+ #
179
+ # Shell-escaping alone is NOT enough here, which is the subtle part: a binding
180
+ # in this file passes through TWO parsers. tmux reads the config line first and
181
+ # processes backslashes inside its double quotes, so a shell-level '\'' arrives
182
+ # at sh already stripped to '' — closing the quote after all. Escaping correctly
183
+ # for both layers at once is the kind of thing that looks right and isn't.
184
+ #
185
+ # So: one parsing layer each. The helper script holds the paths with ordinary
186
+ # shell quoting (sq is exactly right for that), and the config line names only
187
+ # the helper's own mktemp path inside tmux SINGLE quotes, where tmux performs no
188
+ # escape processing at all. If that path could itself contain a quote we bind
189
+ # nothing rather than emit a line we cannot reason about.
190
+ CYCLE_SH="$(mktemp "${TMPDIR:-/tmp}/ccr-cycle.XXXXXX")"
191
+ trap 'rm -f "$RUN_CONF" "$CYCLE_SH"' EXIT
192
+ {
193
+ printf '#!/bin/sh\n'
194
+ printf "exec '%s' '%s' cycle-view --state-dir '%s'\n" \
195
+ "$(sq "$NODE")" "$(sq "$REPO/bin/ccr.js")" "$(sq "$STATE")"
196
+ } > "$CYCLE_SH"
197
+ chmod 700 "$CYCLE_SH"
198
+ case "$CYCLE_SH" in
199
+ *\'*) echo "ccr: TMPDIR contains a quote — F3 (cycle view) not bound" >&2 ;;
200
+ *) printf "bind-key -n F3 run-shell '%s'\n" "$CYCLE_SH" >> "$RUN_CONF" ;;
201
+ esac
202
+
203
+ tmux -L "$SOCKET" select-pane -t "$SESSION:0.0"
204
+ tmux -L "$SOCKET" source-file -t "$SESSION" "$RUN_CONF"
205
+ 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
@@ -33,7 +33,8 @@ const { parseResetsAt } = require('./burn');
33
33
  const { modelScope } = require('./rate-limits');
34
34
 
35
35
  const MAX_SNAPSHOT_BYTES = 1_000_000; // a status JSON is a few KB; bound parse/disk
36
- const MAX_PROFILES = 32; // sanity cap on how many siblings we scan
36
+ const MAX_PROFILES = 32; // sanity cap on how many siblings we merge
37
+ const MAX_SCAN_ENTRIES = 512; // sanity cap on how many dir entries we inspect
37
38
 
38
39
  /**
39
40
  * Canonical reset instant for fingerprinting/matching — tolerant of CC reporting
@@ -127,12 +128,14 @@ function readSiblingRateLimits(file) {
127
128
  * reconcile the local meters against them. Best-effort — returns `localRl` on any
128
129
  * problem so it can wrap the render path without a guard at the call site.
129
130
  *
130
- * Engages ONLY for the launcher's profile layout (`~/.ccr/<profile>`): the state
131
- * dir's parent must be `~/.ccr`. For ad-hoc `~/.ccr` or a custom CCR_STATE_DIR we
132
- * have no sibling set to trust, so we behave exactly as before (no merge).
131
+ * Engages ONLY for the launcher's layout: instances under `~/.ccr/instances/`
132
+ * (the 0.4.0 container/member split src/instance-slot.js). A custom
133
+ * CCR_STATE_DIR elsewhere has no sibling set to trust, so it behaves as
134
+ * before (no merge). There is no slot-1 special case any more: slot 1 is an
135
+ * ordinary member of instances/, which is exactly why the layout changed.
133
136
  *
134
137
  * @param {any} localRl the local snapshot's `rate_limits`
135
- * @param {string} stateDir the local profile's state dir (CCR_STATE_DIR)
138
+ * @param {string} stateDir the local instance's state dir (CCR_STATE_DIR)
136
139
  * @param {{ home?: string }} [opts]
137
140
  * @returns {any}
138
141
  */
@@ -140,21 +143,26 @@ function freshenAccountLimits(localRl, stateDir, opts = {}) {
140
143
  try {
141
144
  if (!localRl || typeof localRl !== 'object') return localRl;
142
145
  const home = opts.home || os.homedir();
143
- const root = path.dirname(path.resolve(stateDir));
144
- if (root !== path.resolve(path.join(home, '.ccr'))) return localRl; // not a profile layout
146
+ const root = path.resolve(path.join(home, '.ccr', 'instances'));
147
+ const self = path.resolve(stateDir);
148
+ if (path.dirname(self) !== root) return localRl; // not the launcher's layout
145
149
  const selfFile = path.resolve(path.join(stateDir, 'last-status.json'));
146
150
 
147
151
  /** @type {any[]} */
148
152
  const siblings = [];
153
+ // Bound the WALK, not just the harvest: MAX_PROFILES alone counts collected
154
+ // siblings, so entries that yield nothing — slot dirs with no snapshot yet,
155
+ // junk — would all be stat'd on every tick. This runs inside the sidecar's
156
+ // ~1s draw loop, so the scan stays capped by entries seen even though
157
+ // instances/ holds only instance dirs under the 0.4.0 layout.
158
+ let seen = 0;
149
159
  for (const name of fs.readdirSync(root)) {
150
- if (siblings.length >= MAX_PROFILES) break;
160
+ if (siblings.length >= MAX_PROFILES || ++seen > MAX_SCAN_ENTRIES) break;
151
161
  const p = path.join(root, name);
152
162
  let st; try { st = fs.statSync(p); } catch { continue; }
153
- // A sibling profile dir (~/.ccr/<name>/last-status.json) or the ad-hoc
154
- // ~/.ccr/last-status.json file itself.
155
- const file = st.isDirectory() ? path.join(p, 'last-status.json')
156
- : (name === 'last-status.json' ? p : null);
157
- if (!file || path.resolve(file) === selfFile) continue;
163
+ if (!st.isDirectory()) continue;
164
+ const file = path.join(p, 'last-status.json');
165
+ if (path.resolve(file) === selfFile) continue;
158
166
  const rl = readSiblingRateLimits(file);
159
167
  if (rl) siblings.push(rl);
160
168
  }
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.
@@ -90,8 +91,12 @@ function run(opts = {}) {
90
91
  out.push(dim('· ccs not installed (optional — only for `ccr <profile>`)'));
91
92
  }
92
93
 
93
- // newest captured snapshot across ~/.ccr and its per-profile subdirs (state
94
- // lives under the user's home now, never world-shared /tmp).
94
+ // newest captured snapshot across the container. Instances live TWO levels
95
+ // down under the 0.4.0 layout (~/.ccr/instances/<n>/last-status.json) the
96
+ // one-level scan alone would report "no status captured" while instances run
97
+ // fine (features/instance-lifecycle.feature: "doctor finds a live instance's
98
+ // captured status"). The root and one-level entries are still scanned so a
99
+ // pre-migration home keeps diagnosing.
95
100
  const ccrDir = path.join(homedir, '.ccr');
96
101
  const dirs = [ccrDir];
97
102
  try {
@@ -100,12 +105,20 @@ function run(opts = {}) {
100
105
  try { if (fs.statSync(sub).isDirectory()) dirs.push(sub); } catch { /* ignore */ }
101
106
  }
102
107
  } catch { /* none */ }
108
+ try {
109
+ const inst = path.join(ccrDir, 'instances');
110
+ for (const d of fs.readdirSync(inst)) {
111
+ const sub = path.join(inst, d);
112
+ try { if (fs.statSync(sub).isDirectory()) dirs.push(sub); } catch { /* ignore */ }
113
+ }
114
+ } catch { /* none */ }
103
115
  let newest = null;
104
116
  for (const d of dirs) {
105
117
  try { const m = fs.statSync(path.join(d, 'last-status.json')).mtimeMs; if (!newest || m > newest.m) newest = { d, m }; } catch { /* none */ }
106
118
  }
107
119
  if (newest) {
108
120
  const ageMin = Math.round((Date.now() - newest.m) / 60000);
121
+ /** @type {string[]} */
109
122
  let keys = [];
110
123
  try { keys = Object.keys(JSON.parse(fs.readFileSync(path.join(newest.d, 'last-status.json'), 'utf8')).rate_limits || {}); } catch { /* ignore */ }
111
124
  // 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 });