@crouton-kit/crouter 0.3.68 → 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 (46) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +31 -31
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +28 -8
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +11 -11
  4. package/dist/cli.d.ts +1 -1
  5. package/dist/cli.js +1 -0
  6. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -4
  7. package/dist/clients/attach/attach-cmd.js +667 -667
  8. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  9. package/dist/clients/attach/auth-pickers.js +11 -0
  10. package/dist/clients/attach/pickers.js +2 -0
  11. package/dist/clients/attach/slash-commands.js +1 -0
  12. package/dist/commands/node.js +3 -3
  13. package/dist/commands/profile/new.js +30 -5
  14. package/dist/commands/sys/setup-core.js +2 -0
  15. package/dist/commands/sys/setup.js +132 -16
  16. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  17. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  18. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  19. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  20. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  21. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  22. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  23. package/dist/core/__tests__/helpers/harness.js +28 -4
  24. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  25. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  26. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  27. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  28. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  29. package/dist/core/canvas/pid.d.ts +14 -10
  30. package/dist/core/canvas/pid.js +14 -10
  31. package/dist/core/runtime/broker.js +2 -0
  32. package/dist/core/runtime/launch.js +1 -1
  33. package/dist/core/runtime/revive.js +28 -18
  34. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  35. package/dist/daemon/crtrd-cli.d.ts +1 -1
  36. package/dist/daemon/crtrd-cli.js +1 -0
  37. package/dist/suppress-experimental-warnings.d.ts +1 -0
  38. package/dist/suppress-experimental-warnings.js +15 -0
  39. package/dist/types.js +10 -10
  40. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  41. package/dist/web-client/assets/{index-CnF5r8ky.js → index-DiFuLcp6.js} +18 -17
  42. package/dist/web-client/index.html +3 -3
  43. package/dist/web-client/sw.js +1 -1
  44. package/package.json +10 -7
  45. package/scripts/postinstall.mjs +1 -1
  46. 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
@@ -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
  }
@@ -1086,6 +1086,8 @@ export async function runBroker(nodeId) {
1086
1086
  showTerminalProgress: sm.getShowTerminalProgress(),
1087
1087
  warnings: sm.getWarnings(),
1088
1088
  defaultProjectTrust: sm.getDefaultProjectTrust(),
1089
+ showCacheMissNotices: sm.getShowCacheMissNotices(),
1090
+ outputPad: sm.getOutputPad(),
1089
1091
  autoRetry: session.autoRetryEnabled,
1090
1092
  model: toModelRef(session.model),
1091
1093
  };
