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/README.md +134 -2
- package/bin/ccr.js +235 -22
- package/package.json +4 -2
- package/scripts/launch.sh +121 -16
- package/sidecar/ccr.tmux.conf +12 -4
- package/src/account-limits.js +21 -13
- package/src/burn.js +7 -3
- package/src/cycle-view.js +78 -0
- package/src/doctor.js +15 -2
- package/src/economy-model.js +3 -0
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +40 -9
- package/src/liveness.js +18 -1
- package/src/migrate.js +155 -0
- 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/git-pane.js +345 -0
- package/src/render/pane.js +186 -0
- package/src/render/shared.js +115 -11
- package/src/render/statusline.js +45 -4
- package/src/safe-read.js +82 -0
- package/src/sanitize.js +45 -4
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +277 -23
- package/src/state-dir.js +44 -1
- package/src/transcripts.js +31 -15
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
tmux
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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"
|
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/account-limits.js
CHANGED
|
@@ -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
|
|
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
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
|
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.
|
|
144
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
|
94
|
-
//
|
|
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
|
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 });
|