@crouton-kit/crouter 0.3.78 → 0.3.79

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.
Files changed (72) hide show
  1. package/dist/build-root.d.ts +12 -4
  2. package/dist/build-root.js +25 -6
  3. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
  8. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  9. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  10. package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
  11. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  12. package/dist/clients/attach/attach-cmd.js +849 -803
  13. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  14. package/dist/clients/attach/auth-pickers.js +64 -15
  15. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  16. package/dist/clients/attach/graph-overlay.js +83 -33
  17. package/dist/commands/human/queue.js +3 -4
  18. package/dist/commands/pkg/plugin-inspect.js +22 -1
  19. package/dist/commands/pkg/plugin-manage.js +31 -9
  20. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  21. package/dist/commands/sys/doctor.js +42 -4
  22. package/dist/commands/sys/setup-core.d.ts +37 -0
  23. package/dist/commands/sys/setup-core.js +138 -1
  24. package/dist/commands/sys/setup.d.ts +88 -0
  25. package/dist/commands/sys/setup.js +915 -171
  26. package/dist/commands/view-pick.d.ts +4 -0
  27. package/dist/commands/view-pick.js +17 -7
  28. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  29. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  30. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  31. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  32. package/dist/core/__tests__/command-plugins.test.js +444 -0
  33. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  35. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  36. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  37. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  38. package/dist/core/canvas/browse/app.d.ts +6 -0
  39. package/dist/core/canvas/browse/app.js +71 -41
  40. package/dist/core/command-plugins/adapter.d.ts +15 -0
  41. package/dist/core/command-plugins/adapter.js +145 -0
  42. package/dist/core/command-plugins/compose.d.ts +5 -0
  43. package/dist/core/command-plugins/compose.js +56 -0
  44. package/dist/core/command-plugins/discovery.d.ts +104 -0
  45. package/dist/core/command-plugins/discovery.js +565 -0
  46. package/dist/core/config.d.ts +2 -5
  47. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  48. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  49. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  50. package/dist/core/keybindings/catalog.d.ts +3 -3
  51. package/dist/core/keybindings/catalog.js +2 -1
  52. package/dist/core/profiles/select.d.ts +6 -0
  53. package/dist/core/profiles/select.js +86 -52
  54. package/dist/core/provider-management.d.ts +12 -0
  55. package/dist/core/provider-management.js +24 -0
  56. package/dist/core/runtime/broker.js +7 -6
  57. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  58. package/dist/core/runtime/pi-vendored.js +14 -0
  59. package/dist/core/runtime/recap.d.ts +1 -1
  60. package/dist/core/runtime/recap.js +50 -25
  61. package/dist/core/runtime/session-list-cache.d.ts +10 -0
  62. package/dist/core/runtime/session-list-cache.js +94 -26
  63. package/dist/core/runtime/spawn.js +1 -13
  64. package/dist/core/runtime/tmux.js +2 -1
  65. package/dist/core/scope.js +27 -4
  66. package/dist/core/subscription-state.d.ts +90 -0
  67. package/dist/core/subscription-state.js +762 -0
  68. package/dist/daemon/crtrd.js +253 -12
  69. package/dist/pi-extensions/canvas-recap.js +43 -17
  70. package/dist/types.d.ts +6 -13
  71. package/dist/types.js +0 -3
  72. package/package.json +7 -3
@@ -4,7 +4,7 @@
4
4
  // One entry point: generateRecap — async (execFile, non-blocking), mirroring
5
5
  // naming.ts's headless-`pi -p` pattern exactly. It runs the live conversation's
6
6
  // working model over the literal back-and-forth (the caller concatenates user and
7
- // assistant text) with a stripped-
7
+ // assistant text, plus the node's roadmap when one exists) with a stripped-
8
8
  // down pi invocation (no tools/session/context/extensions/skills), so it's fast
9
9
  // and side-effect free and can never recurse into another spawn.
10
10
  //
@@ -24,30 +24,49 @@ const RECAP_TIMEOUT_MS = 25_000;
24
24
  * turn, avoiding a dead hard-pinned provider. CRTR_RECAP_MODEL overrides both. */
25
25
  const DEFAULT_RECAP_MODEL = 'anthropic/claude-haiku-4-5';
26
26
  const RECAP_SYSTEM_PROMPT = 'You brief a developer returning to a paused coding-agent conversation. They run ' +