@@ -111,7 +111,7 @@ const STRENGTH_ALIASES = {
111
111
  const MODEL_PROVIDER_KEYS = ['anthropic', 'openai'];
112
112
  const MODEL_STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
113
113
  const MODEL_STRENGTH_RANK = { light: 0, medium: 1, strong: 2, ultra: 3 };
114
- const THINKING_SUFFIXES = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']);
114
+ const THINKING_SUFFIXES = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
115
115
  function stripThinkingSuffix(spec) {
116
116
  const i = spec.lastIndexOf(':');
117
117
  if (i <= 0)
@@ -29,7 +29,7 @@ import { fanDoctrineWake } from './close.js';
29
29
  import { buildPiArgv } from './launch.js';
30
30
  import { buildReviveKickoff, drainBearings } from './kickoff.js';
31
31
  import { headlessBrokerHost } from './host.js';
32
- import { recordedPidLiveness } from '../canvas/pid.js';
32
+ import { isRecordedPidAlive } from '../canvas/pid.js';
33
33
  import { reconcileBootLiveness } from '../canvas/boot.js';
34
34
  import { clearFault } from './fault.js';
35
35
  import { clearInjectedDocs } from '../substrate/injected-store.js';
@@ -118,24 +118,34 @@ export function reviveNode(nodeId, opts) {
118
118
  const db = openDb();
119
119
  db.exec('BEGIN IMMEDIATE');
120
120
  try {
121
- // Double-revive guard: identity-aware liveness on pi_pid, requiring
122
- // CONFIRMED alive not the fail-open-to-alive boolean adapter. A node
123
- // whose broker pid is CONFIRMED still running as that same broker (not a
124
- // reused pid) was already revived by another path — re-launching would put
125
- // a SECOND broker on the same session file. No-op. A bare isPidAlive would
126
- // misread a reused pid (heavy forking, esp. Linux) as "still alive" and
127
- // skip the relaunch forever; conversely, treating an INDETERMINATE read
128
- // (can't confirm either way, e.g. a transient `ps` probe failure) as
129
- // "alive" here would risk the opposite failuresilently skipping a
130
- // needed relaunch and stranding the node with no engine. So this guard
131
- // blocks ONLY on a positively confirmed-alive read; `dead` AND
132
- // `indeterminate` both proceed to relaunch. We accept that tradeoff: an
133
- // `indeterminate` read CAN come from a genuinely live broker whose identity
134
- // `ps` probe transiently failed, so proceeding risks a rare double-launch —
135
- // but stranding a truly-dead node with no engine is the worse failure, and
136
- // recovering from it is far harder, so we bias toward relaunching.
121
+ // Double-revive guard: identity-aware liveness on pi_pid. Only a
122
+ // CONFIRMED-dead read proceeds to relaunch; `alive` AND `indeterminate`
123
+ // both no-op. A node whose broker pid is still running as that same broker
124
+ // was already revived by another path — re-launching would put a SECOND
125
+ // broker on the same session file.
126
+ //
127
+ // Why `indeterminate` must NOT relaunch (live incident, 2026-07-10, 4GB
128
+ // Hearth home): the `dead` verdict comes from the fork-free signal-0
129
+ // syscall (`isPidAlive`), which cannot fail under resource pressure — a
130
+ // truly dead broker ALWAYS reads `dead`. `indeterminate` therefore means
131
+ // "the pid EXISTS but the `ps` identity probe failed" — and the probe is a
132
+ // fork+exec, so it fails exactly when the host is out of memory (ENOMEM)
133
+ // or thrashing (>2s timeout). Those failures are CORRELATED and
134
+ // persistent, not transient: under sustained OOM every guard read went
135
+ // `indeterminate`, every daemon grace window relaunched a broker whose
136
+ // predecessor was still alive, and `recordPid` below overwrote the row's
137
+ // pid — leaking each older broker untracked. That compounded to 7 live
138
+ // engines on one node (35 total on the home), which itself sustained the
139
+ // OOM that kept the probes failing. Relaunching on uncertainty is
140
+ // self-amplifying in precisely the regime where uncertainty occurs.
141
+ //
142
+ // The cost of no-opping on `indeterminate` is bounded and small: the only
143
+ // dead-but-indeterminate cases are a zombie or a reused pid whose `ps`
144
+ // probe failed — both re-read next daemon tick, and read `dead` the
145
+ // moment a probe succeeds. `isRecordedPidAlive` is the exact adapter for
146
+ // this direction (false ONLY on confirmed `dead`).
137
147
  const live = getNode(nodeId) ?? meta;
138
- if (recordedPidLiveness(live.pi_pid, live.pi_pid_identity) === 'alive') {
148
+ if (isRecordedPidAlive(live.pi_pid, live.pi_pid_identity)) {
139
149
  return {
140
150
  window: null,
141
151
  session: live.tmux_session ?? null,
@@ -25,8 +25,12 @@ function startStalledServer() {
25
25
  });
26
26
  });
27
27
  }
28
- test('a stalled endpoint returns a timeout failure within the configured bound instead of hanging forever', async () => {
28
+ test('a stalled endpoint returns a timeout failure within the configured bound instead of hanging forever', async (t) => {
29
29
  const server = await startStalledServer();
30
+ // Register the close BEFORE any assertion runs — a listening server left
31
+ // open by a thrown assertion keeps this test file's process alive forever
32
+ // (see the CI-hang postmortem on rebuild-coalescer.test.ts).
33
+ t.after(() => server.close());
30
34
  const exec = createRemoteExec(server.url, 'tok', 100); // small timeout for the test
31
35
  const started = Date.now();
32
36
  // Race against a generous outer deadline: if the transport doesn't
@@ -40,9 +44,8 @@ test('a stalled endpoint returns a timeout failure within the configured bound i
40
44
  assert.equal(result.ok, false);
41
45
  assert.match(result.stderr, /timeout/i);
42
46
  assert.ok(elapsed < 1000, `expected the timeout to fire near the configured 100ms bound, took ${elapsed}ms`);
43
- await server.close();
44
47
  });
45
- test('a normal fast response is unaffected by the timeout wiring', async () => {
48
+ test('a normal fast response is unaffected by the timeout wiring', async (t) => {
46
49
  const server = createServer((req, res) => {
47
50
  const chunks = [];
48
51
  req.on('data', (c) => chunks.push(c));
@@ -51,6 +54,7 @@ test('a normal fast response is unaffected by the timeout wiring', async () => {
51
54
  res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
52
55
  });
53
56
  });
57
+ t.after(() => new Promise((r) => server.close(() => r())));
54
58
  await new Promise((r) => server.listen(0, '127.0.0.1', r));
55
59
  const addr = server.address();
56
60
  const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
@@ -58,7 +62,6 @@ test('a normal fast response is unaffected by the timeout wiring', async () => {
58
62
  const result = await exec('crtr', ['--json', 'node', 'inspect', 'list']);
59
63
  assert.equal(result.ok, true);
60
64
  assert.equal(result.stdout, JSON.stringify({ nodes: [] }));
61
- await new Promise((r) => server.close(() => r()));
62
65
  });
63
66
  // --- Phase 3 review Minor 1: no token bytes may ever reach returned stderr ---
64
67
  test('an unreachable endpoint returns a flat generic network-error message, never the raw exception text', async () => {
@@ -75,7 +78,7 @@ test('a malformed token (contains a newline) is rejected up front with a generic
75
78
  assert.ok(!result.stderr.includes('bad\ntoken'));
76
79
  assert.ok(!result.stderr.includes('bad'));
77
80
  });
78
- test('a valid token containing typical bearer-token punctuation (-, _, .) still round-trips against a real server', async () => {
81
+ test('a valid token containing typical bearer-token punctuation (-, _, .) still round-trips against a real server', async (t) => {
79
82
  const server = createServer((req, res) => {
80
83
  const chunks = [];
81
84
  req.on('data', (c) => chunks.push(c));
@@ -84,6 +87,7 @@ test('a valid token containing typical bearer-token punctuation (-, _, .) still
84
87
  res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
85
88
  });
86
89
  });
90
+ t.after(() => new Promise((r) => server.close(() => r())));
87
91
  await new Promise((r) => server.listen(0, '127.0.0.1', r));
88
92
  const addr = server.address();
89
93
  const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
@@ -91,5 +95,4 @@ test('a valid token containing typical bearer-token punctuation (-, _, .) still
91
95
  const result = await exec('crtr', ['--json', 'node', 'inspect', 'list']);
92
96
  assert.equal(result.ok, true);
93
97
  assert.equal(result.stdout, JSON.stringify({ nodes: [] }));
94
- await new Promise((r) => server.close(() => r()));
95
98
  });
@@ -1 +1 @@
1
- export {};
1
+ import '../suppress-experimental-warnings.js';
@@ -1,4 +1,5 @@
1
1
  // crtrd entry point — spawned detached by `crtr sys daemon start` and by bin/crtrd.
2
2
  // Calls runDaemon() and never returns (the loop drives via setTimeout).
3
+ import '../suppress-experimental-warnings.js';
3
4
  import { runDaemon } from './crtrd.js';
4
5
  runDaemon();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ const originalEmit = process.emit;
2
+ process.emit = function (name, ...args) {
3
+ const [data] = args;
4
+ if (name === 'warning' &&
5
+ data instanceof Error &&
6
+ data.name === 'ExperimentalWarning' &&
7
+ data.message.includes('SQLite')) {
8
+ return false;
9
+ }
10
+ return originalEmit.apply(this, [
11
+ name,
12
+ ...args,
13
+ ]);
14
+ };
15
+ export {};
package/dist/types.js CHANGED
@@ -57,12 +57,12 @@ export function defaultKindsConfig() {
57
57
  },
58
58
  explore: {
59
59
  whenToUse: 'Map unfamiliar code or architecture — read-only orientation and code-path research with concrete file:line evidence. Anything diagnostic — investigating a failure, why something is broken or misbehaving, or live/runtime behavior — is advisor, not explore.',
60
- model: 'anthropic/light',
60
+ model: 'openai/light',
61
61
  },
62
62
  developer: {
63
63
  whenToUse: 'Implement a change — make the feature or fix genuinely work against its acceptance criteria, not merely compile.',
64
- model: 'anthropic/medium',
65
- orchestratorModel: 'anthropic/strong',
64
+ model: 'openai/medium',
65
+ orchestratorModel: 'openai/strong',
66
66
  },
67
67
  design: {
68
68
  whenToUse: 'Architect a solution — produce one design document an implementer can build from without re-deciding anything left open.',
@@ -150,15 +150,15 @@ export function defaultModelLaddersConfig() {
150
150
  return {
151
151
  anthropic: {
152
152
  ultra: 'anthropic/claude-fable-5:high',
153
- strong: 'anthropic/claude-opus-4-8:high',
154
- medium: 'anthropic/claude-sonnet-5:high',
155
- light: 'anthropic/claude-haiku-4-5:high',
153
+ strong: 'anthropic/claude-fable-5:medium',
154
+ medium: 'anthropic/claude-opus-4-8:high',
155
+ light: 'anthropic/claude-sonnet-5:high',
156
156
  },
157
157
  openai: {
158
- ultra: 'openai-codex/gpt-5.5:xhigh',
159
- strong: 'openai-codex/gpt-5.5:high',
160
- medium: 'openai-codex/gpt-5.5:medium',
161
- light: 'openai-codex/gpt-5.3-codex-spark',
158
+ ultra: 'openai-codex/gpt-5.6-sol:max',
159
+ strong: 'openai-codex/gpt-5.6-sol:high',
160
+ medium: 'openai-codex/gpt-5.6-terra:medium',
161
+ light: 'openai-codex/gpt-5.6-luna:low',
162
162
  },
163
163
  };
164
164
  }