claude-code-runrate 0.2.4 → 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 +56 -0
- package/bin/ccr.js +16 -0
- package/package.json +1 -1
- package/scripts/launch.sh +87 -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/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 +148 -19
- 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
|
|
@@ -107,6 +110,59 @@ In `~/.claude/settings.json`:
|
|
|
107
110
|
Code calls the status line frequently, and a resolved binary avoids per-tick
|
|
108
111
|
latency.)
|
|
109
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
|
+
|
|
110
166
|
## Development
|
|
111
167
|
|
|
112
168
|
This project is built **BDD-first**: the Gherkin in [`features/`](features/) is
|
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,17 +86,27 @@ 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
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
tmux -L "$SOCKET" kill-session -t "$SESSION" 2>/dev/null || true
|
|
90
|
+
|
|
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"
|
|
80
107
|
|
|
81
108
|
# 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}' \
|
|
109
|
+
SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
|
|
83
110
|
"$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
|
|
84
111
|
|
|
85
112
|
# The sidebar is a live dashboard — there is nothing to scroll. A stray mouse-wheel
|
|
@@ -91,10 +118,59 @@ SIDEBAR_PANE="$(tmux split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}"
|
|
|
91
118
|
# re-fires this hook with pane_in_mode=0, so the guard stops it recursing. Best-effort:
|
|
92
119
|
# pane-scoped hooks need tmux >= 3.2; older tmux just skips the guard (|| true).
|
|
93
120
|
if [ -n "$SIDEBAR_PANE" ]; then
|
|
94
|
-
tmux set-hook -p -t "$SIDEBAR_PANE" pane-mode-changed \
|
|
121
|
+
tmux -L "$SOCKET" set-hook -p -t "$SIDEBAR_PANE" pane-mode-changed \
|
|
95
122
|
"if-shell -F '#{pane_in_mode}' 'send-keys -t $SIDEBAR_PANE -X cancel'" 2>/dev/null || true
|
|
96
123
|
fi
|
|
97
124
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
173
|
+
|
|
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/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
|
}
|