claude-code-runrate 0.1.0 → 0.2.1
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 +35 -5
- package/bin/ccr.js +35 -13
- package/package.json +11 -4
- package/src/doctor.js +33 -23
- package/src/launch-vscode.js +288 -0
- package/src/launch-win.js +457 -0
- package/src/render/economy.js +5 -1
- package/src/render/shared.js +29 -1
- package/src/settings-inject.js +77 -0
- package/src/sidecar.js +133 -20
package/src/sidecar.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/sidecar.js — the live economy panel that runs in the tmux sidebar.
|
|
4
4
|
// Reads the per-session snapshot that `ccr statusline` writes (CCR_STATE_DIR),
|
|
5
5
|
// re-renders the economy screen every second (so the imminent band flashes),
|
|
6
|
-
// and shows a clean ended
|
|
6
|
+
// and shows a clean ended/waiting state. Pure Node, zero dependencies.
|
|
7
7
|
|
|
8
8
|
const fs = require('node:fs');
|
|
9
9
|
const path = require('node:path');
|
|
@@ -11,11 +11,10 @@ const os = require('node:os');
|
|
|
11
11
|
const { normalizeStatus } = require('./normalize');
|
|
12
12
|
const { renderEconomy } = require('./render/economy');
|
|
13
13
|
const { renderFeed } = require('./render/feed');
|
|
14
|
+
const { clampVisible } = require('./render/shared');
|
|
14
15
|
const { currentTranscriptPath, readNewLines, parseEvents } = require('./transcripts');
|
|
15
16
|
|
|
16
17
|
const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
|
|
17
|
-
const SNAPSHOT = path.join(STATE_DIR, 'last-status.json');
|
|
18
|
-
const EXITED = path.join(STATE_DIR, 'exited');
|
|
19
18
|
|
|
20
19
|
// Live feed accumulator: tail the current transcript incrementally (by byte
|
|
21
20
|
// offset) and roll up tool/skill events + per-session stats. Reset on session
|
|
@@ -57,38 +56,152 @@ function draw(/** @type {string} */ s) {
|
|
|
57
56
|
process.stdout.write('\x1b[H' + s.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Compose the screen for one tick — the ended / waiting / unreadable / live
|
|
61
|
+
* states — and return it as a string (no I/O to stdout). Pure enough to test:
|
|
62
|
+
* the only inputs are the state dir on disk, `now`, and the pane width `cols`.
|
|
63
|
+
*
|
|
64
|
+
* `cols` is the pane's visible column count (process.stdout.columns); every line
|
|
65
|
+
* is clamped to it so a wide row can't soft-wrap and corrupt the cursor-home
|
|
66
|
+
* redraw in a narrow cmd/PowerShell/split pane. Omit it (non-TTY) for no clamp.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} stateDir
|
|
69
|
+
* @param {{ now?: number, cols?: number }} [opts]
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function composeFrame(stateDir, opts = {}) {
|
|
73
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
74
|
+
const cols = opts.cols;
|
|
75
|
+
const clamp = (/** @type {string} */ s) =>
|
|
76
|
+
(typeof cols === 'number' && cols > 0
|
|
77
|
+
? s.split('\n').map((l) => clampVisible(l, cols)).join('\n')
|
|
78
|
+
: s);
|
|
79
|
+
const snapshot = path.join(stateDir, 'last-status.json');
|
|
80
|
+
const exited = path.join(stateDir, 'exited');
|
|
81
|
+
|
|
82
|
+
if (fs.existsSync(exited)) return clamp(bold('ccr') + ' ' + dim('session ended') + '\n');
|
|
62
83
|
let raw = '';
|
|
63
|
-
try { raw = fs.readFileSync(
|
|
64
|
-
if (!raw.trim())
|
|
84
|
+
try { raw = fs.readFileSync(snapshot, 'utf8'); } catch { /* none yet */ }
|
|
85
|
+
if (!raw.trim()) return clamp(dim('ccr · waiting for the first status tick…') + '\n');
|
|
65
86
|
let state;
|
|
66
|
-
try { state = JSON.parse(raw); } catch {
|
|
87
|
+
try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
|
|
67
88
|
let out;
|
|
68
89
|
try {
|
|
69
|
-
out = renderEconomy(normalizeStatus(state), { tick: Math.floor(
|
|
90
|
+
out = renderEconomy(normalizeStatus(state), { tick: Math.floor(now / 1000) % 2 === 0 });
|
|
70
91
|
} catch (e) {
|
|
71
92
|
out = dim('ccr render error: ' + (e && e instanceof Error ? e.message : String(e)));
|
|
72
93
|
}
|
|
73
94
|
// Live tool/skills feed below the panel — best-effort; never break the panel.
|
|
95
|
+
// Its inner width tracks the pane so args truncate cleanly (the clamp below is
|
|
96
|
+
// the hard safety net regardless).
|
|
74
97
|
try {
|
|
75
98
|
const tpath = currentTranscriptPath(state);
|
|
76
99
|
if (tpath) {
|
|
77
|
-
const
|
|
100
|
+
const feedWidth = typeof cols === 'number' && cols > 0 ? Math.max(20, Math.min(48, cols - 2)) : 48;
|
|
101
|
+
const feedStr = renderFeed(updateFeed(tpath), { max: 6, width: feedWidth });
|
|
78
102
|
if (feedStr) out += '\n\n' + feedStr;
|
|
79
103
|
}
|
|
80
104
|
} catch { /* feed is optional */ }
|
|
81
|
-
|
|
105
|
+
return clamp(out.endsWith('\n') ? out : out + '\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the column budget to clamp the panel to. `process.stdout.columns` is
|
|
110
|
+
* authoritative when present (live resize re-flows on the next frame), but inside
|
|
111
|
+
* the Windows launcher's `cmd /c` conpty pane it is unreliable — often undefined
|
|
112
|
+
* or the FULL window width rather than the narrow split. So the launcher injects
|
|
113
|
+
* the computed pane width as CCR_SIDECAR_COLS; we take the SMALLER of the two,
|
|
114
|
+
* which is safe whichever is wrong: a bogus full-width `columns` can't defeat the
|
|
115
|
+
* hint, and a missing hint (Linux/tmux, standalone `ccr sidecar`) leaves the live
|
|
116
|
+
* value untouched. Returns undefined only when neither is known (no clamp).
|
|
117
|
+
*
|
|
118
|
+
* @returns {number|undefined}
|
|
119
|
+
*/
|
|
120
|
+
function resolveCols() {
|
|
121
|
+
const live = process.stdout.columns;
|
|
122
|
+
const haveLive = typeof live === 'number' && live > 0;
|
|
123
|
+
const hint = parseInt(process.env.CCR_SIDECAR_COLS || '', 10);
|
|
124
|
+
const haveHint = Number.isFinite(hint) && hint > 0;
|
|
125
|
+
if (haveLive && haveHint) return Math.min(live, hint);
|
|
126
|
+
if (haveHint) return hint;
|
|
127
|
+
return haveLive ? live : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function frame() {
|
|
131
|
+
// Read columns each tick so a live resize re-flows on the next frame.
|
|
132
|
+
draw(composeFrame(STATE_DIR, { now: Date.now(), cols: resolveCols() }));
|
|
82
133
|
}
|
|
83
134
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The live loop. With `exitOnEnd` (the Windows launcher passes `--exit-on-end`),
|
|
137
|
+
* the sidecar closes its own pane as soon as the `exited` sentinel appears — so a
|
|
138
|
+
* `cmd /c` pane folds away on session end rather than lingering, matching the tmux
|
|
139
|
+
* launcher's kill-session sweep. Without it (Linux/tmux, standalone `ccr sidecar`)
|
|
140
|
+
* the loop runs until signalled, exactly as before.
|
|
141
|
+
*
|
|
142
|
+
* For the fastest, correctly-ordered close, the sentinel is POLLED faster than the
|
|
143
|
+
* render cadence when `exitOnEnd` (a redraw is ~1s; waiting a full second just to
|
|
144
|
+
* NOTICE the exit would dominate the close time). A single interval ticks at
|
|
145
|
+
* `pollMs`; the expensive redraw is throttled to ~1s, while the cheap sentinel
|
|
146
|
+
* check runs every poll. On exit we paint "session ended" once and close after a
|
|
147
|
+
* short `graceMs`. The launcher's pane 0 then lingers slightly longer (see
|
|
148
|
+
* buildWtArgs) so this RIGHT pane closes first and the border sweeps left→right.
|
|
149
|
+
* Side effects are injectable so the end-sweep is unit-testable.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number,
|
|
152
|
+
* tick?: () => void, sentinelExists?: () => boolean,
|
|
153
|
+
* setIntervalFn?: Function, setTimeoutFn?: Function,
|
|
154
|
+
* clearIntervalFn?: Function, clearTimeoutFn?: Function,
|
|
155
|
+
* exit?: () => void, onSignal?: (sig: string, handler: () => void) => void }} [opts]
|
|
156
|
+
* @returns {() => void} the stop handler (exposed for tests)
|
|
157
|
+
*/
|
|
158
|
+
function run(opts = {}) {
|
|
159
|
+
const stateDir = opts.stateDir || STATE_DIR;
|
|
160
|
+
const exitOnEnd = opts.exitOnEnd != null ? opts.exitOnEnd : (process.env.CCR_SIDECAR_EXIT_ON_END === '1');
|
|
161
|
+
// Tiny grace so the "session ended" frame paints before we close — kept short
|
|
162
|
+
// since this drives the close speed (the launcher tunes pane 0 to outlast it).
|
|
163
|
+
const graceMs = opts.graceMs != null ? opts.graceMs : 200;
|
|
164
|
+
const tick = opts.tick || frame;
|
|
165
|
+
const sentinelExists = opts.sentinelExists || (() => fs.existsSync(path.join(stateDir, 'exited')));
|
|
166
|
+
const setIntervalFn = opts.setIntervalFn || setInterval;
|
|
167
|
+
const setTimeoutFn = opts.setTimeoutFn || setTimeout;
|
|
168
|
+
const clearIntervalFn = opts.clearIntervalFn || clearInterval;
|
|
169
|
+
const clearTimeoutFn = opts.clearTimeoutFn || clearTimeout;
|
|
170
|
+
const exit = opts.exit || (() => process.exit(0));
|
|
171
|
+
const onSignal = opts.onSignal || ((sig, handler) => process.on(sig, handler));
|
|
172
|
+
|
|
173
|
+
// Poll the sentinel fast when we have to detect the end; keep the redraw at ~1s.
|
|
174
|
+
const RENDER_MS = 1000;
|
|
175
|
+
const pollMs = exitOnEnd ? 120 : RENDER_MS;
|
|
176
|
+
|
|
177
|
+
let id = null;
|
|
178
|
+
let endTimer = null;
|
|
179
|
+
let sinceRender = RENDER_MS; // render on the first loop
|
|
180
|
+
const stop = () => {
|
|
181
|
+
if (id != null) clearIntervalFn(id);
|
|
182
|
+
if (endTimer != null) clearTimeoutFn(endTimer);
|
|
183
|
+
exit();
|
|
184
|
+
};
|
|
185
|
+
const checkEnd = () => {
|
|
186
|
+
// Once the session has ended, paint it once then sweep this pane closed.
|
|
187
|
+
if (exitOnEnd && endTimer == null && sentinelExists()) {
|
|
188
|
+
tick();
|
|
189
|
+
endTimer = setTimeoutFn(stop, graceMs);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const loop = () => {
|
|
193
|
+
sinceRender += pollMs;
|
|
194
|
+
if (sinceRender >= RENDER_MS) { sinceRender = 0; tick(); }
|
|
195
|
+
checkEnd();
|
|
196
|
+
};
|
|
197
|
+
loop();
|
|
198
|
+
id = setIntervalFn(loop, pollMs);
|
|
199
|
+
onSignal('SIGINT', stop);
|
|
200
|
+
onSignal('SIGTERM', stop);
|
|
201
|
+
return stop;
|
|
90
202
|
}
|
|
91
203
|
|
|
92
|
-
// `updateFeed`
|
|
93
|
-
// reset
|
|
94
|
-
|
|
204
|
+
// `updateFeed` + `composeFrame` are exported for tests (the incremental tail +
|
|
205
|
+
// session-switch reset and the ended/waiting/render states are the subtle
|
|
206
|
+
// parts); the live loop uses `run`.
|
|
207
|
+
module.exports = { run, updateFeed, composeFrame };
|