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 +70 -3
- package/bin/ccr.js +16 -0
- package/package.json +1 -1
- package/scripts/launch.sh +100 -11
- package/sidecar/ccr.tmux.conf +12 -4
- package/src/burn.js +7 -3
- package/src/cycle-view.js +78 -0
- package/src/doctor.js +2 -0
- package/src/economy-model.js +3 -0
- package/src/launch-vscode.js +44 -11
- package/src/liveness.js +18 -1
- package/src/normalize.js +24 -5
- package/src/pane-blob.js +249 -0
- package/src/pane-config.js +109 -0
- package/src/rate-limits.js +12 -2
- package/src/render/economy.js +10 -2
- package/src/render/pane.js +186 -0
- package/src/render/shared.js +66 -10
- package/src/render/statusline.js +3 -0
- package/src/safe-read.js +66 -0
- package/src/sanitize.js +45 -4
- package/src/sidecar.js +238 -17
- package/src/transcripts.js +31 -15
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
|
|
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.
|
|
110
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
tmux
|
|
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
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
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"
|
package/sidecar/ccr.tmux.conf
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
# sidecar/ccr.tmux.conf —
|
|
2
|
-
#
|
|
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
|
|
5
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
package/src/economy-model.js
CHANGED
|
@@ -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 });
|
package/src/launch-vscode.js
CHANGED
|
@@ -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
|
-
|
|
143
|
-
|
|
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).
|
|
146
|
-
//
|
|
147
|
-
//
|
|
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 ${
|
|
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
|
-
|
|
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)
|
|
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:
|
|
27
|
-
durationMin:
|
|
45
|
+
costUsd: num(cost.total_cost_usd),
|
|
46
|
+
durationMin: durationMs != null ? durationMs / 60000 : null,
|
|
28
47
|
branch: null,
|
|
29
48
|
};
|
|
30
49
|
}
|