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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/instance-name.js — choose an instance's friendly name
|
|
4
|
+
// (features/instance-naming.feature). Identity is the slot number; the name
|
|
5
|
+
// is a label on top.
|
|
6
|
+
//
|
|
7
|
+
// THE MAPPING IS LOAD-BEARING FOR SECURITY, not cosmetics. The autoname
|
|
8
|
+
// derives from a directory name, `git clone` names the directory after the
|
|
9
|
+
// repo, and the name reaches the terminal title inside an OSC escape — and
|
|
10
|
+
// some terminals can echo the title back as terminal INPUT, so a repo named
|
|
11
|
+
// "; rm -rf ~" is plain ASCII that no control-byte blocklist touches.
|
|
12
|
+
// Constraining the CHARACTER SET is the guard (src/sanitize.js strips control
|
|
13
|
+
// bytes but is a blocklist; this is an allow-list). Nobody may relax it as
|
|
14
|
+
// cosmetic.
|
|
15
|
+
//
|
|
16
|
+
// The asymmetry, ruled: a DERIVED name is mapped (the user didn't choose
|
|
17
|
+
// it); an EXPLICIT name is rejected (they typed it, and a human is right
|
|
18
|
+
// there to fix it). Collision handling differs the same way: a derived name
|
|
19
|
+
// takes the lowest free suffix among LIVE names; an explicit collision is
|
|
20
|
+
// refused — silently suffixing it would make the address the user typed
|
|
21
|
+
// resolve to someone else's instance.
|
|
22
|
+
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
|
|
26
|
+
const NAME_CHAR_RE = /[A-Za-z0-9._-]/;
|
|
27
|
+
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
28
|
+
const NAME_FILE = 'name';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Map an arbitrary directory basename into the allowed set: anything outside
|
|
32
|
+
* [A-Za-z0-9._-] becomes '-'. When the SOURCE had no legal character at all,
|
|
33
|
+
* the slot number is the name (a dir named "---" keeps its dashes — they are
|
|
34
|
+
* legal; a dir named "###" has nothing to keep).
|
|
35
|
+
*
|
|
36
|
+
* @param {string} source
|
|
37
|
+
* @param {number} slot
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function mapName(source, slot) {
|
|
41
|
+
let kept = 0;
|
|
42
|
+
const mapped = [...String(source)].map((c) => (NAME_CHAR_RE.test(c) ? (kept++, c) : '-')).join('');
|
|
43
|
+
return kept > 0 ? mapped : String(slot);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The autoname: the repository's directory name when the launch dir is inside
|
|
48
|
+
* a repo (with the bare-repo `.git` correction the pane already applies),
|
|
49
|
+
* else the launch dir's own basename — both through the mapping.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ cwd: string, slot: number }} o
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function deriveName(o) {
|
|
55
|
+
let base = path.basename(o.cwd);
|
|
56
|
+
try {
|
|
57
|
+
const found = require('./git-repo').discoverRepo(o.cwd);
|
|
58
|
+
if (found && found.found && found.root) {
|
|
59
|
+
base = path.basename(found.root);
|
|
60
|
+
if (base === '.git') base = path.basename(path.dirname(found.root));
|
|
61
|
+
}
|
|
62
|
+
} catch { /* fall back to the cwd basename */ }
|
|
63
|
+
return mapName(base, o.slot);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Names of the LIVE instances — the only set names must be unique within.
|
|
68
|
+
* Ephemerality does the reaping: a dead instance's dir (and its name file)
|
|
69
|
+
* is deleted, so its name is simply absent here.
|
|
70
|
+
*
|
|
71
|
+
* @param {{ home: string, inspect?: (dir: string) => {live: boolean, attached: boolean} }} o
|
|
72
|
+
* @returns {Set<string>}
|
|
73
|
+
*/
|
|
74
|
+
function liveNames(o) {
|
|
75
|
+
const inspect = o.inspect || require('./instance-slot').defaultInspect;
|
|
76
|
+
const root = path.join(o.home, '.ccr', 'instances');
|
|
77
|
+
/** @type {Set<string>} */
|
|
78
|
+
const names = new Set();
|
|
79
|
+
let entries; try { entries = fs.readdirSync(root); } catch { return names; }
|
|
80
|
+
for (const n of entries) {
|
|
81
|
+
if (!/^\d+$/.test(n)) continue;
|
|
82
|
+
const dir = path.join(root, n);
|
|
83
|
+
try {
|
|
84
|
+
if (!inspect(dir).live) continue;
|
|
85
|
+
const name = fs.readFileSync(path.join(dir, NAME_FILE), 'utf8').trim();
|
|
86
|
+
if (name) names.add(name);
|
|
87
|
+
} catch { /* unnamed or unreadable — nothing to reserve */ }
|
|
88
|
+
}
|
|
89
|
+
return names;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The collision suffix: per-name lowest free among live names, INDEPENDENT of
|
|
94
|
+
* the slot number — with "gitrepo" on slot 1 and "gatrepo" on slot 2, the next
|
|
95
|
+
* gitrepo lands on slot 3 but is named "gitrepo2", never "gitrepo3". The
|
|
96
|
+
* generator checks ALL live names, so a real directory named "gitrepo2"
|
|
97
|
+
* cannot collide with a generated suffix.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} base
|
|
100
|
+
* @param {Set<string>} live
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function withSuffix(base, live) {
|
|
104
|
+
if (!live.has(base)) return base;
|
|
105
|
+
for (let k = 2; ; k++) {
|
|
106
|
+
const candidate = `${base}${k}`;
|
|
107
|
+
if (!live.has(candidate)) return candidate;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Record the chosen name in the instance's own state — instance-scoped, so it
|
|
113
|
+
* dies with the session, which is what frees the name.
|
|
114
|
+
* @param {string} stateDir @param {string} name
|
|
115
|
+
*/
|
|
116
|
+
function recordName(stateDir, name) {
|
|
117
|
+
try { fs.writeFileSync(path.join(stateDir, NAME_FILE), name + '\n', { mode: 0o600 }); } catch { /* best effort */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The LIVE location half of the status-line identity: the repository's name
|
|
122
|
+
* when `cwd` is inside one, else the directory's own basename — through the
|
|
123
|
+
* same allow-list mapping as names (this string reaches the same terminal the
|
|
124
|
+
* title does). Null when nothing legal survives or cwd is unusable: the
|
|
125
|
+
* identity then shows the name alone.
|
|
126
|
+
*
|
|
127
|
+
* @param {string|undefined|null} cwd
|
|
128
|
+
* @returns {string|null}
|
|
129
|
+
*/
|
|
130
|
+
function locationFrom(cwd) {
|
|
131
|
+
if (!cwd || typeof cwd !== 'string') return null;
|
|
132
|
+
let base = path.basename(cwd);
|
|
133
|
+
try {
|
|
134
|
+
const found = require('./git-repo').discoverRepo(cwd);
|
|
135
|
+
if (found && found.found && found.root) {
|
|
136
|
+
base = path.basename(found.root);
|
|
137
|
+
if (base === '.git') base = path.basename(path.dirname(found.root));
|
|
138
|
+
}
|
|
139
|
+
} catch { /* fall back to the basename */ }
|
|
140
|
+
let kept = 0;
|
|
141
|
+
const mapped = [...base].map((c) => (NAME_CHAR_RE.test(c) ? (kept++, c) : '-')).join('');
|
|
142
|
+
return kept > 0 ? mapped : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The terminal title: `[profile / ]name`, composed ONCE at launch and never
|
|
147
|
+
* retitled — the title is the tab's ADDRESS (it must keep matching the name
|
|
148
|
+
* -i accepts), while the pane and status line are the surfaces honest about
|
|
149
|
+
* mid-session movement. Both inputs are already constrained (profile by the
|
|
150
|
+
* launcher's allow-list, name by NAME_RE), so the title needs no escaping.
|
|
151
|
+
*
|
|
152
|
+
* @param {string|undefined} profile
|
|
153
|
+
* @param {string} name
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
function composeTitle(profile, name) {
|
|
157
|
+
return profile ? `${profile} / ${name}` : name;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Everything an allocated slot needs to become a NAMED instance — shared by
|
|
162
|
+
* all three launchers so no platform ships half-lit: derive (or take) the
|
|
163
|
+
* name, record it and the profile in the instance dir, compose the title.
|
|
164
|
+
* Explicit-name validation and the collision refusal happen BEFORE launch
|
|
165
|
+
* routing (bin/ccr.js), so by the time a slot exists the name is legal.
|
|
166
|
+
*
|
|
167
|
+
* @param {{ slot: number, stateDir: string }} slot
|
|
168
|
+
* @param {{ profile?: string, name?: string|null, cwd?: string, home?: string }} [o]
|
|
169
|
+
* @returns {{ name: string, title: string }}
|
|
170
|
+
*/
|
|
171
|
+
function prepareInstance(slot, o = {}) {
|
|
172
|
+
const home = o.home || require('node:os').homedir();
|
|
173
|
+
const name = o.name != null ? o.name
|
|
174
|
+
: withSuffix(deriveName({ cwd: o.cwd || process.cwd(), slot: slot.slot }), liveNames({ home }));
|
|
175
|
+
recordName(slot.stateDir, name);
|
|
176
|
+
if (o.profile) {
|
|
177
|
+
try { fs.writeFileSync(path.join(slot.stateDir, 'profile'), o.profile + '\n', { mode: 0o600 }); } catch { /* best effort */ }
|
|
178
|
+
}
|
|
179
|
+
return { name, title: composeTitle(o.profile, name) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { NAME_RE, NAME_FILE, mapName, deriveName, liveNames, withSuffix, recordName, locationFrom, composeTitle, prepareInstance };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/instance-resolve.js — find the instance you meant
|
|
4
|
+
// (features/instance-resolution.feature).
|
|
5
|
+
//
|
|
6
|
+
// The chain, ruled: -i (an explicit target typed NOW) → CCR_STATE_DIR (an
|
|
7
|
+
// explicit choice standing since launch; refused when it names the container
|
|
8
|
+
// — that is the old container/member confusion arriving by env var) → the
|
|
9
|
+
// live instance whose launch directory contains the cwd, longest match — a
|
|
10
|
+
// TIE is not "the" instance, so it falls through — → the single live one →
|
|
11
|
+
// else list them and refuse, offering -i, "because you have no idea what the
|
|
12
|
+
// user is looking for."
|
|
13
|
+
//
|
|
14
|
+
// Every caller heads its output with the resolved NAME — the safeguard that
|
|
15
|
+
// replaced bounding names by account: the mistake a user actually makes is
|
|
16
|
+
// reading the right panel about the wrong instance.
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {{ slot: number, stateDir: string, name: string|null, launchCwd: string|null }} LiveInstance
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The live set — the only set names resolve against.
|
|
28
|
+
* @param {{ home: string, inspect?: (dir: string) => {live: boolean, attached: boolean} }} o
|
|
29
|
+
* @returns {LiveInstance[]}
|
|
30
|
+
*/
|
|
31
|
+
function listLive(o) {
|
|
32
|
+
const inspect = o.inspect || require('./instance-slot').defaultInspect;
|
|
33
|
+
const root = path.join(o.home, '.ccr', 'instances');
|
|
34
|
+
/** @type {LiveInstance[]} */
|
|
35
|
+
const out = [];
|
|
36
|
+
let entries; try { entries = fs.readdirSync(root); } catch { return out; }
|
|
37
|
+
for (const n of entries.sort((a, b) => Number(a) - Number(b))) {
|
|
38
|
+
if (!/^\d+$/.test(n)) continue;
|
|
39
|
+
const dir = path.join(root, n);
|
|
40
|
+
try {
|
|
41
|
+
if (!inspect(dir).live) continue;
|
|
42
|
+
const read = (/** @type {string} */ f) => {
|
|
43
|
+
try { return fs.readFileSync(path.join(dir, f), 'utf8').trim() || null; } catch { return null; }
|
|
44
|
+
};
|
|
45
|
+
out.push({ slot: Number(n), stateDir: dir, name: read('name'), launchCwd: read('launch-cwd') });
|
|
46
|
+
} catch { /* skip unreadable */ }
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {LiveInstance[]} live @param {string} cmd */
|
|
52
|
+
function listAndRefuse(live, cmd) {
|
|
53
|
+
const lines = ['ccr: several live instances match — say which:'];
|
|
54
|
+
for (const i of live) lines.push(` ${i.name || `slot ${i.slot}`}`);
|
|
55
|
+
lines.push(`try: ccr ${cmd} -i <name>`);
|
|
56
|
+
return { ok: /** @type {false} */ (false), error: lines.join('\n') };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{ home?: string, env?: NodeJS.ProcessEnv, cwd?: string, target?: string|null,
|
|
61
|
+
* command?: string, inspect?: (dir: string) => {live: boolean, attached: boolean} }} [o]
|
|
62
|
+
* @returns {{ ok: true, stateDir: string, name: string|null } | { ok: false, error: string, none?: boolean }}
|
|
63
|
+
*/
|
|
64
|
+
function resolveInstance(o = {}) {
|
|
65
|
+
const home = o.home || os.homedir();
|
|
66
|
+
const env = o.env || process.env;
|
|
67
|
+
const cwd = o.cwd || process.cwd();
|
|
68
|
+
const cmd = o.command || 'economy';
|
|
69
|
+
|
|
70
|
+
const live = listLive({ home, inspect: o.inspect });
|
|
71
|
+
|
|
72
|
+
// 1. An explicit -i target, from the live set only. A typo matches nothing
|
|
73
|
+
// and errors — it reaches something else only by landing exactly on another
|
|
74
|
+
// live name.
|
|
75
|
+
if (o.target != null) {
|
|
76
|
+
const hit = live.find((i) => i.name === o.target);
|
|
77
|
+
if (!hit) return { ok: false, error: `ccr: no live instance named '${o.target}'` };
|
|
78
|
+
return { ok: true, stateDir: hit.stateDir, name: hit.name };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. An explicit state dir standing since launch — unless it names the
|
|
82
|
+
// container itself.
|
|
83
|
+
if (env.CCR_STATE_DIR) {
|
|
84
|
+
const dir = path.resolve(env.CCR_STATE_DIR);
|
|
85
|
+
const container = path.resolve(home, '.ccr');
|
|
86
|
+
if (dir === container || dir === path.join(container, 'instances')) {
|
|
87
|
+
return { ok: false, error: 'ccr: the ccr home is a container, not an instance — point CCR_STATE_DIR at an instance dir or use -i' };
|
|
88
|
+
}
|
|
89
|
+
let name = null;
|
|
90
|
+
try { name = fs.readFileSync(path.join(dir, 'name'), 'utf8').trim() || null; } catch { /* unnamed */ }
|
|
91
|
+
return { ok: true, stateDir: dir, name };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 3. Launch-directory containment, longest match; a tie falls through.
|
|
95
|
+
const here = path.resolve(cwd);
|
|
96
|
+
const containing = live.filter((i) => {
|
|
97
|
+
if (!i.launchCwd) return false;
|
|
98
|
+
const base = path.resolve(i.launchCwd);
|
|
99
|
+
return here === base || here.startsWith(base + path.sep);
|
|
100
|
+
});
|
|
101
|
+
if (containing.length) {
|
|
102
|
+
const longest = Math.max(...containing.map((i) => path.resolve(String(i.launchCwd)).length));
|
|
103
|
+
const best = containing.filter((i) => path.resolve(String(i.launchCwd)).length === longest);
|
|
104
|
+
if (best.length === 1) return { ok: true, stateDir: best[0].stateDir, name: best[0].name };
|
|
105
|
+
return listAndRefuse(best, cmd);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. The single live one is unambiguous from anywhere.
|
|
109
|
+
if (live.length === 1) return { ok: true, stateDir: live[0].stateDir, name: live[0].name };
|
|
110
|
+
if (live.length === 0) return { ok: false, none: true, error: 'ccr: no live instance — run `ccr` to launch one' };
|
|
111
|
+
|
|
112
|
+
// 5. Several candidates, no signal.
|
|
113
|
+
return listAndRefuse(live, cmd);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { resolveInstance, listLive };
|