@crouton-kit/crouter 0.3.67 → 0.3.70

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 (56) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +31 -31
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +28 -8
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +11 -11
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +1 -0
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -4
  8. package/dist/clients/attach/attach-cmd.js +668 -668
  9. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  10. package/dist/clients/attach/auth-pickers.js +11 -0
  11. package/dist/clients/attach/pickers.js +2 -0
  12. package/dist/clients/attach/slash-commands.js +1 -0
  13. package/dist/commands/capture.d.ts +2 -0
  14. package/dist/commands/capture.js +28 -0
  15. package/dist/commands/node.js +3 -3
  16. package/dist/commands/profile/new.js +30 -5
  17. package/dist/commands/sys/setup-core.js +2 -0
  18. package/dist/commands/sys/setup.js +132 -16
  19. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  20. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  21. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  22. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  23. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  24. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  25. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  26. package/dist/core/__tests__/helpers/harness.js +28 -4
  27. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  28. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  29. package/dist/core/__tests__/revive.test.js +70 -1
  30. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  31. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  32. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  33. package/dist/core/canvas/pid.d.ts +14 -10
  34. package/dist/core/canvas/pid.js +14 -10
  35. package/dist/core/command.d.ts +15 -0
  36. package/dist/core/command.js +72 -0
  37. package/dist/core/profiles/select.d.ts +4 -1
  38. package/dist/core/profiles/select.js +356 -81
  39. package/dist/core/runtime/broker.js +2 -0
  40. package/dist/core/runtime/launch.js +1 -1
  41. package/dist/core/runtime/revive.js +187 -140
  42. package/dist/core/runtime/tmux.js +4 -1
  43. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  44. package/dist/core/view/transport-local.js +12 -3
  45. package/dist/daemon/crtrd-cli.d.ts +1 -1
  46. package/dist/daemon/crtrd-cli.js +1 -0
  47. package/dist/suppress-experimental-warnings.d.ts +1 -0
  48. package/dist/suppress-experimental-warnings.js +15 -0
  49. package/dist/types.js +10 -10
  50. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  51. package/dist/web-client/assets/{index-BvzxXXGU.js → index-DiFuLcp6.js} +19 -18
  52. package/dist/web-client/index.html +3 -3
  53. package/dist/web-client/sw.js +1 -1
  54. package/package.json +10 -7
  55. package/scripts/postinstall.mjs +1 -1
  56. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -45,12 +45,28 @@ const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
45
45
  // A multi-word launcher, baked verbatim ahead of the (shell-quoted) argv by the
46
46
  // seam. Absolute paths so it works regardless of the spawned window's cwd.
47
47
  const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