27
- 'many agent sessions in parallel and have lost this one\'s thread; the brief is ' +
28
- 'pinned above their input box and must re-orient them in seconds, without ' +
29
- 're-reading the transcript. You are given the literal user/agent back-and-forth ' +
30
- '(tool output omitted, most recent last).\n' +
27
+ 'many agent sessions in parallel and have lost this one\'s thread: they no longer ' +
28
+ 'know what this conversation is about or what they originally asked for. The ' +
29
+ 'brief exists to solve exactly that pinned above their input box, it must tell ' +
30
+ 'them at a glance what this conversation is FOR and where it actually stands. ' +
31
+ 'You are given the first and most recent messages of the literal user/agent ' +
32
+ 'back-and-forth (tool output omitted), and the agent\'s roadmap file when one ' +
33
+ 'exists.\n' +
31
34
  '\n' +
32
- 'Output EXACTLY one concise plain-text briefing: no labels, numbering, bullets, ' +
33
- 'or line breaks. In one or two sentences, explain the overall goal, what has ' +
34
- 'actually happened, and the current next move or blocker. If the agent is ' +
35
- 'waiting on the developer, make the needed answer clear. Be concrete: use the ' +
36
- 'actual file names, commands, branches, option labels, and error messages from ' +
37
- 'the conversation a developer re-orients on specifics, never abstractions. ' +
38
- 'Weight the end of the conversation, never narrate ("the user asked…"), hedge, ' +
39
- 'or pad. If the conversation has barely started, still emit your best briefing. ' +
40
- 'Output JUST the briefing.';
41
- /** Put the conversation FIRST in a delimited block, then the instruction, so the
42
- * model reads the content before being told what to do. */
43
- function recapUserPrompt(conversation) {
44
- return `<conversation>\n${conversation}\n</conversation>\n\nWrite the concise returning-developer briefing for the conversation above. Output JUST the briefing.`;
35
+ 'Output EXACTLY two plain-text lines, nothing else:\n' +
36
+ 'Goal: <what was requested the original ask / what this conversation is for>\n' +
37
+ 'State: <catch-them-up: what is ACTUALLY going on right now>\n' +
38
+ '\n' +
39
+ 'Both lines are extremely short telegraphic fragments, roughly 8-15 words, no ' +
40
+ 'filler. The State line must NOT restate the most recent message — the last ' +
41
+ 'message is often mid-progress, a question, or noise; instead synthesize the ' +
42
+ 'real position: what has landed, what is in flight, and the live blocker or ' +
43
+ 'needed answer. If the agent is waiting on the developer, say exactly what for. ' +
44
+ 'Be concrete (real file names, branches, SHAs, error messages one specific ' +
45
+ 'anchor beats abstractions). Never narrate turn by turn, hedge, or pad. If the ' +
46
+ 'conversation has barely started, still emit your best two lines.\n' +
47
+ '\n' +
48
+ 'Examples of the shape (from other conversations):\n' +
49
+ 'Goal: Validate + commit the Unit 3 attach-keybinding migration in this worktree.\n' +
50
+ 'State: Done — 17/17 tests green, committed and rebased onto main.\n' +
51
+ '\n' +
52
+ 'Goal: Build the trimmed-v1 command-plugin seam (orchestrating phases).\n' +
53
+ 'State: Phase 1 landed; waiting on the core-seam worker\'s final.\n' +
54
+ '\n' +
55
+ 'Goal: Standing personal assistant — iMessage wakes, Reddit scans, small tasks.\n' +
56
+ 'State: Idle — waiting on you: hook pick, map export, Omi timing.';
57
+ /** Put the content FIRST in delimited blocks (roadmap, then conversation), then
58
+ * the instruction, so the model reads the content before being told what to do. */
59
+ function recapUserPrompt(conversation, roadmap) {
60
+ const rm = roadmap !== undefined && roadmap.trim() !== ''
61
+ ? `<roadmap>\n${roadmap.trim()}\n</roadmap>\n\n`
62
+ : '';
63
+ return `${rm}<conversation>\n${conversation}\n</conversation>\n\nWrite the two-line Goal:/State: briefing for the conversation above. Output JUST the two lines.`;
45
64
  }
46
65
  /** The pi argv for a headless recap request. Stripped down (no tools, session,
47
66
  * context files, extensions, skills, templates, themes) so it's fast and
48
67
  * side-effect free. Thinking stays off; model precedence is explicit env
49
68
  * override → the conversation's live working model → the small fallback. */
50
- function recapArgs(conversation, liveModel) {
69
+ function recapArgs(conversation, liveModel, roadmap) {
51
70
  const override = process.env['CRTR_RECAP_MODEL'];
52
71
  // An explicit operator override wins. Otherwise prefer the live conversation's
53
72
  // current model: it just completed a successful turn, while a hard-pinned
@@ -71,14 +90,20 @@ function recapArgs(conversation, liveModel) {
71
90
  '--thinking', 'off',
72
91
  '--model', model,
73
92
  '--system-prompt', RECAP_SYSTEM_PROMPT,
74
- recapUserPrompt(conversation),
93
+ recapUserPrompt(conversation, roadmap),
75
94
  ];
76
95
  return argv;
77
96
  }
78
- /** Normalize model stdout to one display string. Newlines are whitespace rather
79
- * than separate fields; the viewer wraps this string to its available width. */
97
+ /** Normalize model stdout to the two-line `Goal:`/`State:` briefing. Collapses
98
+ * intra-line whitespace and drops blank lines; the viewer paints each line
99
+ * (wrapping to its width). A garbled shape still returns whatever lines came
100
+ * back — the widget is best-effort chrome, not a contract. */
80
101
  function parseRecap(stdout) {
81
- return stdout.replace(/\s+/g, ' ').trim();
102
+ return stdout
103
+ .split('\n')
104
+ .map((l) => l.replace(/\s+/g, ' ').trim())
105
+ .filter((l) => l !== '')
106
+ .join('\n');
82
107
  }
83
108
  /** Ask pi headlessly for a concise briefing of `conversation`, async. Invokes
84
109
  * `onRecap` with the parsed string on success, or never (silent) on any
@@ -87,12 +112,12 @@ function parseRecap(stdout) {
87
112
  * and execFile's default stdin is an OPEN pipe that never closes, so without
88
113
  * this pi blocks waiting for EOF and the call exits non-zero (the same gotcha
89
114
  * naming.ts documents). */
90
- export function generateRecap(conversation, onRecap, liveModel) {
115
+ export function generateRecap(conversation, onRecap, liveModel, roadmap) {
91
116
  const body = (conversation ?? '').trim();
92
117
  if (body === '')
93
118
  return;
94
119
  try {
95
- const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...recapArgs(body, liveModel)], { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS, env: bundledPiSubprocessEnv() }, (err, stdout) => {
120
+ const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...recapArgs(body, liveModel, roadmap)], { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS, env: bundledPiSubprocessEnv() }, (err, stdout) => {
96
121
  if (err || typeof stdout !== 'string')
97
122
  return; // silent — no recap
98
123
  const briefing = parseRecap(stdout);
@@ -10,13 +10,23 @@ export declare class SessionListCache {
10
10
  private loaded;
11
11
  private persisting;
12
12
  private persistAgain;
13
+ private persistTimer;
13
14
  constructor(cacheFile: string);
15
+ /** Fire-and-forget warm of the cwd scope at broker start, so the first
16
+ * `/resume` open after this broker launches is already warm. The cold scan
17
+ * streams each file via readline (yields per chunk), so it never blocks the
18
+ * broker loop — pre-warming beats worker threads here. Errors are swallowed
19
+ * by the same paths that guard a normal open. */
20
+ prewarm(sessionDir: string): void;
14
21
  /** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
15
22
  listDir(sessionDir: string): Promise<WireSessionInfo[]>;
16
23
  /** Sessions across every cwd's dir under the sessions root (`all` scope). */
17
24
  listAll(sessionsRoot: string): Promise<WireSessionInfo[]>;
18
25
  private build;
19
26
  private ensureLoaded;
27
+ /** Coalesce persists onto a single trailing timer so the tens-of-MB
28
+ * `JSON.stringify` never runs on the reply path and rapid opens write once. */
29
+ private schedulePersist;
20
30
  /** Atomic write (temp + rename) so a concurrent reader never sees a partial
21
31
  * file. Coalesces overlapping writes into one trailing flush. */
22
32
  private persist;
@@ -17,9 +17,9 @@
17
17
  // file's info without re-reading the whole corpus is to reproduce its parse.
18
18
  // Keep it in sync with pi's session-file format (CURRENT_SESSION_VERSION).
19
19
  import { createReadStream } from 'node:fs';
20
- import { readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
20
+ import { readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
21
21
  import { createInterface } from 'node:readline';
22
- import { join } from 'node:path';
22
+ import { dirname, join } from 'node:path';
23
23
  /** Search-text bound: `allMessagesText` is searched on every query edit; pi's
24
24
  * unbounded value retains gigabytes in the viewer and turns key-repeat into GC
25
25
  * jitter. The opening and latest text keep search useful. */
@@ -31,6 +31,29 @@ export function compactSessionSearchText(text) {
31
31
  return `${text.slice(0, half)} … ${text.slice(-half)}`;
32
32
  }
33
33
  const MAX_CONCURRENT_INFO_LOADS = 10;
34
+ /** Persisted-file schema version. Bump whenever `CacheEntry`'s shape (or
35
+ * `WireSessionInfo`'s) changes: on a version mismatch the whole file is
36
+ * discarded and rebuilt from disk — no per-entry migration, no lenient
37
+ * recovery (the cache is fully derivable). */
38
+ const CACHE_SCHEMA_VERSION = 1;
39
+ /** The full map is tens of MB (~15 MB for one cwd, ~46 MB for `all`); a
40
+ * synchronous `JSON.stringify` of it must never sit on the reply path. Persists
41
+ * are coalesced onto this trailing timer so rapid opens write at most once per
42
+ * window, off the hot path. */
43
+ const PERSIST_DEBOUNCE_MS = 2000;
44
+ /** Shape-check a loaded entry — a well-formed-JSON file with the right version
45
+ * but a stale entry shape (e.g. missing `info`) would otherwise push undefined
46
+ * into the sort and throw. Any failure discards the whole file (rebuild). */
47
+ function isValidEntry(e) {
48
+ if (typeof e !== 'object' || e === null)
49
+ return false;
50
+ const c = e;
51
+ return (typeof c['mtimeMs'] === 'number' &&
52
+ typeof c['size'] === 'number' &&
53
+ typeof c['info'] === 'object' &&
54
+ c['info'] !== null &&
55
+ typeof c['info']['modified'] === 'string');
56
+ }
34
57
  // ---- faithful mirror of pi's private buildSessionInfo -----------------------
35
58
  function parseLine(line) {
36
59
  if (!line.trim())
@@ -152,12 +175,21 @@ export class SessionListCache {
152
175
  loaded = false;
153
176
  persisting = false;
154
177
  persistAgain = false;
178
+ persistTimer = null;
155
179
  constructor(cacheFile) {
156
180
  this.cacheFile = cacheFile;
157
181
  }
182
+ /** Fire-and-forget warm of the cwd scope at broker start, so the first
183
+ * `/resume` open after this broker launches is already warm. The cold scan
184
+ * streams each file via readline (yields per chunk), so it never blocks the
185
+ * broker loop — pre-warming beats worker threads here. Errors are swallowed
186
+ * by the same paths that guard a normal open. */
187
+ prewarm(sessionDir) {
188
+ void this.listDir(sessionDir);
189
+ }
158
190
  /** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
159
191
  async listDir(sessionDir) {
160
- return this.build(await jsonlFiles(sessionDir));
192
+ return this.build(await jsonlFiles(sessionDir), new Set([sessionDir]));
161
193
  }
162
194
  /** Sessions across every cwd's dir under the sessions root (`all` scope). */
163
195
  async listAll(sessionsRoot) {
@@ -173,32 +205,44 @@ export class SessionListCache {
173
205
  const files = [];
174
206
  for (const dir of dirEntries)
175
207
  files.push(...(await jsonlFiles(dir)));
176
- return this.build(files);
208
+ return this.build(files, new Set(dirEntries));
177
209
  }
178
- async build(files) {
210
+ async build(files, scannedDirs) {
179
211
  await this.ensureLoaded();
180
212
  const present = new Set(files);
213
+ let changed = false;
214
+ // Prune ONLY keys under the dirs we actually scanned. A cwd-scope open must
215
+ // not evict other cwds' cached entries: they weren't rescanned, so their
216
+ // absence from `files` says nothing about whether they still exist on disk.
217
+ // (Pruning the whole map here would self-destruct the global/`all` cache on
218
+ // every cwd open.)
181
219
  for (const key of this.cache.keys()) {
182
- if (!present.has(key))
220
+ if (!present.has(key) && scannedDirs.has(dirname(key))) {
183
221
  this.cache.delete(key);
222
+ changed = true;
223
+ }
184
224
  }
185
- const infos = [];
186
- let changed = false;
225
+ // Positioned by file index so equal-`modified` ties keep readdir order
226
+ // deterministically (workers finish out of order); a stable sort preserves it.
227
+ const infos = new Array(files.length);
187
228
  let next = 0;
188
229
  const worker = async () => {
189
- while (next < files.length) {
190
- const file = files[next++];
230
+ for (;;) {
231
+ const i = next++;
232
+ if (i >= files.length)
233
+ return;
234
+ const file = files[i];
191
235
  if (file === undefined)
192
236
  return;
193
237
  const st = await stat(file).catch(() => null);
194
238
  if (!st) {
195
- this.cache.delete(file);
196
- changed = true;
239
+ if (this.cache.delete(file))
240
+ changed = true;
197
241
  continue;
198
242
  }
199
243
  const cached = this.cache.get(file);
200
244
  if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
201
- infos.push(cached.info);
245
+ infos[i] = cached.info;
202
246
  continue;
203
247
  }
204
248
  const info = await buildWireSessionInfo(file);
@@ -208,14 +252,15 @@ export class SessionListCache {
208
252
  continue;
209
253
  }
210
254
  this.cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, info });
211
- infos.push(info);
255
+ infos[i] = info;
212
256
  }
213
257
  };
214
258
  await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT_INFO_LOADS, files.length) }, () => worker()));
215
259
  if (changed)
216
- void this.persist();
217
- infos.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
218
- return infos;
260
+ this.schedulePersist();
261
+ const result = infos.filter((x) => x !== undefined);
262
+ result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
263
+ return result;
219
264
  }
220
265
  async ensureLoaded() {
221
266
  if (this.loaded)
@@ -223,15 +268,35 @@ export class SessionListCache {
223
268
  this.loaded = true;
224
269
  try {
225
270
  const raw = JSON.parse(await readFile(this.cacheFile, 'utf8'));
226
- for (const [path, entry] of Object.entries(raw))
271
+ // Version mismatch or shape drift → discard and rebuild. No lenient
272
+ // multi-path recovery: the cache is derivable, so a bad file is just
273
+ // overwritten on the next persist.
274
+ if (raw?.version !== CACHE_SCHEMA_VERSION || typeof raw.entries !== 'object')
275
+ return;
276
+ for (const [path, entry] of Object.entries(raw.entries)) {
277
+ if (!isValidEntry(entry)) {
278
+ this.cache.clear();
279
+ return;
280
+ }
227
281
  this.cache.set(path, entry);
282
+ }
228
283
  }
229
284
  catch {
230
- // Missing or corrupt cache file → start empty and rebuild. No lenient
231
- // multi-path recovery: the cache is derivable, so a bad file is just
232
- // overwritten on the next persist.
285
+ // Missing or corrupt cache file → start empty and rebuild.
286
+ this.cache.clear();
233
287
  }
234
288
  }
289
+ /** Coalesce persists onto a single trailing timer so the tens-of-MB
290
+ * `JSON.stringify` never runs on the reply path and rapid opens write once. */
291
+ schedulePersist() {
292
+ if (this.persistTimer)
293
+ return;
294
+ this.persistTimer = setTimeout(() => {
295
+ this.persistTimer = null;
296
+ void this.persist();
297
+ }, PERSIST_DEBOUNCE_MS);
298
+ this.persistTimer.unref?.();
299
+ }
235
300
  /** Atomic write (temp + rename) so a concurrent reader never sees a partial
236
301
  * file. Coalesces overlapping writes into one trailing flush. */
237
302
  async persist() {
@@ -240,16 +305,19 @@ export class SessionListCache {
240
305
  return;
241
306
  }
242
307
  this.persisting = true;
308
+ const tmp = `${this.cacheFile}.${process.pid}.tmp`;
243
309
  try {
244
- const obj = {};
310
+ const entries = {};
245
311
  for (const [path, entry] of this.cache)
246
- obj[path] = entry;
247
- const tmp = `${this.cacheFile}.${process.pid}.tmp`;
248
- await writeFile(tmp, JSON.stringify(obj));
312
+ entries[path] = entry;
313
+ const payload = { version: CACHE_SCHEMA_VERSION, entries };
314
+ await writeFile(tmp, JSON.stringify(payload));
249
315
  await rename(tmp, this.cacheFile);
250
316
  }
251
317
  catch {
252
- // Best-effort: a failed persist only costs a rebuild next open.
318
+ // Best-effort: a failed persist only costs a rebuild next open. Clean up
319
+ // the temp file so a failed write never leaks it.
320
+ await unlink(tmp).catch(() => { });
253
321
  }
254
322
  finally {
255
323
  this.persisting = false;
@@ -12,7 +12,6 @@
12
12
  import { spawn } from 'node:child_process';
13
13
  import { readdirSync, existsSync } from 'node:fs';
14
14
  import { isAbsolute, resolve, join } from 'node:path';
15
- import { homedir } from 'node:os';
16
15
  import { spawnNode, currentNodeContext, rootOfSpine, newNodeId } from './nodes.js';
17
16
  import { loadProfileManifest } from '../profiles/manifest.js';
18
17
  import { selectProfileForCwd } from '../profiles/select.js';
@@ -28,6 +27,7 @@ import { installTmuxBindings } from './tmux-chrome.js';
28
27
  import { getNode, fullName, recordPid } from '../canvas/index.js';
29
28
  import { registerViewerFocus, openViewerWindow, waitForBrokerViewSocket, viewerSplitEnv, windowOfPane, currentTmux, inTmux, focusWindow, } from './placement.js';
30
29
  import { transition } from './lifecycle.js';
30
+ import { piSessionsRoot } from './pi-vendored.js';
31
31
  import { headlessBrokerHost } from './host.js';
32
32
  import { ensureDaemon } from '../../daemon/manage.js';
33
33
  // ---------------------------------------------------------------------------
@@ -186,18 +186,6 @@ export async function bootRoot(opts) {
186
186
  }
187
187
  process.exit(await attachExit);
188
188
  }
189
- /** pi's sessions root, VENDORED from pi `config.getSessionsDir()` (= `<agentDir>/
190
- * sessions`). pi's package `exports` map is `.`-only, so config.js can't be
191
- * deep-imported, and a ROOT import of `getAgentDir` would eager-load the entire
192
- * heavy pi SDK index on crtr's front-door hot path (the reason broker-sdk.ts
193
- * dynamic-imports the engine). Mirrors pi: `PI_CODING_AGENT_DIR` env, else
194
- * `~/.pi/agent` (APP_NAME defaults to 'pi'). Re-sync on a pi SDK bump that moves
195
- * the sessions dir — same vendoring rationale as pi-vendored.ts. */
196
- function piSessionsRoot() {
197
- const env = process.env['PI_CODING_AGENT_DIR'];
198
- const agentDir = env !== undefined && env !== '' ? env : join(homedir(), '.pi', 'agent');
199
- return join(agentDir, 'sessions');
200
- }
201
189
  /** Resolve a bare/partial pi session uuid to its ABSOLUTE `.jsonl` path by
202
190
  * scanning pi's sessions store the way pi's own CLI does (exact id, else a
203
191
  * unique prefix). The broker fork seam (`SessionManager.forkFrom`) loads a FILE
@@ -419,6 +419,7 @@ const MENU_ACTIONS = {
419
419
  'crtr.tmux.menu.detach': { description: 'detach to background', action: { kind: 'command', run: 'node lifecycle demote --pane {pane} --detach' } },
420
420
  'crtr.tmux.menu.close-subtree': { description: 'close agent + subtree', action: { kind: 'command', run: 'node lifecycle close --pane {pane}' } },
421
421
  'crtr.tmux.menu.context': { description: 'browse context dirs', action: { kind: 'keys', keys: '/context' } },
422
+ 'crtr.tmux.menu.providers': { description: 'manage providers', action: { kind: 'popup', run: 'sys setup' } },
422
423
  'crtr.tmux.menu.graph': { description: 'graph view', action: { kind: 'keys', keys: '/graph' } },
423
424
  'crtr.tmux.menu.focus-manager': { description: 'focus manager', action: { kind: 'command', run: 'node focus {manager}' } },
424
425
  'crtr.tmux.menu.open': undefined,
@@ -501,7 +502,7 @@ function menuItems(bindings) {
501
502
  for (const id of [
502
503
  'crtr.tmux.menu.promote', 'crtr.tmux.menu.resume', 'crtr.tmux.menu.demote',
503
504
  'crtr.tmux.menu.detach', 'crtr.tmux.menu.close-subtree', 'crtr.tmux.menu.context',
504
- 'crtr.tmux.menu.graph', 'crtr.tmux.menu.focus-manager', 'crtr.tmux.menu.issues',
505
+ 'crtr.tmux.menu.providers', 'crtr.tmux.menu.graph', 'crtr.tmux.menu.focus-manager', 'crtr.tmux.menu.issues',
505
506
  ]) {
506
507
  const entry = id === 'crtr.tmux.menu.issues'
507
508
  ? { description: 'new issue / todo', action: { kind: 'submenu', title: 'new issue / todo', items: issueItems } }
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
- import { existsSync, statSync } from 'node:fs';
2
+ import { existsSync, statSync, realpathSync } from 'node:fs';
3
3
  import { join, resolve, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { CRTR_DIR_NAME } from '../types.js';
@@ -48,6 +48,14 @@ export function builtinPiPackageDir(name) {
48
48
  export function userScopeRoot() {
49
49
  return join(homedir(), CRTR_DIR_NAME);
50
50
  }
51
+ function realpathOrSelf(p) {
52
+ try {
53
+ return realpathSync(p);
54
+ }
55
+ catch {
56
+ return p;
57
+ }
58
+ }
51
59
  function isProjectScopeDir(candidate, userRoot) {
52
60
  if (candidate === userRoot || !existsSync(candidate))
53
61
  return false;
@@ -61,16 +69,31 @@ function isProjectScopeDir(candidate, userRoot) {
61
69
  export function findProjectScopeRoot(startDir = process.cwd()) {
62
70
  return findProjectScopeRoots(startDir)[0] ?? null;
63
71
  }
64
- /** Walk `startDir` upward to the filesystem root, collecting every ancestor
65
- * `.crouter/` dir (nearest first). The user-global `~/.crouter/` is excluded
66
- * because it is the separate user scope, not a project ancestor. */
72
+ /** Walk `startDir` upward collecting every ancestor `.crouter/` dir (nearest
73
+ * first). The user-global `~/.crouter/` is excluded because it is the separate
74
+ * user scope, not a project ancestor.
75
+ *
76
+ * The walk is FENCED at `homedir()`: it must never climb at or above HOME.
77
+ * Home's own `.crouter/` is the user scope (already excluded), and nothing at
78
+ * or above HOME is a project ancestor. Without this fence, a relocated HOME
79
+ * nested under a directory tree that itself contains a `.crouter/` (e.g. a
80
+ * mock-VM home under the real host home) would sweep the host's `~/.crouter`
81
+ * (and any other above-HOME `.crouter`) in as a bogus project ancestor,
82
+ * leaking one machine's user-global memory into a nested-HOME node. Mirrors
83
+ * the on-read positional-walk fence (src/core/substrate/on-read.ts). A read
84
+ * whose start dir is outside HOME (e.g. a production VM with HOME=/root and no
85
+ * ancestor `.crouter`) never reaches home and walks to the filesystem root, as
86
+ * before. */
67
87
  function collectAncestorScopeRoots(startDir, userRoot) {
68
88
  const roots = [];
89
+ const home = realpathOrSelf(homedir());
69
90
  let dir = resolve(startDir);
70
91
  while (true) {
71
92
  const candidate = join(dir, CRTR_DIR_NAME);
72
93
  if (isProjectScopeDir(candidate, userRoot))
73
94
  roots.push(candidate);
95
+ if (realpathOrSelf(dir) === home)
96
+ return roots;
74
97
  const parent = dirname(dir);
75
98
  if (parent === dir)
76
99
  return roots;
@@ -0,0 +1,90 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ export declare const ANTHROPIC_PROVIDER_ID = "anthropic";
4
+ export declare const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
5
+ export type ManagedProviderId = typeof ANTHROPIC_PROVIDER_ID | typeof OPENAI_CODEX_PROVIDER_ID;
6
+ export type LadderProviderId = "anthropic" | "openai";
7
+ export type ModelStrength = "ultra" | "strong" | "medium" | "light";
8
+ export type SubscriptionCredential = {
9
+ label: string;
10
+ refresh?: string;
11
+ access?: string;
12
+ expires?: number;
13
+ rateLimitedUntil: number;
14
+ lastAttemptAt: number;
15
+ lastRateLimitedAt: number;
16
+ accountId?: string;
17
+ };
18
+ export type ModelRef = {
19
+ providerId: ManagedProviderId;
20
+ modelId: string;
21
+ };
22
+ export type RotationConfig = {
23
+ defaultFallbackStrength?: ModelStrength;
24
+ revertWhenAvailable?: boolean;
25
+ preferredModel?: ModelRef;
26
+ };
27
+ type RotationConfigUpdate = {
28
+ defaultFallbackStrength?: ModelStrength;
29
+ revertWhenAvailable?: boolean;
30
+ preferredModel?: ModelRef | null;
31
+ };
32
+ export type FallbackTarget = {
33
+ providerId: ManagedProviderId;
34
+ modelId: string;
35
+ label: string;
36
+ strength: ModelStrength;
37
+ thinkingLevel?: ThinkingLevel;
38
+ };
39
+ export declare function isManagedProvider(providerId: string | undefined): providerId is ManagedProviderId;
40
+ /** The `modelLadders` CONFIG key for a runtime provider id -- `openai-codex`
41
+ * (the runtime/auth id) maps to the `openai` ladder key used in
42
+ * ~/.crouter/config.json. Callers building a repair command that a user can
43
+ * paste into `crtr sys config set modelLadders.<key>...` must use THIS key,
44
+ * not the runtime provider id. */
45
+ export declare function getLadderProviderId(providerId: ManagedProviderId): LadderProviderId;
46
+ export declare function getProviderLabel(providerId: ManagedProviderId): string;
47
+ export declare function mutateSubscriptionPool(providerId: ManagedProviderId, fn: (pool: SubscriptionCredential[]) => SubscriptionCredential[] | undefined): SubscriptionCredential[];
48
+ export declare function readSubscriptionPool(providerId: ManagedProviderId): SubscriptionCredential[];
49
+ export declare function writeSubscriptionPool(providerId: ManagedProviderId, next: SubscriptionCredential[]): SubscriptionCredential[];
50
+ export declare function upsertSubscription(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[];
51
+ export declare class DuplicateSubscriptionError extends Error {
52
+ constructor(message: string);
53
+ }
54
+ export declare function addSubscription(providerId: ManagedProviderId, entry: SubscriptionCredential): SubscriptionCredential[];
55
+ export declare function upsertSubscriptionWithUniqueAccount(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[];
56
+ export type DefaultSlotLogin = {
57
+ refresh: string;
58
+ access: string;
59
+ expires: number;
60
+ accountId?: string;
61
+ lastRateLimitedAt: number;
62
+ };
63
+ export declare function commitDefaultIdentity(providerId: ManagedProviderId, login: DefaultSlotLogin): SubscriptionCredential[];
64
+ export declare function renameSubscription(providerId: ManagedProviderId, ref: string, nextLabel: string): SubscriptionCredential[];
65
+ export declare function removeManagedAccount(providerId: ManagedProviderId, ref: string): SubscriptionCredential[];
66
+ export declare function commitManagedLogin(providerId: ManagedProviderId, label: string, credential: {
67
+ refresh: string;
68
+ access: string;
69
+ expires: number;
70
+ accountId?: string;
71
+ defaultLastRateLimitedAt: number;
72
+ }): SubscriptionCredential[];
73
+ export declare function removeManagedProvider(providerId: ManagedProviderId, label?: string): SubscriptionCredential[];
74
+ export declare function removeSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[];
75
+ export declare function promoteSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[];
76
+ export declare function markSubscriptionAttempt(providerId: ManagedProviderId, ref: string, attemptAt?: number): SubscriptionCredential[];
77
+ export declare function markSubscriptionRateLimited(providerId: ManagedProviderId, ref: string, retryAfterMs: number, attemptAt?: number, rateLimitedAt?: number): SubscriptionCredential[];
78
+ export declare function markSubscriptionSuccess(providerId: ManagedProviderId, ref: string, attemptAt?: number): SubscriptionCredential[];
79
+ export declare function findAvailableSubscription(providerId: ManagedProviderId, now?: number): SubscriptionCredential | undefined;
80
+ export declare function parseRetryAfterHeader(value: string | undefined, now?: number): number | undefined;
81
+ export declare function readRotationConfig(): RotationConfig;
82
+ export declare function writeRotationConfig(next: RotationConfigUpdate): RotationConfig;
83
+ export declare function clearPreferredModel(): RotationConfig;
84
+ export declare function rememberPreferredModel(model: ModelRef): RotationConfig;
85
+ export declare function resolveFallbackTarget(currentProviderId: ManagedProviderId, currentModelId?: string, config?: RotationConfig, currentThinkingLevel?: string): FallbackTarget | undefined;
86
+ export declare function shouldRestorePreferredModel(config?: RotationConfig, now?: number): boolean;
87
+ export declare function restorePreferredModelIfPossible(piApi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">): Promise<boolean>;
88
+ export declare function switchToFallbackIfPossible(piApi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">, preferredModel?: ModelRef | undefined): Promise<FallbackTarget | undefined>;
89
+ export declare function formatStatusLine(providerId: ManagedProviderId, now?: number): string;
90
+ export {};