@crouton-kit/crouter 0.3.15 → 0.3.17

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 (101) hide show
  1. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  2. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  3. package/dist/builtin-personas/plan/base.md +1 -1
  4. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  5. package/dist/builtin-personas/spec/base.md +1 -1
  6. package/dist/commands/canvas-browse.d.ts +2 -0
  7. package/dist/commands/canvas-browse.js +45 -0
  8. package/dist/commands/canvas-prune.js +11 -2
  9. package/dist/commands/canvas.js +3 -2
  10. package/dist/commands/chord.js +1 -1
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/node.js +14 -2
  13. package/dist/commands/skill/author.js +2 -2
  14. package/dist/commands/tmux-spread.js +2 -3
  15. package/dist/core/__tests__/cascade-close.test.js +199 -0
  16. package/dist/core/__tests__/close.test.js +2 -2
  17. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  18. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  19. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  20. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  21. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  22. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  23. package/dist/core/__tests__/focuses.test.js +5 -68
  24. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  25. package/dist/core/__tests__/grace-clock.test.js +115 -0
  26. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  27. package/dist/core/__tests__/helpers/harness.js +406 -0
  28. package/dist/core/__tests__/home-session.test.js +1 -1
  29. package/dist/core/__tests__/lifecycle.test.js +6 -13
  30. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  31. package/dist/core/__tests__/live-mutation.test.js +341 -0
  32. package/dist/core/__tests__/placement-focus.test.js +106 -46
  33. package/dist/core/__tests__/placement-teardown.test.js +4 -9
  34. package/dist/core/__tests__/relaunch.test.js +22 -16
  35. package/dist/core/__tests__/reset.test.js +11 -6
  36. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  37. package/dist/core/__tests__/spike-harness.test.js +241 -0
  38. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  39. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  40. package/dist/core/__tests__/tmux-surface.test.js +8 -9
  41. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  42. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  43. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  44. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  45. package/dist/core/canvas/browse/app.d.ts +4 -0
  46. package/dist/core/canvas/browse/app.js +349 -0
  47. package/dist/core/canvas/browse/model.d.ts +97 -0
  48. package/dist/core/canvas/browse/model.js +258 -0
  49. package/dist/core/canvas/browse/render.d.ts +41 -0
  50. package/dist/core/canvas/browse/render.js +387 -0
  51. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  52. package/dist/core/canvas/browse/terminal.js +100 -0
  53. package/dist/core/canvas/canvas.d.ts +9 -2
  54. package/dist/core/canvas/canvas.js +41 -3
  55. package/dist/core/canvas/db.js +2 -3
  56. package/dist/core/canvas/focuses.d.ts +2 -2
  57. package/dist/core/canvas/focuses.js +4 -3
  58. package/dist/core/canvas/render.d.ts +10 -0
  59. package/dist/core/canvas/render.js +25 -1
  60. package/dist/core/canvas/types.d.ts +1 -1
  61. package/dist/core/feed/inbox.d.ts +0 -3
  62. package/dist/core/feed/inbox.js +1 -5
  63. package/dist/core/runtime/busy.d.ts +8 -0
  64. package/dist/core/runtime/busy.js +46 -0
  65. package/dist/core/runtime/close.js +2 -2
  66. package/dist/core/runtime/demote.js +2 -7
  67. package/dist/core/runtime/launch.d.ts +3 -1
  68. package/dist/core/runtime/launch.js +4 -1
  69. package/dist/core/runtime/lifecycle.d.ts +1 -1
  70. package/dist/core/runtime/lifecycle.js +12 -4
  71. package/dist/core/runtime/naming.d.ts +3 -3
  72. package/dist/core/runtime/naming.js +6 -6
  73. package/dist/core/runtime/nodes.d.ts +7 -0
  74. package/dist/core/runtime/nodes.js +10 -1
  75. package/dist/core/runtime/placement.d.ts +39 -10
  76. package/dist/core/runtime/placement.js +100 -44
  77. package/dist/core/runtime/reset.d.ts +11 -8
  78. package/dist/core/runtime/reset.js +36 -31
  79. package/dist/core/runtime/revive.d.ts +1 -1
  80. package/dist/core/runtime/revive.js +2 -2
  81. package/dist/core/runtime/spawn.js +3 -3
  82. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  83. package/dist/core/runtime/tmux-chrome.js +4 -0
  84. package/dist/core/runtime/tmux.d.ts +13 -6
  85. package/dist/core/runtime/tmux.js +21 -12
  86. package/dist/daemon/crtrd.js +43 -21
  87. package/dist/pi-extensions/canvas-nav.js +40 -28
  88. package/dist/pi-extensions/canvas-resume.d.ts +21 -0
  89. package/dist/pi-extensions/canvas-resume.js +82 -0
  90. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  91. package/dist/pi-extensions/canvas-stophook.js +21 -9
  92. package/dist/prompts/skill.js +6 -1
  93. package/package.json +2 -2
  94. package/dist/commands/__tests__/skill.test.js +0 -290
  95. package/dist/core/__tests__/pkg.test.js +0 -218
  96. package/dist/core/__tests__/sys.test.js +0 -208
  97. package/dist/core/runtime/presence.d.ts +0 -30
  98. package/dist/core/runtime/presence.js +0 -178
  99. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  100. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  101. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -0,0 +1,406 @@