48
+ // Every raw `tmux` call the harness/full-tier tests make against the shared
49
+ // DEFAULT server is synchronous (spawnSync). Node's spawnSync blocks the WHOLE
50
+ // worker (no event loop, no `node --test` timeout can preempt it) until the
51
+ // child exits — with no `timeout` option it can wait FOREVER. A wedged/CPU-
52
+ // starved tmux server (the acknowledged "intermittent Linux-only crash-revive
53
+ // wedge", CI-only) has been observed to hang one of these calls indefinitely,
54
+ // which permanently consumes one of the `--test-concurrency` slots; as more
55
+ // full-tier files each hit the SAME wedged server, every slot eventually gets
56
+ // consumed the same way and the whole suite flatlines with zero further TAP
57
+ // output (confirmed 3/3 in CI, see ci-full-suite-hang-blocker.md). TMUX_TIMEOUT_MS
58
+ // bounds every raw tmux spawnSync call so a wedge fails that ONE call (and the
59
+ // test it belongs to) fast instead of hanging the run.
60
+ export const TMUX_TIMEOUT_MS = 10_000;
48
61
  /** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
49
62
  export function hasTmux() {
50
- return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
63
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS }).status === 0;
51
64
  }
52
65
  function tmuxSessionExists(session) {
53
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
66
+ return (spawnSync('tmux', ['has-session', '-t', session], {
67
+ stdio: 'ignore',
68
+ timeout: TMUX_TIMEOUT_MS,
69
+ }).status === 0);
54
70
  }
55
71
  // Every canvas/tmux var the harness itself runs under — scrubbed from each child
56
72
  // env so a spawned CLI cannot leak the REAL canvas into the isolated test.
@@ -172,14 +188,19 @@ export async function createHarness(opts = {}) {
172
188
  if (!headless) {
173
189
  spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
174
190
  stdio: 'ignore',
191
+ timeout: TMUX_TIMEOUT_MS,
175
192
  });
176
193
  // Put CRTR_PI_BINARY in the SESSION environment so EVERY pane/process spawned
177
194
  // in this session inherits it — so any pi the runtime launches (boot, revive)
178
195
  // resolves to the fake-pi vehicle, not a real pi.
179
196
  spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
180
197
  stdio: 'ignore',
198
+ timeout: TMUX_TIMEOUT_MS,
199
+ });
200
+ spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], {
201
+ stdio: 'ignore',
202
+ timeout: TMUX_TIMEOUT_MS,
181
203
  });
182
- spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
183
204
  }
184
205
  const pidsToKill = new Set();
185
206
  let nextRootSeq = 0;
@@ -517,7 +538,10 @@ export async function createHarness(opts = {}) {
517
538
  return subscriptionsOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
518
539
  },
519
540
  async dispose() {
520
- spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
541
+ spawnSync('tmux', ['kill-session', '-t', session], {
542
+ stdio: 'ignore',
543
+ timeout: TMUX_TIMEOUT_MS,
544
+ });
521
545
  // Best-effort: a superviseTick-triggered revive of a fabricated node fires
522
546
  // a DETACHED throwaway broker (it loads the real SDK; the rmSync of home
523
547
  // below makes a mid-boot one self-terminate when its files vanish). Kill
@@ -47,6 +47,7 @@ import { commitPersonaAck, personaDrift } from '../runtime/persona.js';
47
47
  function persona(m) {
48
48
  return { mode: m.mode, lifecycle: m.lifecycle };
49
49
  }
50
+ const FULL_CLI_TEST_TIMEOUT_MS = 180_000;
50
51
  // ===========================================================================
51
52
  // (b) THE demote / recycle SPLIT — two DISTINCT verbs:
52
53
  // • `node lifecycle demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
@@ -60,9 +61,9 @@ function persona(m) {
60
61
  // ===========================================================================
61
62
  test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle recycle is FINISH+RECYCLE',
62
63
  // Drives several full-CLI subprocess spawns (each a cold tsx compile of the
63
- // whole CLI) + recycleNode; a 20s budget blew on the slower, concurrency-
64
- // oversubscribed CI runner though it passes in ~8s locally. 60s = safe margin.
65
- { timeout: 60_000 }, async () => {
64
+ // whole CLI) + recycleNode; this is a subprocess-heavy regression and gets a
65
+ // CI-safe per-test budget while the npm script isolates it at file concurrency 1.
66
+ { timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
66
67
  const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-demote' });
67
68
  try {
68
69
  const A = h.spawnRoot('resident root');
@@ -136,7 +137,7 @@ test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle
136
137
  // ===========================================================================
137
138
  test('A4: a base→orchestrator yield auto-promotes + sets refresh but leaves persona_ack PENDING (the steer is never delivered)',
138
139
  // Full-CLI subprocess spawn under load — see the sibling test's budget note.
139
- { timeout: 60_000 }, async () => {
140
+ { timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
140
141
  const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-a4' });
141
142
  try {
142
143
  const A = h.spawnRoot('resident root');
@@ -27,7 +27,7 @@ test('review still permits equal-or-stronger explicit models', () => {
27
27
  assert.equal(spec('review', 'ultra'), normalizeModel('ultra'));
28
28
  });
29
29
  test('explore remains cheap by default and may explicitly run light', () => {
30
- assert.equal(spec('explore'), normalizeModel('anthropic/light'));
30
+ assert.equal(spec('explore'), normalizeModel('openai/light'));
31
31
  assert.equal(spec('explore', 'light'), normalizeModel('light'));
32
32
  });
33
33
  // `node new --model` widened to the full spec grammar (provider/tier, exact
@@ -34,9 +34,11 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
34
34
  import { tmpdir } from 'node:os';
35
35
  import { join } from 'node:path';
36
36
  import { spawn, spawnSync } from 'node:child_process';
37
+ import { DatabaseSync } from 'node:sqlite';
37
38
  import { createNode, subscribe } from '../canvas/canvas.js';
38
39
  import { readInboxSince } from '../feed/inbox.js';
39
- import { closeDb } from '../canvas/db.js';
40
+ import { closeDb, openDb } from '../canvas/db.js';
41
+ import { canvasDbPath } from '../canvas/paths.js';
40
42
  import { buildPiArgv } from '../runtime/launch.js';
41
43
  import { headlessBrokerHost } from '../runtime/host.js';
42
44
  import { resumeArgs, reviveNode } from '../runtime/revive.js';
@@ -350,3 +352,70 @@ test('reviveNode fans a doctrine wake to subscribers when launch/preflight throw
350
352
  await h.dispose();
351
353
  }
352
354
  });
355
+ // ---------------------------------------------------------------------------
356
+ // Duplicate-broker leak (live-incident regression): reviveNode's guard-check-
357
+ // then-launch-then-recordPid critical section must be a single atomic write
358
+ // transaction, so a concurrent reviveNode call for the SAME node (a second OS
359
+ // process racing this one against the same canvas.db, e.g. a retried web-relay
360
+ // revive under socket thrash) cannot read the same stale pid, launch its own
361
+ // broker, and overwrite this call's freshly-recorded pid — leaking this call's
362
+ // broker as an untracked live process. Proven here by holding a competing raw
363
+ // write transaction open on a SECOND connection to the same db file before
364
+ // calling reviveNode: with the fix, reviveNode's own BEGIN IMMEDIATE cannot
365
+ // acquire the write lock while the other connection holds it, so it fails
366
+ // closed (throws) rather than silently interleaving with the held lock and
367
+ // corrupting the guard's read-then-write. Releasing the competing lock lets an
368
+ // identical reviveNode call proceed and record a fresh pid normally — the
369
+ // serialization is transient contention, not a permanent wedge.
370
+ // ---------------------------------------------------------------------------
371
+ test('reviveNode\'s guard-check-through-recordPid section is one atomic write transaction (duplicate-broker-leak regression)', async () => {
372
+ const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-lock' });
373
+ let contender;
374
+ try {
375
+ const sessionFile = join(home, 'sessions', 'crtr-revive-lock.jsonl');
376
+ mkdirSync(join(home, 'sessions'), { recursive: true });
377
+ writeFileSync(sessionFile, '{}\n', 'utf8');
378
+ const stalePid = deadPid();
379
+ const M = h.fabricateBrokerNode({
380
+ status: 'active',
381
+ intent: null,
382
+ pi_pid: stalePid,
383
+ pi_session_id: 'uuid-lock',
384
+ pi_session_file: sessionFile,
385
+ });
386
+ // Shorten THIS test's connection's busy_timeout (default 5000ms, db.ts) so
387
+ // the contended call below fails fast instead of waiting out the real
388
+ // production window — it's the ACQUIRING side's timeout that governs how
389
+ // long `BEGIN IMMEDIATE` waits, not the holder's.
390
+ openDb().exec('PRAGMA busy_timeout = 200;');
391
+ // A second raw connection to the SAME db file holds an exclusive write
392
+ // transaction open — modeling a concurrent reviveNode call (a different OS
393
+ // process, in the real incident) that already won the race and is mid-launch.
394
+ contender = new DatabaseSync(canvasDbPath());
395
+ contender.exec('BEGIN IMMEDIATE');
396
+ assert.throws(() => reviveNode(M, { resume: true }), /locked|busy/i, 'a reviveNode call cannot acquire the write lock while a concurrent revive holds it — no interleaved double-launch');
397
+ assert.equal(h.node(M).pi_pid, stalePid, 'blocked-out call recorded nothing — the held pid is untouched');
398
+ // Release the contender's lock — an identical reviveNode call now proceeds
399
+ // normally, exactly like the uncontended case.
400
+ contender.exec('COMMIT');
401
+ contender.close();
402
+ contender = undefined;
403
+ reviveNode(M, { resume: true });
404
+ const after = h.node(M);
405
+ assert.notEqual(after.pi_pid, stalePid, 'once uncontended, revive proceeds and records a fresh pid');
406
+ assert.equal(after.cycles, 1, 'the uncontended revive bumped the cycle counter');
407
+ }
408
+ finally {
409
+ if (contender !== undefined) {
410
+ try {
411
+ contender.exec('ROLLBACK');
412
+ }
413
+ catch { /* best-effort */ }
414
+ try {
415
+ contender.close();
416
+ }
417
+ catch { /* best-effort */ }
418
+ }
419
+ await h.dispose();
420
+ }
421
+ });
@@ -45,7 +45,7 @@ async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
45
45
  await new Promise((r) => setTimeout(r, intervalMs));
