claude-code-runrate 0.3.0 → 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.
@@ -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 };