1
+ // helpers/harness.ts — a reusable, FAITHFUL integration-test driver for the
2
+ // node/canvas runtime.
3
+ //
4
+ // It drives the REAL `crtr` CLI (as subprocesses, AS specific nodes) into a
5
+ // REAL but isolated tmux session, substitutes the fake-pi vehicle (fixtures/
6
+ // fake-pi-host.ts) for the LLM `pi` via the CRTR_PI_BINARY seam, fires the REAL
7
+ // extension hooks inside that fake-pi over a polled control channel, and runs
8
+ // the daemon decision pass in-process via superviseTick(now). Every assertion
9
+ // reads straight off the canvas data layer. NOTHING here mocks the runtime.
10
+ //
11
+ // See harness-design.md §4/§5 and vehicle-and-hooks.md §6 for the architecture
12
+ // this implements. The ONE production seam used is CRTR_PI_BINARY in
13
+ // piCommand (src/core/runtime/tmux.ts) — no production file is modified.
14
+ //
15
+ // Isolation contract (harness-design.md §4a):
16
+ // • The harness itself runs AS a canvas node, so its OWN process.env carries
17
+ // the REAL canvas vars. We override CRTR_HOME (+ CRTR_PI_BINARY) for our own
18
+ // in-process reads/revives, and scrub every canvas var from each subprocess
19
+ // env. closeDb() rebinds sqlite to the isolated home before every read.
20
+ // • The isolated tmux session lives on the DEFAULT server (the runtime shells
21
+ // `tmux` with no -L, so an -L server would be invisible to the real CLI and
22
+ // to superviseTick). We only ever kill-session, never kill-server.
23
+ import { spawnSync } from 'node:child_process';
24
+ import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, renameSync, } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join, dirname } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { createRequire } from 'node:module';
29
+ import { createNode, getNode, subscribersOf, subscriptionsOf, } from '../../canvas/canvas.js';
30
+ import { closeDb } from '../../canvas/db.js';
31
+ import { isNodePaneAlive } from '../../runtime/placement.js';
32
+ import { superviseTick } from '../../../daemon/crtrd.js';
33
+ import { readInboxSince } from '../../feed/inbox.js';
34
+ // --- locations --------------------------------------------------------------
35
+ const HERE = dirname(fileURLToPath(import.meta.url)); // src/core/__tests__/helpers
36
+ const CROUTER = join(HERE, '..', '..', '..', '..'); // package root
37
+ const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
38
+ const FAKE_PI_HOST = join(HERE, '..', 'fixtures', 'fake-pi-host.ts');
39
+ const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
40
+ // A multi-word launcher, baked verbatim ahead of the (shell-quoted) argv by the
41
+ // seam. Absolute paths so it works regardless of the spawned window's cwd.
42
+ const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
43
+ /** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
44
+ export function hasTmux() {
45
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
46
+ }
47
+ function tmuxSessionExists(session) {
48
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
49
+ }
50
+ // Every canvas/tmux var the harness itself runs under — scrubbed from each child
51
+ // env so a spawned CLI cannot leak the REAL canvas into the isolated test.
52
+ const CANVAS_ENV_KEYS = [
53
+ 'CRTR_NODE_ID',
54
+ 'CRTR_HOME',
55
+ 'CRTR_ROOT_SESSION',
56
+ 'CRTR_NODE_SESSION',
57
+ 'CRTR_PARENT_NODE_ID',
58
+ 'CRTR_FRONT_DOOR',
59
+ 'CRTR_KIND',
60
+ 'CRTR_MODE',
61
+ 'CRTR_LIFECYCLE',
62
+ 'CRTR_NODE_CWD',
63
+ 'CRTR_PI_BINARY',
64
+ 'TMUX',
65
+ 'TMUX_PANE',
66
+ ];
67
+ function cleanBaseEnv() {
68
+ const e = {};
69
+ for (const [k, v] of Object.entries(process.env))
70
+ if (v !== undefined)
71
+ e[k] = v;
72
+ for (const k of CANVAS_ENV_KEYS)
73
+ delete e[k];
74
+ // Contain per-invocation bootstrap + auto-update side effects (they write to
75
+ // ~/.crouter / ~/.claude / ~/.pi, NOT under CRTR_HOME — HOME is contained too).
76
+ e['CRTR_NO_BOOTSTRAP'] = '1';
77
+ e['CRTR_NO_AUTO_UPDATE'] = '1';
78
+ e['CRTR_NO_BOOT_SKILL'] = '1';
79
+ e['CRTR_NO_MODE_CMDS'] = '1';
80
+ e['CRTR_NO_AUTO_INIT'] = '1';
81
+ return e;
82
+ }
83
+ async function waitFor(probe, opts = {}) {
84
+ const timeoutMs = opts.timeoutMs ?? 20_000;
85
+ const intervalMs = opts.intervalMs ?? 100;
86
+ const deadline = Date.now() + timeoutMs;
87
+ for (;;) {
88
+ const v = probe();
89
+ if (v)
90
+ return v;
91
+ if (Date.now() > deadline)
92
+ throw new Error(`waitFor timed out: ${opts.label ?? 'condition'}`);
93
+ await new Promise((r) => setTimeout(r, intervalMs));
94
+ }
95
+ }
96
+ export async function createHarness(opts = {}) {
97
+ if (!hasTmux())
98
+ throw new Error('createHarness: tmux not available');
99
+ const origHome = process.env['CRTR_HOME'];
100
+ const origPiBinary = process.env['CRTR_PI_BINARY'];
101
+ const origNodeSession = process.env['CRTR_NODE_SESSION'];
102
+ const home = mkdtempSync(join(tmpdir(), 'crtr-harness-home-'));
103
+ const tmpHome = mkdtempSync(join(tmpdir(), 'crtr-harness-HOME-'));
104
+ const session = `${opts.sessionPrefix ?? 'crtr-harness'}-${process.pid}-${Date.now().toString(36)}`;
105
+ // The harness reads/writes the isolated canvas in-process. CRTR_PI_BINARY in
106
+ // OUR env makes in-process revives (superviseTick → reviveNode → openNodeWindow)
107
+ // bake the fake-pi into the command string.
108
+ process.env['CRTR_HOME'] = home;
109
+ process.env['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
110
+ delete process.env['CRTR_NODE_SESSION'];
111
+ closeDb();
112
+ // Pre-create the isolated session on the DEFAULT server so teardown always
113
+ // has a target and `node new`'s ensureSession no-ops.
114
+ // ISOLATION ASSUMPTION (see header + MINOR-6): isolation is by SESSION NAME on
115
+ // the DEFAULT tmux server only. The runtime CLI shells `tmux` with no `-L`, so
116
+ // a custom-socket server (`tmux -L foo`) would be invisible to it; this harness
117
+ // therefore assumes the default socket and only ever kill-sessions, never the
118
+ // server.
119
+ spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
120
+ stdio: 'ignore',
121
+ });
122
+ // Put CRTR_PI_BINARY in the SESSION environment so EVERY pane spawned in this
123
+ // session inherits it — critically the fake-pi's OWN process, so when its real
124
+ // stophook fires reviveInPlace (respawn-pane -k on its own pane, the refresh-
125
+ // yield path) the in-process piCommand there substitutes the fake-pi too.
126
+ spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
127
+ stdio: 'ignore',
128
+ });
129
+ spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
130
+ const pidsToKill = new Set();
131
+ let nextRootSeq = 0;
132
+ // -- env for a subprocess CLI invocation -----------------------------------
133
+ function childEnv(nodeId) {
134
+ const e = cleanBaseEnv();
135
+ e['CRTR_HOME'] = home;
136
+ e['HOME'] = tmpHome;
137
+ e['CRTR_NODE_SESSION'] = session;
138
+ e['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
139
+ if (nodeId !== null)
140
+ e['CRTR_NODE_ID'] = nodeId;
141
+ return e;
142
+ }
143
+ function nodeDir(id) {
144
+ return join(home, 'nodes', id);
145
+ }
146
+ function nodeDirs() {
147
+ try {
148
+ return readdirSync(join(home, 'nodes'));
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ }
154
+ function readLines(path) {
155
+ try {
156
+ return readFileSync(path, 'utf8')
157
+ .split('\n')
158
+ .filter((l) => l.trim() !== '');
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ }
164
+ function cli(nodeId, args) {
165
+ const res = spawnSync(process.execPath, ['--import', TSX_ESM, CLI_SRC, ...args], {
166
+ cwd: CROUTER,
167
+ env: childEnv(nodeId),
168
+ encoding: 'utf8',
169
+ stdio: ['ignore', 'pipe', 'pipe'],
170
+ timeout: 60_000,
171
+ });
172
+ closeDb();
173
+ let json;
174
+ try {
175
+ json = JSON.parse(res.stdout ?? '');
176
+ }
177
+ catch {
178
+ /* not json */
179
+ }
180
+ return { code: res.status ?? -1, stdout: res.stdout ?? '', stderr: res.stderr ?? '', json };
181
+ }
182
+ // -- control channel -------------------------------------------------------
183
+ function sendCmd(nodeId, cmd) {
184
+ const dir = nodeDir(nodeId);
185
+ const tmp = join(dir, 'fake-pi.cmd.tmp');
186
+ writeFileSync(tmp, JSON.stringify(cmd));
187
+ renameSync(tmp, join(dir, 'fake-pi.cmd')); // atomic: host never reads a partial
188
+ }
189
+ function eventCount(nodeId, event) {
190
+ return readLines(join(nodeDir(nodeId), 'fake-pi.events.jsonl')).filter((l) => {
191
+ try {
192
+ return JSON.parse(l).event === event;
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }).length;
198
+ }
199
+ function bootCount(nodeId) {
200
+ return readLines(join(nodeDir(nodeId), 'fake-pi.boots.jsonl')).length;
201
+ }
202
+ function injected(nodeId) {
203
+ return readLines(join(nodeDir(nodeId), 'fake-pi.injected.jsonl'))
204
+ .map((l) => {
205
+ try {
206
+ return JSON.parse(l);
207
+ }
208
+ catch {
209
+ return null;
210
+ }
211
+ })
212
+ .filter((x) => x !== null);
213
+ }
214
+ // Wait for the host to have BEGUN dispatching agent_end (recorded before its
215
+ // handlers run, so it survives a handler that tears the process down).
216
+ async function awaitAgentEnd(nodeId, base, label) {
217
+ await waitFor(() => eventCount(nodeId, 'agent_end') > base, {
218
+ timeoutMs: 20_000,
219
+ label,
220
+ });
221
+ }
222
+ const harness = {
223
+ home,
224
+ session,
225
+ spawnRoot(task, o = {}) {
226
+ const id = o.id ?? `root-${process.pid}-${nextRootSeq++}`;
227
+ const meta = {
228
+ node_id: id,
229
+ name: o.id ?? (task.slice(0, 24) || id),
230
+ created: new Date().toISOString(),
231
+ cwd: CROUTER,
232
+ kind: o.kind ?? 'general',
233
+ mode: o.mode ?? 'base',
234
+ lifecycle: o.lifecycle ?? 'resident',
235
+ status: 'active',
236
+ parent: null,
237
+ };
238
+ createNode(meta);
239
+ closeDb();
240
+ return id;
241
+ },
242
+ async spawnChild(parentId, task, o = {}) {
243
+ const before = new Set(nodeDirs());
244
+ const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER];
245
+ if (o.kind)
246
+ args.push('--kind', o.kind);
247
+ if (o.mode)
248
+ args.push('--mode', o.mode);
249
+ const res = cli(parentId, args);
250
+ if (res.code !== 0) {
251
+ throw new Error(`spawnChild(${parentId}) failed (code ${res.code})\n--stdout--\n${res.stdout}\n--stderr--\n${res.stderr}`);
252
+ }
253
+ const added = nodeDirs().filter((d) => !before.has(d));
254
+ if (added.length !== 1) {
255
+ throw new Error(`spawnChild: expected exactly 1 new node dir, got [${added.join(', ')}]`);
256
+ }
257
+ const childId = added[0];
258
+ await harness.awaitBoot(childId);
259
+ return childId;
260
+ },
261
+ cli,
262
+ async turn(nodeId, text = '') {
263
+ const base = eventCount(nodeId, 'agent_end');
264
+ sendCmd(nodeId, { cmd: 'turn', id: `turn-${Date.now()}`, text });
265
+ await awaitAgentEnd(nodeId, base, `turn agent_end for ${nodeId}`);
266
+ },
267
+ async stop(nodeId, reason = 'stop') {
268
+ const base = eventCount(nodeId, 'agent_end');
269
+ sendCmd(nodeId, { cmd: 'stop', id: `stop-${Date.now()}`, reason });
270
+ await awaitAgentEnd(nodeId, base, `stop agent_end for ${nodeId}`);
271
+ },
272
+ async finish(nodeId, finalText) {
273
+ const res = cli(nodeId, ['push', 'final', finalText]);
274
+ if (res.code !== 0) {
275
+ throw new Error(`finish(${nodeId}): push final failed (code ${res.code})\n${res.stderr}`);
276
+ }
277
+ // Fire agent_end so the now-done node runs the real (b) done branch
278
+ // (null presence + ctx.shutdown → window closes), exactly as real pi would.
279
+ const base = eventCount(nodeId, 'agent_end');
280
+ sendCmd(nodeId, { cmd: 'stop', id: `finish-${Date.now()}` });
281
+ await awaitAgentEnd(nodeId, base, `finish agent_end for ${nodeId}`);
282
+ await harness.waitForPaneGone(nodeId);
283
+ },
284
+ async yieldNode(nodeId, note) {
285
+ const res = cli(nodeId, ['node', 'yield', note]);
286
+ if (res.code !== 0) {
287
+ throw new Error(`yieldNode(${nodeId}): node yield failed (code ${res.code})\n${res.stderr}`);
288
+ }
289
+ // node yield set intent=refresh (active, kept). Fire agent_end so the real
290
+ // (b') branch runs reviveInPlace (respawn-pane -k) IN the fake-pi's pane —
291
+ // a FRESH fake-pi boots (resume); its session_start clears intent=refresh.
292
+ const baseBoots = bootCount(nodeId);
293
+ sendCmd(nodeId, { cmd: 'stop', id: `yield-${Date.now()}` });
294
+ await waitFor(() => bootCount(nodeId) > baseBoots, {
295
+ timeoutMs: 30_000,
296
+ label: `fresh boot after yield for ${nodeId}`,
297
+ });
298
+ await waitFor(() => {
299
+ closeDb();
300
+ const n = getNode(nodeId);
301
+ return n?.intent == null && n?.status === 'active';
302
+ }, { timeoutMs: 20_000, label: `intent=refresh cleared after yield for ${nodeId}` });
303
+ await harness.awaitBoot(nodeId, { minCount: baseBoots + 1 });
304
+ },
305
+ async tick(now) {
306
+ closeDb();
307
+ await superviseTick(now);
308
+ closeDb();
309
+ },
310
+ async awaitBoot(nodeId, o = {}) {
311
+ const minCount = o.minCount ?? 1;
312
+ const bootsPath = join(nodeDir(nodeId), 'fake-pi.boots.jsonl');
313
+ const errPath = join(nodeDir(nodeId), 'fake-pi.error');
314
+ const lines = await waitFor(() => {
315
+ const ls = readLines(bootsPath);
316
+ return ls.length >= minCount ? ls : null;
317
+ }, {
318
+ timeoutMs: o.timeoutMs ?? 30_000,
319
+ label: `fake-pi boot >= ${minCount} for ${nodeId}` +
320
+ (existsSync(errPath) ? ` (error file: ${readFileSync(errPath, 'utf8')})` : ''),
321
+ });
322
+ const boot = JSON.parse(lines[lines.length - 1]);
323
+ if (typeof boot.pid === 'number')
324
+ pidsToKill.add(boot.pid);
325
+ return boot;
326
+ },
327
+ async awaitWake(nodeId, o = {}) {
328
+ const sinceCount = o.sinceCount ?? 0;
329
+ const match = o.match;
330
+ const fresh = await waitFor(() => {
331
+ const all = injected(nodeId).slice(sinceCount);
332
+ if (all.length === 0)
333
+ return null;
334
+ if (match && !all.some((e) => match.test(e.content)))
335
+ return null;
336
+ return all;
337
+ }, { timeoutMs: o.timeoutMs ?? 15_000, label: `wake delivery for ${nodeId}` });
338
+ return fresh.map((e) => e.content);
339
+ },
340
+ async waitForStatus(nodeId, status, timeoutMs = 20_000) {
341
+ await waitFor(() => {
342
+ closeDb();
343
+ return getNode(nodeId)?.status === status;
344
+ }, { timeoutMs, label: `status=${status} for ${nodeId}` });
345
+ },
346
+ async waitForPaneGone(nodeId, timeoutMs = 20_000) {
347
+ await waitFor(() => {
348
+ closeDb();
349
+ return !isNodePaneAlive(nodeId);
350
+ }, { timeoutMs, label: `pane gone for ${nodeId}` });
351
+ },
352
+ waitFor,
353
+ node(nodeId) {
354
+ closeDb();
355
+ return getNode(nodeId);
356
+ },
357
+ status(nodeId) {
358
+ closeDb();
359
+ return getNode(nodeId)?.status ?? null;
360
+ },
361
+ paneAlive(nodeId) {
362
+ closeDb();
363
+ return isNodePaneAlive(nodeId);
364
+ },
365
+ inbox(nodeId) {
366
+ closeDb();
367
+ return readInboxSince(nodeId);
368
+ },
369
+ injected,
370
+ subscribers(nodeId) {
371
+ closeDb();
372
+ return subscribersOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
373
+ },
374
+ subscriptions(nodeId) {
375
+ closeDb();
376
+ return subscriptionsOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
377
+ },
378
+ async dispose() {
379
+ spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
380
+ for (const p of pidsToKill) {
381
+ try {
382
+ process.kill(p, 'SIGKILL');
383
+ }
384
+ catch {
385
+ /* already gone */
386
+ }
387
+ }
388
+ closeDb();
389
+ rmSync(home, { recursive: true, force: true });
390
+ rmSync(tmpHome, { recursive: true, force: true });
391
+ if (origHome === undefined)
392
+ delete process.env['CRTR_HOME'];
393
+ else
394
+ process.env['CRTR_HOME'] = origHome;
395
+ if (origPiBinary === undefined)
396
+ delete process.env['CRTR_PI_BINARY'];
397
+ else
398
+ process.env['CRTR_PI_BINARY'] = origPiBinary;
399
+ if (origNodeSession === undefined)
400
+ delete process.env['CRTR_NODE_SESSION'];
401
+ else
402
+ process.env['CRTR_NODE_SESSION'] = origNodeSession;
403
+ },
404
+ };
405
+ return harness;
406
+ }
@@ -18,7 +18,7 @@ import { createNode, getNode, updateNode } from '../canvas/canvas.js';
18
18
  import { nodeMetaPath } from '../canvas/paths.js';