46
46
  }
47
47
  }
48
- test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async () => {
48
+ test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async (t) => {
49
49
  const server = await startSseServer((res) => {
50
50
  res.writeHead(200, { 'content-type': 'text/event-stream' });
51
51
  res.write(': connected\n\n');
@@ -55,15 +55,18 @@ test('a listener fires once per known-kind SSE event, and never for a ping or an
55
55
  res.end();
56
56
  });
57
57
  const stream = new RemoteEventStream(server.url, 'tok');
58
+ // Register cleanup BEFORE any assertion runs — a thrown assertion must not
59
+ // leave the client's reconnect loop or the server's listening socket alive
60
+ // (see the CI-hang postmortem on rebuild-coalescer.test.ts).
61
+ t.after(() => { stream.stop(); return server.close(); });
58
62
  const seen = [];
59
63
  stream.subscribe((kind) => seen.push(kind));
60
64
  stream.start();
61
65
  await waitFor(() => seen.length > 0);
62
66
  stream.stop();
63
67
  assert.deepEqual(seen, ['nodes']);
64
- await server.close();
65
68
  });
66
- test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async () => {
69
+ test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async (t) => {
67
70
  const server = await startSseServer((res) => {
68
71
  res.writeHead(200, { 'content-type': 'text/event-stream' });
69
72
  res.end();
@@ -73,6 +76,7 @@ test('the SSE connection carries the token ONLY in the Authorization header —
73
76
  // no header, including Host, is permitted to carry it.
74
77
  const token = 'secret-sse-token-value';
75
78
  const stream = new RemoteEventStream(server.url, token);
79
+ t.after(() => { stream.stop(); return server.close(); });
76
80
  stream.start();
77
81
  await waitFor(() => server.requests.length > 0);
78
82
  stream.stop();
@@ -87,9 +91,8 @@ test('the SSE connection carries the token ONLY in the Authorization header —
87
91
  assert.ok(!String(v).includes(token), `header ${k} must not carry the token`);
88
92
  }
89
93
  assert.ok(!req.url.includes(token));
90
- await server.close();
91
94
  });
92
- test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async () => {
95
+ test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async (t) => {
93
96
  const server = await startSseServer((res) => {
94
97
  res.writeHead(500);
95
98
  res.end();
@@ -101,6 +104,7 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
101
104
  // wide enough that ordinary event-loop/scheduler jitter can't flip it.
102
105
  const BACKOFF = 200;
103
106
  const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
107
+ t.after(() => { stream.stop(); return server.close(); });
104
108
  stream.start();
105
109
  // t≈0: first request fires+fails, backoff(BACKOFF) scheduled → stale deadline ≈ BACKOFF.
106
110
  await waitFor(() => server.hitCount() >= 1);
@@ -119,10 +123,8 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
119
123
  // this window; a cleared one (fixed) leaves it at 2.
120
124
  await new Promise((r) => setTimeout(r, BACKOFF * 0.7));
121
125
  assert.equal(server.hitCount(), 2, "no zombie request from the stopped loop's dangling timer");
122
- stream.stop();
123
- await server.close();
124
126
  });
125
- test('stop() halts the reconnect loop — no further requests after it is called', async () => {
127
+ test('stop() halts the reconnect loop — no further requests after it is called', async (t) => {
126
128
  const server = await startSseServer((res) => {
127
129
  // Every connection is refused immediately (no SSE headers, just a close)
128
130
  // so the client's loop falls into its backoff-and-retry path.
@@ -131,6 +133,7 @@ test('stop() halts the reconnect loop — no further requests after it is called
131
133
  });
132
134
  const BACKOFF = 20;
133
135
  const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
136
+ t.after(() => { stream.stop(); return server.close(); });
134
137
  stream.start();
135
138
  await waitFor(() => server.hitCount() >= 1);
136
139
  const hitsAtStop = server.hitCount();
@@ -140,5 +143,4 @@ test('stop() halts the reconnect loop — no further requests after it is called
140
143
  // continuing to retry in the background after stop() returned.
141
144
  await new Promise((r) => setTimeout(r, BACKOFF * 5));
142
145
  assert.equal(server.hitCount(), hitsAtStop);
143
- await server.close();
144
146
  });
@@ -140,7 +140,7 @@ function seedPoisonedLocalState() {
140
140
  ].join('\n'));
141
141
  return sf;
142
142
  }
143
- test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
143
+ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
144
144
  sessionFile = seedPoisonedLocalState();
145
145
  const server = await startMockCrtrServer((args) => {
146
146
  if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
@@ -164,6 +164,10 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
164
164
  }
165
165
  return { status: 500, body: {} };
166
166
  });
167
+ // Register the close BEFORE any assertion runs — a listening server left
168
+ // open by a thrown assertion keeps this test file's process alive forever
169
+ // (see the CI-hang postmortem on rebuild-coalescer.test.ts).
170
+ t.after(() => server.close());
167
171
  const source = new RemoteCanvasSource(server.url, 'tok');
168
172
  const rows = await dashboardRowsAllFromSource(source);
169
173
  assert.equal(rows.length, 1);
@@ -180,7 +184,6 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
180
184
  assert.equal(previewed.prompts, undefined, 'a local session transcript must not leak into a remote preview');
181
185
  assert.equal(previewed.lastAssistant, undefined, 'a local session transcript must not leak into a remote preview');
182
186
  assert.equal(previewed.previewLoaded, true);
183
- await server.close();
184
187
  });
185
188
  // The non-TTY `canvas browse` path (browse/app.ts's `if (!process.stdin.isTTY)`
186
189
  // branch) calls `renderForestFromSource(source)` directly — a SEPARATE code
@@ -190,7 +193,7 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
190
193
  // test above never exercises it, so a regression that reintroduced local
191
194
  // telemetry/ask/fault reads into the static forest renderer specifically would
192
195
  // pass that test while still leaking poisoned local state here.
193
- test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
196
+ test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
194
197
  const sf = seedPoisonedLocalState();
195
198
  const server = await startMockCrtrServer((args) => {
196
199
  if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
@@ -211,11 +214,11 @@ test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) f
211
214
  }
212
215
  return { status: 500, body: {} };
213
216
  });
217
+ t.after(() => server.close());
214
218
  const source = new RemoteCanvasSource(server.url, 'tok');
215
219
  const out = await renderForestFromSource(source);
216
220
  assert.match(out, /ctx 0k/, 'the remote node must render with the suppressed ctx default, not a real token read');
217
221
  assert.doesNotMatch(out, /⚑/, 'no ask badge — local asks must not leak into the static forest render');
218
222
  assert.doesNotMatch(out, /POISON FAULT/, 'a local active fault must not leak into the static forest render');
219
223
  assert.doesNotMatch(out, /POISON/, 'no poisoned local telemetry/goal/session text may appear anywhere in the output');
220
- await server.close();
221
224
  });
@@ -71,19 +71,36 @@ test('a burst of SSE-style triggers produces ONE round of remote requests, not N
71
71
  res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
72
72
  });
73
73
  });
74
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
75
- const addr = server.address();
76
- const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
77
- const source = new RemoteCanvasSource(`http://127.0.0.1:${port}`, 'tok');
78
- const rebuilder = createCoalescedRebuilder(async () => {
79
- source.invalidate();
80
- await source.listNodes(); // one round of remote requests, mirroring app.ts's rebuildSnapshot
81
- }, 20);
82
- for (let i = 0; i < 6; i++)
83
- rebuilder.trigger();
84
- await new Promise((r) => setTimeout(r, 80));
85
- assert.equal(hits, 1, 'a burst of 6 SSE events must produce ONE round of remote requests, not 6');
86
- await new Promise((r) => server.close(() => r()));
74
+ // The server MUST close in a `finally` — a listening TCP server keeps its
75
+ // process's event loop non-empty forever. Under `node --test`, every test
76
+ // file is its own child process; if an assertion in this test throws before
77
+ // the old inline `server.close()` at the end of the function ran, the
78
+ // server (and the process) never exited, wedging one `--test-concurrency`
79
+ // slot permanently and hanging the whole suite (see the CI-hang postmortem).
80
+ try {
81
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
82
+ const addr = server.address();
83
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
84
+ const source = new RemoteCanvasSource(`http://127.0.0.1:${port}`, 'tok');
85
+ const rebuilder = createCoalescedRebuilder(async () => {
86
+ source.invalidate();
87
+ await source.listNodes(); // one round of remote requests, mirroring app.ts's rebuildSnapshot
88
+ }, 20);
89
+ for (let i = 0; i < 6; i++)
90
+ rebuilder.trigger();
91
+ // Poll for the request to land instead of sleeping a fixed duration —
92
+ // deterministic under CI's variable CPU load, and doesn't slow down the
93
+ // common case (returns as soon as the debounce + request round trip land).
94
+ const deadline = Date.now() + 2000;
95
+ while (hits < 1 && Date.now() < deadline) {
96
+ await new Promise((r) => setTimeout(r, 10));
97
+ }
98
+ assert.equal(hits, 1, 'a burst of 6 SSE events must produce ONE round of remote requests, not 6');
99
+ }
100
+ finally {
101
+ server.closeAllConnections();
102
+ await new Promise((r) => server.close(() => r()));
103
+ }
87
104
  });