19
19
  import { closeDb } from '../canvas/db.js';
20
20
  import { resolveBirthSession, homeSessionOf } from '../runtime/nodes.js';
21
- import { nodeSession } from '../runtime/tmux.js';
21
+ import { nodeSession } from '../runtime/nodes.js';
22
22
  import { relaunchRoot } from '../runtime/reset.js';
23
23
  import { demoteNode } from '../runtime/demote.js';
24
24
  let home;
@@ -67,18 +67,11 @@ test('finalize: illegal from done|dead|canceled → throws, status untouched', (
67
67
  }
68
68
  });
69
69
  // ---------------------------------------------------------------------------
70
- // reapdone + intent cleared, legal from ANY status (forced teardown)
71
- // ---------------------------------------------------------------------------
72
- test('reap: any status done + intent cleared', () => {
73
- for (const from of ALL) {
74
- mk(`n_${from}`, from, 'refresh');
75
- const m = transition(`n_${from}`, 'reap');
76
- assert.equal(m.status, 'done', `reap from ${from}`);
77
- assert.equal(m.intent, null, `reap from ${from} clears intent`);
78
- }
79
- });
80
- // ---------------------------------------------------------------------------
81
- // cancel → canceled + intent cleared, legal from ANY status
70
+ // cancelcanceled + intent cleared, legal from ANY status (forced teardown).
71
+ // A5 (human-confirmed 2026-06-06): the `reap` event was COLLAPSED into `cancel`
72
+ // they were identical (status differed only; both intent=null, from=ANY, no
73
+ // side effects in transition()). Every external reap (close cascade AND
74
+ // reset/relaunch) now routes through `cancel`; `done` is reserved for finalize.
82
75
  // ---------------------------------------------------------------------------
83
76
  test('cancel: any status → canceled + intent cleared', () => {
84
77
  for (const from of ALL) {
@@ -172,7 +165,7 @@ test('boot: illegal from done|dead|canceled → throws', () => {
172
165
  // unknown node → throws for every event
173
166
  // ---------------------------------------------------------------------------
174
167
  test('transition on an unknown node throws', () => {
175
- for (const ev of ['finalize', 'reap', 'cancel', 'crash', 'yield', 'release', 'revive', 'boot']) {
168
+ for (const ev of ['finalize', 'cancel', 'crash', 'yield', 'release', 'revive', 'boot']) {
176
169
  assert.throws(() => transition('ghost', ev), /unknown node/);
177
170
  }
178
171
  });
@@ -0,0 +1 @@
1
+ export {};