88
105
  // Integration proof for browse/app.ts's ACTUAL wiring — not the coalescer
89
106
  // helper in isolation. Every test above instantiates `createCoalescedRebuilder`
@@ -74,16 +74,20 @@ export declare function identitiesMatch(a: string, b: string): boolean;
74
74
  * `identitiesMatch` says so, else `'dead'` (positively reused). */
75
75
  export type RecordedPidLiveness = 'alive' | 'dead' | 'indeterminate';
76
76
  export declare function recordedPidLiveness(pid: number | null | undefined, expectedIdentity: string | null | undefined): RecordedPidLiveness;
77
- /** Boolean teardown-direction adapter over `recordedPidLiveness`, kept so
78
- * every pre-existing signalling caller stays behavior-identical: `dead` is
79
- * the ONLY thing that reads false. `indeterminate` (can't confirm either
80
- * way) reads true alongside `alive` never signal a maybe-ours pid on mere
81
- * uncertainty, matching this module's universal discipline (a POSITIVE
82
- * mismatch is the only thing that changes behavior, never an absence of
83
- * evidence). Callers that need to distinguish "confirmed alive" from
84
- * "can't confirm" (e.g. a double-launch guard, where treating uncertainty as
85
- * alive risks silently skipping a needed relaunch) must call
86
- * `recordedPidLiveness` directly instead of this adapter. */
77
+ /** Boolean confirmed-dead-direction adapter over `recordedPidLiveness`:
78
+ * `dead` is the ONLY thing that reads false. `indeterminate` (can't confirm
79
+ * either way) reads true alongside `alive` never act on a maybe-ours pid
80
+ * on mere uncertainty, matching this module's universal discipline (a
81
+ * POSITIVE verdict is the only thing that changes behavior, never an
82
+ * absence of evidence). This is the correct read for BOTH signalling
83
+ * callers (never signal on uncertainty) and the double-launch guard in
84
+ * `reviveNode` (never RELAUNCH on uncertainty the identity probe is a
85
+ * fork+exec that fails precisely under the OOM pressure where an extra
86
+ * broker is most destructive, while a truly dead pid always reads `dead`
87
+ * via the fork-free signal-0 path; see the guard's comment for the live
88
+ * incident this closed). Callers that need "confirmed alive" three-valued
89
+ * precision (e.g. the daemon's grace-clock bookkeeping) call
90
+ * `recordedPidLiveness` directly. */
87
91
  export declare function isRecordedPidAlive(pid: number | null | undefined, expectedIdentity: string | null | undefined): boolean;
88
92
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
89
93
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
@@ -132,16 +132,20 @@ export function recordedPidLiveness(pid, expectedIdentity) {
132
132
  logPidLivenessDecision(pid, alive, expectedIdentity, 'value', matches ? 'identity matches expected → alive' : 'identity differs from expected → REUSED → dead', matches ? 'alive' : 'dead', actual);
133
133
  return matches ? 'alive' : 'dead';
134
134
  }
135
- /** Boolean teardown-direction adapter over `recordedPidLiveness`, kept so
136
- * every pre-existing signalling caller stays behavior-identical: `dead` is
137
- * the ONLY thing that reads false. `indeterminate` (can't confirm either
138
- * way) reads true alongside `alive` never signal a maybe-ours pid on mere
139
- * uncertainty, matching this module's universal discipline (a POSITIVE
140
- * mismatch is the only thing that changes behavior, never an absence of
141
- * evidence). Callers that need to distinguish "confirmed alive" from
142
- * "can't confirm" (e.g. a double-launch guard, where treating uncertainty as
143
- * alive risks silently skipping a needed relaunch) must call
144
- * `recordedPidLiveness` directly instead of this adapter. */
135
+ /** Boolean confirmed-dead-direction adapter over `recordedPidLiveness`:
136
+ * `dead` is the ONLY thing that reads false. `indeterminate` (can't confirm
137
+ * either way) reads true alongside `alive` never act on a maybe-ours pid
138
+ * on mere uncertainty, matching this module's universal discipline (a
139
+ * POSITIVE verdict is the only thing that changes behavior, never an
140
+ * absence of evidence). This is the correct read for BOTH signalling
141
+ * callers (never signal on uncertainty) and the double-launch guard in
142
+ * `reviveNode` (never RELAUNCH on uncertainty the identity probe is a
143
+ * fork+exec that fails precisely under the OOM pressure where an extra
144
+ * broker is most destructive, while a truly dead pid always reads `dead`
145
+ * via the fork-free signal-0 path; see the guard's comment for the live
146
+ * incident this closed). Callers that need "confirmed alive" three-valued
147
+ * precision (e.g. the daemon's grace-clock bookkeeping) call
148
+ * `recordedPidLiveness` directly. */
145
149
  export function isRecordedPidAlive(pid, expectedIdentity) {
146
150
  return recordedPidLiveness(pid, expectedIdentity) !== 'dead';
147
151
  }
@@ -56,6 +56,17 @@ export interface BranchDef {
56
56
  rootEntry?: RootEntry;
57
57
  /** Opt into editor slash-command exposure (see SlashSpec). */
58
58
  slash?: SlashSpec;
59
+ /** Opt this branch out of the tree model entirely: every token after this
60
+ * branch's name is forwarded VERBATIM (raw argv, not the `--json`-filtered
61
+ * tokens) to an external binary via spawn, with stdio inherited and the
62
+ * child's exit code propagated. A deliberate, documented exception for
63
+ * wrapping an external CLI whose own schema crtr cannot and must not
64
+ * duplicate (see `crtr capture`) — not a general escape hatch. A
65
+ * passthrough branch should declare no children. */
66
+ passthrough?: {
67
+ bin: string;
68
+ installHint: string;
69
+ };
59
70
  children: (LeafDef | BranchDef)[];
60
71
  }
61
72
  export interface RootDef {
@@ -81,6 +92,10 @@ export declare function defineBranch(opts: {
81
92
  help: BranchHelp;
82
93
  rootEntry?: RootEntry;
83
94
  slash?: SlashSpec;
95
+ passthrough?: {
96
+ bin: string;
97
+ installHint: string;
98
+ };
84
99
  children: (LeafDef | BranchDef)[];
85
100
  }): BranchDef;
86
101
  /** Walk the whole tree and collect every node's SlashSpec (depth-first). Used
@@ -10,6 +10,8 @@ import { renderResult } from './render.js';
10
10
  import { CrtrError } from './errors.js';
11
11
  import { ExitCode } from '../types.js';
12
12
  import { readFileSync } from 'node:fs';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { constants as osConstants } from 'node:os';
13
15
  // ---------------------------------------------------------------------------
14
16
  // Factory functions
15
17
  // ---------------------------------------------------------------------------
@@ -34,6 +36,14 @@ function visibleSubCount(def) {
34
36
  return (def.help.listing ?? []).filter((c) => c.tier !== 'hidden').length;
35
37
  }
36
38
  export function defineBranch(opts) {
39
+ // A passthrough branch must be childless: walk() only stops at the deepest
40
+ // matched node, so a passthrough branch with children would let some
41
+ // invocations descend into a child (tree dispatch) while others hit the
42
+ // passthrough interception — silently inconsistent transparency. Fail loudly
43
+ // at construction instead of letting that split happen at runtime.
44
+ if (opts.passthrough !== undefined && opts.children.length > 0) {
45
+ throw new Error(`defineBranch("${opts.name}"): a passthrough branch must declare no children (found ${opts.children.length})`);
46
+ }
37
47
  // Assemble the parent-level listing straight from the child defs — each node
38
48
  // owns its own description/whenToUse/tier, so the parent copies nothing
39
49
  // (principle 16). Bottom-up construction guarantees a child branch's listing
@@ -57,6 +67,7 @@ export function defineBranch(opts) {
57
67
  help: opts.help,
58
68
  rootEntry: opts.rootEntry,
59
69
  slash: opts.slash,
70
+ passthrough: opts.passthrough,
60
71
  children: opts.children,
61
72
  };
62
73
  }
@@ -169,6 +180,59 @@ function renderNode(node) {
169
180
  function helpRequested(remaining) {
170
181
  return remaining.some((t) => t === '-h' || t === '--help');
171
182
  }
183
+ /** Recover the RAW argv slice after a matched passthrough branch's path,
184
+ * unaffected by the `--json`-stripping done to build `tokens`. `path` is a
185
+ * prefix of non-`--json` tokens in order, so walking `rawTokens` while
186
+ * skipping `--json` occurrences until `path` is fully matched lands the
187
+ * cursor exactly where the branch's own args begin — including a literal
188
+ * `--json` the external binary itself accepts. */
189
+ function rawArgsAfterPath(rawTokens, path) {
190
+ let matched = 0;
191
+ for (let i = 0; i < rawTokens.length; i++) {
192
+ if (matched === path.length)
193
+ return rawTokens.slice(i);
194
+ if (rawTokens[i] === '--json')
195
+ continue;
196
+ if (rawTokens[i] === path[matched])
197
+ matched++;
198
+ }
199
+ return [];
200
+ }
201
+ /** Forward `args` to an external binary verbatim: stdio inherited (streaming
202
+ * preserved), and the child's exit code propagated. Never returns — either
203
+ * exits with the child's status or throws a structured CrtrError (ENOENT ->
204
+ * install hint) for the caller's existing error handler to render. */
205
+ function runPassthrough(passthrough, args) {
206
+ const result = spawnSync(passthrough.bin, args, { stdio: 'inherit' });
207
+ if (result.error !== undefined) {
208
+ // Any launch-time failure (not found, not executable, ...) means this
209
+ // binary isn't usable from here — surface one structured error with the
210
+ // install hint rather than branching on a specific errno. (Node/libuv can
211
+ // report EACCES instead of ENOENT for a genuinely-missing binary when PATH
212
+ // contains malformed entries — e.g. a literal `~/...` segment — so a
213
+ // ENOENT-only check would miss real missing-binary cases on such hosts.)
214
+ const err = result.error;
215
+ throw new CrtrError('binary_missing', `${passthrough.bin} could not be run (${err.code ?? err.message})`, ExitCode.USAGE, {
216
+ received: passthrough.bin,
217
+ next: passthrough.installHint,
218
+ });
219
+ }
220
+ if (result.status !== null) {
221
+ process.exit(result.status);
222
+ }
223
+ if (result.signal !== null) {
224
+ // Signal-terminated: exit(1) would break the transparent exit-propagation
225
+ // contract (a direct shell invocation would show 128+signal, e.g. 143 for
226
+ // SIGTERM). Map to the standard 128+signal-number convention.
227
+ const signalNumber = osConstants.signals[result.signal];
228
+ if (signalNumber !== undefined) {
229
+ process.exit(128 + signalNumber);
230
+ }
231
+ }
232
+ // Truly impossible per Node's spawnSync contract (status and signal are
233
+ // mutually exclusive and one is always set on a completed run) — fallback only.
234
+ process.exit(1);
235
+ }
172
236
  /** Build a structured unknown-path error. Names valid children of the deepest
173
237
  * matched node and names the entry command per the spec. The entry command is
174
238
  * the full path to the matched node (not just its local name), so the recovery
@@ -367,6 +431,14 @@ export async function runCli(root, argv) {
367
431
  }
368
432
  const { node, path, remaining } = walk(root, tokens);
369
433
  try {
434
+ // Passthrough branches (e.g. `crtr capture`) opt out of the tree model
435
+ // entirely: forward every token after the branch verbatim to an external
436
+ // binary before any of the normal unknown-path / help / dispatch logic
437
+ // runs, so `crtr capture -h`, `crtr capture <args>`, and bare `crtr capture`
438
+ // all reach the external CLI untouched.
439
+ if (node.kind === 'branch' && node.passthrough !== undefined) {
440
+ runPassthrough(node.passthrough, rawArgsAfterPath(rawTokens, path));
441
+ }
370
442
  // A root/branch node's `remaining` is only non-empty because walk hit
371
443
  // -h/--help or an unmatched child name (a leaf always consumes the rest
372
444
  // of the path itself). So a non-help leading token here is always the
@@ -27,7 +27,10 @@ export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): bool
27
27
  * is covered). Default is the MRU covering profile.
28
28
  * - Non-interactive: auto-pick the MRU covering profile, no prompt.
29
29
  * 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
30
- * create a profile here or use the root profile. Non-interactive (no TTY):
30
+ * create a profile here, select an existing profile (a searchable list of
31
+ * every profile, for when you started somewhere none of them cover and
32
+ * can't recall the exact name — the pick then offers to adopt cwd), or use
33
+ * the root profile. Non-interactive (no TTY):
31
34
  * default to the root profile and print the recovery instruction to STDERR —
32
35
  * never stdout, which the caller may be piping. */
33
36
  export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string>;