@crouton-kit/crouter 0.3.20 → 0.3.22

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 (144) hide show
  1. package/dist/builtin-memory/crouter-development/marketplaces.md +2 -4
  2. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +3 -5
  3. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +3 -6
  4. package/dist/builtin-memory/crouter-development/personas.md +2 -6
  5. package/dist/builtin-memory/crouter-development/plugins.md +2 -4
  6. package/dist/builtin-memory/design.md +2 -4
  7. package/dist/builtin-memory/development.md +3 -3
  8. package/dist/builtin-memory/planning.md +3 -4
  9. package/dist/builtin-memory/spec.md +3 -10
  10. package/dist/builtin-personas/orchestration-kernel.md +1 -1
  11. package/dist/commands/canvas-history/read.d.ts +1 -0
  12. package/dist/commands/canvas-history/read.js +61 -0
  13. package/dist/commands/canvas-history/search.d.ts +1 -0
  14. package/dist/commands/canvas-history/search.js +270 -0
  15. package/dist/commands/canvas-history/show.d.ts +1 -0
  16. package/dist/commands/canvas-history/show.js +79 -0
  17. package/dist/commands/canvas-history.d.ts +2 -0
  18. package/dist/commands/canvas-history.js +33 -0
  19. package/dist/commands/canvas.js +3 -2
  20. package/dist/commands/human/queue.js +2 -2
  21. package/dist/commands/memory/__tests__/lint-schema.test.js +29 -0
  22. package/dist/commands/memory/find.js +14 -52
  23. package/dist/commands/memory/lint.d.ts +7 -0
  24. package/dist/commands/memory/lint.js +29 -35
  25. package/dist/commands/memory/list.js +8 -18
  26. package/dist/commands/memory/read.js +4 -37
  27. package/dist/commands/memory/shared.js +1 -2
  28. package/dist/commands/memory/write.js +6 -9
  29. package/dist/commands/node.js +47 -25
  30. package/dist/commands/pkg/bridge.d.ts +1 -0
  31. package/dist/commands/pkg/bridge.js +137 -0
  32. package/dist/commands/pkg/plugin-inspect.js +7 -6
  33. package/dist/commands/pkg.js +2 -1
  34. package/dist/commands/push.js +10 -15
  35. package/dist/commands/skill/author.js +35 -44
  36. package/dist/commands/skill/shared.d.ts +1 -5
  37. package/dist/commands/skill/shared.js +9 -63
  38. package/dist/commands/skill.js +5 -6
  39. package/dist/commands/sys/doctor.js +6 -144
  40. package/dist/commands/tmux-spread.js +16 -1
  41. package/dist/commands/view-cycle.js +9 -5
  42. package/dist/commands/view-run.js +3 -2
  43. package/dist/core/__tests__/broker-dormant-wake.test.js +71 -34
  44. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +21 -5
  45. package/dist/core/__tests__/canvas-inbox-watcher.test.js +21 -5
  46. package/dist/core/__tests__/child-death-wake.test.js +133 -185
  47. package/dist/core/__tests__/context-intro.test.js +23 -27
  48. package/dist/core/__tests__/daemon-liveness.test.js +28 -258
  49. package/dist/core/__tests__/flagship-lifecycle.test.js +156 -135
  50. package/dist/core/__tests__/frame-decoder-perf.test.js +51 -25
  51. package/dist/core/__tests__/{broker-attach-limits.test.js → full/broker-attach-limits.test.js} +62 -35
  52. package/dist/core/__tests__/{broker-attach-stream.test.js → full/broker-attach-stream.test.js} +54 -31
  53. package/dist/core/__tests__/full/broker-crash-teardown.test.js +143 -0
  54. package/dist/core/__tests__/full/broker-dialogs.test.js +153 -0
  55. package/dist/core/__tests__/full/broker-lifecycle.test.js +116 -0
  56. package/dist/core/__tests__/{cascade-close.test.js → full/cascade-close.test.js} +4 -3
  57. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +292 -0
  58. package/dist/core/__tests__/{dead-pane-regression.test.js → full/dead-pane-regression.test.js} +2 -2
  59. package/dist/core/__tests__/{detach-focus.test.js → full/detach-focus.test.js} +7 -7
  60. package/dist/core/__tests__/{human-new-window-regression.test.js → full/human-new-window-regression.test.js} +2 -2
  61. package/dist/core/__tests__/{placement-focus.test.js → full/placement-focus.test.js} +5 -5
  62. package/dist/core/__tests__/{placement-reconcile.test.js → full/placement-reconcile.test.js} +3 -3
  63. package/dist/core/__tests__/{placement-revive.test.js → full/placement-revive.test.js} +5 -5
  64. package/dist/core/__tests__/{placement-teardown.test.js → full/placement-teardown.test.js} +4 -4
  65. package/dist/core/__tests__/{review-render-pane-regression.test.js → full/review-render-pane-regression.test.js} +2 -2
  66. package/dist/core/__tests__/full/spike-harness.test.d.ts +1 -0
  67. package/dist/core/__tests__/full/spike-harness.test.js +117 -0
  68. package/dist/core/__tests__/grace-clock.test.js +72 -75
  69. package/dist/core/__tests__/helpers/harness.d.ts +35 -1
  70. package/dist/core/__tests__/helpers/harness.js +70 -12
  71. package/dist/core/__tests__/live-mutation-verbs.test.js +100 -105
  72. package/dist/core/__tests__/live-mutation.test.js +111 -134
  73. package/dist/core/__tests__/memory.test.js +4 -4
  74. package/dist/core/__tests__/revive.test.js +96 -86
  75. package/dist/core/__tests__/subscription-delivery.test.js +116 -138
  76. package/dist/core/__tests__/wake-origin.test.js +54 -50
  77. package/dist/core/bootstrap.js +18 -14
  78. package/dist/core/bridge-map.d.ts +19 -0
  79. package/dist/core/bridge-map.js +73 -0
  80. package/dist/core/canvas/history.d.ts +91 -0
  81. package/dist/core/canvas/history.js +389 -0
  82. package/dist/core/canvas/index.d.ts +1 -0
  83. package/dist/core/canvas/index.js +1 -0
  84. package/dist/core/config.js +3 -22
  85. package/dist/core/help.d.ts +2 -2
  86. package/dist/core/help.js +1 -1
  87. package/dist/core/memory-resolver.d.ts +9 -2
  88. package/dist/core/memory-resolver.js +47 -7
  89. package/dist/core/render.d.ts +4 -3
  90. package/dist/core/render.js +38 -41
  91. package/dist/core/resolver.d.ts +1 -30
  92. package/dist/core/resolver.js +6 -546
  93. package/dist/core/runtime/bearings.d.ts +2 -2
  94. package/dist/core/runtime/bearings.js +2 -2
  95. package/dist/core/runtime/persona.js +5 -7
  96. package/dist/core/scope.d.ts +7 -5
  97. package/dist/core/scope.js +16 -17
  98. package/dist/core/substrate/ceiling.d.ts +28 -0
  99. package/dist/core/substrate/ceiling.js +87 -0
  100. package/dist/core/substrate/index.d.ts +2 -0
  101. package/dist/core/substrate/index.js +1 -0
  102. package/dist/core/substrate/on-read.js +18 -2
  103. package/dist/core/substrate/render.d.ts +3 -5
  104. package/dist/core/substrate/render.js +68 -59
  105. package/dist/core/substrate/schema.d.ts +18 -13
  106. package/dist/core/substrate/schema.js +12 -11
  107. package/dist/pi-extensions/canvas-context-intro.js +5 -3
  108. package/dist/pi-extensions/canvas-inbox-watcher.js +19 -2
  109. package/dist/types.d.ts +3 -18
  110. package/dist/types.js +0 -8
  111. package/package.json +4 -3
  112. package/dist/builtin-skills/.crouter-plugin/plugin.json +0 -5
  113. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +0 -157
  114. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +0 -106
  115. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +0 -49
  116. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +0 -49
  117. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +0 -156
  118. package/dist/builtin-skills/skills/design/SKILL.md +0 -51
  119. package/dist/builtin-skills/skills/development/SKILL.md +0 -109
  120. package/dist/builtin-skills/skills/planning/SKILL.md +0 -59
  121. package/dist/builtin-skills/skills/spec/SKILL.md +0 -83
  122. package/dist/commands/skill/state.d.ts +0 -3
  123. package/dist/commands/skill/state.js +0 -71
  124. package/dist/core/__tests__/broker-crash-teardown.test.js +0 -116
  125. package/dist/core/__tests__/broker-dialogs.test.js +0 -126
  126. package/dist/core/__tests__/broker-lifecycle.test.js +0 -87
  127. package/dist/core/__tests__/resolver.test.js +0 -181
  128. package/dist/core/__tests__/spike-harness.test.js +0 -242
  129. /package/dist/{core/__tests__/broker-attach-limits.test.d.ts → commands/memory/__tests__/lint-schema.test.d.ts} +0 -0
  130. /package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-limits.test.d.ts} +0 -0
  131. /package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-attach-stream.test.d.ts} +0 -0
  132. /package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-crash-teardown.test.d.ts} +0 -0
  133. /package/dist/core/__tests__/{broker-lifecycle.test.d.ts → full/broker-dialogs.test.d.ts} +0 -0
  134. /package/dist/core/__tests__/{cascade-close.test.d.ts → full/broker-lifecycle.test.d.ts} +0 -0
  135. /package/dist/core/__tests__/{dead-pane-regression.test.d.ts → full/cascade-close.test.d.ts} +0 -0
  136. /package/dist/core/__tests__/{detach-focus.test.d.ts → full/daemon-liveness-pane.full.test.d.ts} +0 -0
  137. /package/dist/core/__tests__/{human-new-window-regression.test.d.ts → full/dead-pane-regression.test.d.ts} +0 -0
  138. /package/dist/core/__tests__/{placement-focus.test.d.ts → full/detach-focus.test.d.ts} +0 -0
  139. /package/dist/core/__tests__/{placement-reconcile.test.d.ts → full/human-new-window-regression.test.d.ts} +0 -0
  140. /package/dist/core/__tests__/{placement-revive.test.d.ts → full/placement-focus.test.d.ts} +0 -0
  141. /package/dist/core/__tests__/{placement-teardown.test.d.ts → full/placement-reconcile.test.d.ts} +0 -0
  142. /package/dist/core/__tests__/{resolver.test.d.ts → full/placement-revive.test.d.ts} +0 -0
  143. /package/dist/core/__tests__/{review-render-pane-regression.test.d.ts → full/placement-teardown.test.d.ts} +0 -0
  144. /package/dist/core/__tests__/{spike-harness.test.d.ts → full/review-render-pane-regression.test.d.ts} +0 -0
@@ -0,0 +1,117 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/full/spike-harness.test.ts
2
+ //
3
+ // HEADLESS RETARGET (foundation-spec §C.14 + §E). The throwaway-grade POC that a
4
+ // faithful integration harness for the node/canvas runtime is feasible — now on
5
+ // the BROKER path, tmux-free (no hasTmux() gate). FULL TIER: it boots a REAL
6
+ // broker process (~5s pi-SDK load), so it lives in full/ (CI), not the fast loop. It drives the
7
+ // REAL `crtr` CLI to spawn a `--headless` node onto the REAL headless broker host,
8
+ // which boots a REAL detached broker PROCESS hosting the fake SDK engine via the
9
+ // CRTR_BROKER_ENGINE seam — proving the broker exec's with the right argv+env,
10
+ // loads the REAL `-e` canvas extensions, and that real lifecycle hooks drive real
11
+ // canvas transitions.
12
+ //
13
+ // Milestones (de-risk order):
14
+ // 1. SEAM — piCommand substitutes CRTR_PI_BINARY only when set (pure unit).
15
+ // 2. ROUND-TRIP— real `node new --headless` → real broker boots with CRTR_NODE_ID
16
+ // + the -e canvas extensions intact (GO/NO-GO).
17
+ // 3. REAL HOOKS— the broker loads the real stophook: session_start captures
18
+ // pi_session_id + recordPid(broker pid); a `push --final` agent_end
19
+ // drives status=done through the stophook's clean-exit branch.
20
+ //
21
+ // (1) BUG LOCKED — harness faithfulness. A real `node new --headless` must boot a
22
+ // real broker that loads EVERY canvas `-e` extension with none failing, the
23
+ // REAL stophook must capture the session id on session_start, and the REAL
24
+ // stophook agent_end clean-exit branch must transition the node to done.
25
+ //
26
+ // (2) WHY MODEL-LEVEL / HEADLESS — the broker hosts the fake engine over the SDK
27
+ // seam (no real LLM), boots DETACHED supervised by pid (no tmux pane), and
28
+ // every proof is a file the broker writes (fake-pi.boots.jsonl) or a canvas
29
+ // row — nothing reads a pane. ONE real boot is inherent: the POC's whole
30
+ // point is that a real broker PROCESS loads real extensions + fires real hooks.
31
+ // (The fake engine models a clean exit via the broker's shutdownHandler rather
32
+ // than a pi `session_shutdown` frame, so the clean done transition is driven
33
+ // through the stophook's `push --final` agent_end branch — the same real
34
+ // stophook code path that shuts a done broker down.)
35
+ //
36
+ // (3) HOW THE HEADLESS DRIVE STILL FAILS IF THE BUG REGRESSES — if the seam or an
37
+ // extension load broke, failedExt is non-empty or extPaths drifts from
38
+ // CANVAS_EXTENSIONS → RED; if the stophook didn't capture the session,
39
+ // pi_session_id stays null → RED; if the clean-exit branch didn't shut the
40
+ // broker down, status never reaches done → the finish wait times out.
41
+ import { test, before, after } from 'node:test';
42
+ import assert from 'node:assert/strict';
43
+ import { createHarness } from '../helpers/harness.js';
44
+ import { piCommand } from '../../runtime/placement.js';
45
+ import { CANVAS_EXTENSIONS } from '../../runtime/launch.js';
46
+ let h;
47
+ let root;
48
+ before(async () => {
49
+ h = await createHarness({ headless: true, sessionPrefix: 'crtr-spike' });
50
+ root = h.spawnRoot('spike acting-root');
51
+ });
52
+ after(async () => {
53
+ if (h !== undefined)
54
+ await h.dispose();
55
+ });
56
+ // ===========================================================================
57
+ // MILESTONE 1 — the CRTR_PI_BINARY seam (pure unit; no boot, no tmux).
58
+ // ===========================================================================
59
+ test('M1 seam: piCommand exec\'s `pi` when CRTR_PI_BINARY is unset, substitutes when set', () => {
60
+ const saved = process.env['CRTR_PI_BINARY'];
61
+ try {
62
+ delete process.env['CRTR_PI_BINARY'];
63
+ const unset = piCommand(['-e', '/abs/ext.ts', '-n', 'label']);
64
+ assert.equal(unset, "pi '-e' '/abs/ext.ts' '-n' 'label'", 'unset → identical to exec pi');
65
+ assert.ok(unset.startsWith('pi '), 'unset → leads with the literal pi binary');
66
+ process.env['CRTR_PI_BINARY'] = '/tmp/fake-pi';
67
+ const set = piCommand(['-e', '/abs/ext.ts']);
68
+ assert.ok(set.startsWith('/tmp/fake-pi '), 'set → leads with the substituted binary');
69
+ assert.ok(!set.startsWith('pi '), 'set → no longer the literal pi');
70
+ assert.equal(set, "/tmp/fake-pi '-e' '/abs/ext.ts'", 'argv still shell-quoted after the substitution');
71
+ // A multi-word launcher is spliced verbatim (argv stays quoted).
72
+ process.env['CRTR_PI_BINARY'] = 'node --import tsx/esm host.ts';
73
+ assert.equal(piCommand(['-n', 'x']), "node --import tsx/esm host.ts '-n' 'x'", 'multi-word binary spliced ahead of the quoted argv');
74
+ // An explicit binary arg still overrides the env.
75
+ assert.ok(piCommand(['-n', 'x'], 'pi').startsWith('pi '), 'explicit binary arg wins over the env seam');
76
+ }
77
+ finally {
78
+ if (saved === undefined)
79
+ delete process.env['CRTR_PI_BINARY'];
80
+ else
81
+ process.env['CRTR_PI_BINARY'] = saved;
82
+ }
83
+ });
84
+ // ===========================================================================
85
+ // MILESTONES 2 + 3 — real `node new --headless` → real broker → real hooks.
86
+ // THE GO/NO-GO. One spawned broker drives both milestones (one real boot).
87
+ // ===========================================================================
88
+ test('M2+M3 round-trip: real `node new --headless` boots the broker via the seam, real hooks drive status=done', async () => {
89
+ const id = await h.spawnHeadlessChild(root, 'spike task');
90
+ // ---- MILESTONE 2: the round-trip reached the real broker -----------------
91
+ const boot = await h.awaitBoot(id);
92
+ const env = boot.env;
93
+ assert.equal(env['CRTR_NODE_ID'], id, 'CRTR_NODE_ID is the CHILD id, intact');
94
+ assert.equal(env['CRTR_HOME'], h.home, 'CRTR_HOME isolated value intact');
95
+ assert.ok(env['CRTR_KIND'], 'CRTR_KIND present');
96
+ assert.ok(env['CRTR_MODE'], 'CRTR_MODE present');
97
+ assert.ok(env['CRTR_LIFECYCLE'], 'CRTR_LIFECYCLE present');
98
+ assert.equal(env['CRTR_FRONT_DOOR'], '1', 'CRTR_FRONT_DOOR overlay present (broker host)');
99
+ // argv from buildPiArgv arrived: every canvas -e extension + the kickoff. Assert
100
+ // against the live CANVAS_EXTENSIONS count so this never drifts again.
101
+ assert.equal(boot.extPaths.length, CANVAS_EXTENSIONS.length, `all ${CANVAS_EXTENSIONS.length} canvas -e extension paths in argv`);
102
+ assert.ok(boot.loaded.some((p) => p.includes('canvas-stophook')), 'real stophook module loaded by the broker');
103
+ assert.ok(boot.loaded.some((p) => p.includes('canvas-inbox-watcher')), 'real inbox-watcher module loaded by the broker');
104
+ assert.equal(boot.failedExt.length, 0, `no extension failed to load: ${JSON.stringify(boot.failedExt)}`);
105
+ assert.equal(boot.resuming, false, 'fresh start (no --session)');
106
+ assert.equal(boot.prompt, 'spike task', 'kickoff prompt is the last positional');
107
+ // The REAL stophook session_start handler ran inside the broker and wrote shared
108
+ // canvas state (proves the hook chain, not just the boot).
109
+ const afterBoot = h.node(id);
110
+ assert.equal(afterBoot.pi_session_id, boot.sessionId, 'stophook captured pi_session_id');
111
+ assert.equal(afterBoot.status, 'active', 'child active after boot');
112
+ // ---- MILESTONE 3: a clean finish drives a real transition to done --------
113
+ // push --final flips status=done; the broker's agent_end runs the REAL stophook
114
+ // clean-exit branch (status already done → ctx.shutdown → broker disposes/exits).
115
+ await h.finish(id, 'spike done');
116
+ assert.equal(h.status(id), 'done', 'real stophook agent_end resolved the node to done');
117
+ });
@@ -1,45 +1,45 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/grace-clock.test.ts
2
2
  //
3
- // AXIS: the REVIVE_GRACE_MS double-spawn guard (daemon invariant 10), exercised
4
- // FAITHFULLY with a CONTROLLED CLOCK via the harness's injectable tick(now).
3
+ // HEADLESS RETARGET (foundation-spec §C.10 + §E). Drives a broker-hosted
4
+ // (paneless) node fabricated DIRECTLY in the canvas no real tmux session, no
5
+ // remain-on-exit pane, and NO real broker boot. The grace decision is pure
6
+ // daemon logic, so the test never pays the ~5s SDK-boot cost.
5
7
  //
6
- // Why this exists (MINOR-4): the harness exposes superviseTick's injectable
7
- // `now` through h.tick(now), but every other faithful test calls h.tick() with
8
- // no arg — so the grace window (the guard that a pi observed dead-while-its-pane-
9
- // lives must pend through REVIVE_GRACE_MS before a revive, lest a revive land in
10
- // the transient old-pi-dies→fresh-pi-boots gap and DOUBLE-SPAWN) was never
11
- // exercised end-to-end. daemon-liveness.test.ts pins livenessVerdict purely and
12
- // drives superviseTick with a FABRICATED pi-death (deadPid); this drives a REAL
13
- // fake-pi boot, kills it under a FROZEN (remain-on-exit) pane so the pane stays
14
- // alive while pi is genuinely dead, then walks a deterministic clock across the
15
- // grace boundary.
8
+ // (1) BUG LOCKED the REVIVE_GRACE_MS double-spawn guard (daemon invariant 10,
9
+ // MINOR-4). A node whose engine pid is observed DEAD must pend through the
10
+ // REVIVE_GRACE_MS window before the daemon revives it; revive too early and
11
+ // it lands in the transient old-pi-dies→fresh-pi-boots gap and DOUBLE-SPAWNS
12
+ // a second vehicle on the same .jsonl.
16
13
  //
17
- // This file is ADDITIVE and uses ONLY the public Harness API + h.tick(now) +
18
- // test-local tmux/file reads (the same shape as live-mutation.test.ts's
19
- // firstPaneOf/demote helpers). It does NOT edit harness.ts / fake-pi-host.ts or
20
- // any production file, and adds no harness helper.
14
+ // (2) WHY MODEL-LEVEL, NOT TMUX CHROME the guard lives entirely in pure daemon
15
+ // logic: livenessVerdict(piPidAlive,deadFor) REVIVE_GRACE_MS the
16
+ // unhealthySince clock → revivedThisTick (crtrd.ts). For a broker the dead-pid
17
+ // path routes handleBrokerLiveness handleLiveWindow VERBATIM (intent is
18
+ // neither refresh nor idle-release), exercising the SAME grace machinery as a
19
+ // tmux node. A broker is supervised purely by row.pi_pid, so a fabricated row
20
+ // carrying a known-DEAD pid + a recorded pi_session_id (so it routes the
21
+ // crash/grace path, NOT the never-booted boot-failure branch) reproduces the
22
+ // exact "dead but supervised" state with no process at all.
23
+ //
24
+ // (3) HOW THE FABRICATED DRIVE STILL FAILS IF THE BUG REGRESSES — we walk
25
+ // h.tick(now) across the grace boundary with a deterministic clock and assert
26
+ // getNode(B).cycles (reviveNode bumps cycles + runs transition('revive')
27
+ // BEFORE it spawns — revive.ts:114,145 — so a revive's model effect is
28
+ // observable INSTANTLY, no awaitBoot needed). If the pending branch is removed
29
+ // (revive fires on first-observed-dead), the WITHIN-GRACE tick revives →
30
+ // cycles bumps early → the "cycles unchanged" asserts go RED. Verified by
31
+ // reverting livenessVerdict's `deadFor < REVIVE_GRACE_MS` pending branch
32
+ // (see bug-injection report).
21
33
  import { test } from 'node:test';
22
34
  import assert from 'node:assert/strict';
23
35
  import { spawnSync } from 'node:child_process';
24
- import { readFileSync } from 'node:fs';
25
- import { join } from 'node:path';
26
- import { createHarness, hasTmux } from './helpers/harness.js';
36
+ import { createHarness } from './helpers/harness.js';
27
37
  import { isPidAlive } from '../../daemon/crtrd.js';
28
- const SKIP = !hasTmux() ? 'tmux unavailable' : false;
29
- function sessionExists(session) {
30
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
31
- }
32
- /** Count the fake-pi boots recorded for a node (one line per boot in
33
- * fake-pi.boots.jsonl) — the observable for "did a revive double-spawn?". */
34
- function bootCount(home, id) {
35
- try {
36
- return readFileSync(join(home, 'nodes', id, 'fake-pi.boots.jsonl'), 'utf8')
37
- .split('\n')
38
- .filter((l) => l.trim() !== '').length;
39
- }
40
- catch {
41
- return 0;
42
- }
38
+ // A pid that is reaped (dead) by the time spawnSync returns — the "dead but
39
+ // supervised" pid the grace path judges.
40
+ function deadPid() {
41
+ const r = spawnSync('true', [], { stdio: 'ignore' });
42
+ return r.pid ?? 0x7ffffffe; // fall back to an implausibly-high pid
43
43
  }
44
44
  // REVIVE_GRACE_MS is 20_000 (crtrd.ts). We choose offsets well inside / well
45
45
  // past it so the test is robust to the exact value without importing it.
@@ -47,69 +47,66 @@ const NOW = 5_000_000;
47
47
  const WITHIN_GRACE = NOW + 10_000; // < 20s after first-observed-dead → still pending
48
48
  const PAST_GRACE = NOW + 25_000; // > 20s after first-observed-dead → revive
49
49
  // ===========================================================================
50
- // The grace window guards against a double-spawn: while a node's pane is alive
51
- // but its pi has been observed dead for LESS than REVIVE_GRACE_MS, the daemon
52
- // must NOT revive (a revive there would race the in-flight respawn and spawn a
53
- // second vehicle on the same pane). Once the pi has been dead PAST the grace, a
54
- // revive proceeds.
50
+ // The grace window guards against a double-spawn: while a node's engine pid has
51
+ // been observed dead for LESS than REVIVE_GRACE_MS, the daemon must NOT revive
52
+ // (a revive there would race the in-flight respawn and spawn a second vehicle on
53
+ // the same conversation). Once the pid has been dead PAST the grace, a revive
54
+ // proceeds and cycles advances.
55
55
  // ===========================================================================
56
- test('grace clock: a dead-pi/alive-pane node does NOT revive within REVIVE_GRACE_MS, but DOES once it elapses', { skip: SKIP, timeout: 120_000 }, async () => {
57
- const h = await createHarness({ sessionPrefix: 'crtr-grace' });
56
+ test('grace clock: a dead-pi broker does NOT revive within REVIVE_GRACE_MS, but DOES once it elapses', { timeout: 30_000 }, async () => {
57
+ const h = await createHarness({ headless: true, sessionPrefix: 'crtr-grace' });
58
58
  try {
59
- const A = h.spawnRoot('resident root');
60
- const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
61
- // The boot proof carries the live fake-pi pid; the row records it via the
62
- // real session_start hook. Both are the SAME process that pid is what
63
- // handleLiveWindow judges liveness on.
64
- const boot = await h.awaitBoot(B);
59
+ const dead = deadPid();
60
+ assert.equal(isPidAlive(dead), false, 'fabrication precondition: the supervised pid is dead');
61
+ // Fabricate the EXACT "dead but supervised" broker state directly no
62
+ // boot. pi_session_id is SET so handleBrokerLiveness routes the crash/grace
63
+ // path (handleLiveWindow), NOT the never-booted boot-failure branch; intent
64
+ // is null so it is neither the refresh nor the idle-release early-return.
65
+ const B = h.fabricateBrokerNode({
66
+ kind: 'developer',
67
+ status: 'active',
68
+ intent: null,
69
+ pi_pid: dead,
70
+ pi_session_id: 'sess-grace-B',
71
+ });
65
72
  const b0 = h.node(B);
66
- assert.equal(b0.status, 'active', 'B active after boot');
67
- assert.equal(b0.intent ?? null, null, 'B intent=null (NOT idle-release — the grace path, not the frozen early-return)');
68
- assert.equal(b0.pi_pid, boot.pid, 'row pi_pid == the live fake-pi pid (recorded at session_start)');
69
- assert.equal(bootCount(h.home, B), 1, 'exactly one boot so far');
70
- // Arm remain-on-exit on B's window so that when we kill its pi the PANE
71
- // survives (frozen) rather than closing that is the only way to produce
72
- // the "pane alive but pi dead" state handleLiveWindow's grace path judges.
73
- const ro = spawnSync('tmux', ['set-window-option', '-t', b0.window, 'remain-on-exit', 'on'], { stdio: 'ignore' });
74
- assert.equal(ro.status, 0, 'armed remain-on-exit on B\'s window');
75
- // Kill the fake-pi. pi dies; the frozen pane stays alive.
76
- process.kill(boot.pid, 'SIGKILL');
77
- await h.waitFor(() => !isPidAlive(boot.pid), { timeoutMs: 10_000, label: 'fake-pi pid dead' });
78
- assert.equal(h.paneAlive(B), true, 'pane is FROZEN alive after pi death (remain-on-exit)');
79
- // --- TICK 1 @ NOW: first observation of the dead pi → 'pending'. The
73
+ assert.equal(b0.host_kind, 'broker', 'B is broker-hosted (paneless)');
74
+ assert.equal(b0.status, 'active', 'B active');
75
+ assert.equal(b0.intent ?? null, null, 'B intent=null (the grace path, not the idle-release early-return)');
76
+ assert.equal(b0.pi_pid, dead, 'row pi_pid == the known-dead pid');
77
+ assert.equal(b0.window ?? null, null, 'no tmux window broker host');
78
+ assert.equal(b0.cycles ?? 0, 0, 'no revive yet cycles at 0');
79
+ // --- TICK 1 @ NOW: first observation of the dead pid → 'pending'. The
80
80
  // daemon records first-observed-dead and does NOT revive. ---
81
81
  await h.tick(NOW);
82
82
  {
83
83
  const b = h.node(B);
84
- assert.equal(bootCount(h.home, B), 1, 'first tick: NO revive on first-observed-dead (still 1 boot)');
85
- assert.equal(b.pi_pid, boot.pid, 'pi_pid unchanged — no fresh vehicle spawned');
84
+ assert.equal(b.cycles ?? 0, 0, 'first tick: NO revive on first-observed-dead (cycles still 0)');
85
+ assert.equal(b.pi_pid, dead, 'pi_pid unchanged — no fresh vehicle launched');
86
86
  assert.equal(b.status, 'active', 'B left active (pending, not revived)');
87
- assert.equal(h.paneAlive(B), true, 'frozen pane still alive');
88
87
  }
89
88
  // --- TICK 2 @ NOW+10s (WITHIN the 20s grace): STILL pending. This is the
90
89
  // double-spawn guard: a revive here would land in the respawn gap. ---
91
90
  await h.tick(WITHIN_GRACE);
92
91
  {
93
92
  const b = h.node(B);
94
- assert.equal(bootCount(h.home, B), 1, 'within REVIVE_GRACE_MS: NO double-spawn — the dead pi must pend, not revive');
95
- assert.equal(b.pi_pid, boot.pid, 'pi_pid STILL the dead pid — guard held');
93
+ assert.equal(b.cycles ?? 0, 0, 'within REVIVE_GRACE_MS: NO double-spawn — the dead pi must pend, not revive (cycles still 0)');
94
+ assert.equal(b.pi_pid, dead, 'pi_pid STILL the dead pid — guard held');
96
95
  assert.equal(b.status, 'active', 'B still active inside the grace window');
97
96
  }
98
- // --- TICK 3 @ NOW+25s (PAST the 20s grace): now a revive proceeds — a
99
- // FRESH fake-pi boots in the frozen pane (respawn-pane -k resume). ---
97
+ // --- TICK 3 @ NOW+25s (PAST the 20s grace): now a revive proceeds —
98
+ // reviveNode bumps cycles + transition('revive') BEFORE the (detached,
99
+ // unawaited) broker spawn, so the model effect is visible immediately. ---
100
100
  await h.tick(PAST_GRACE);
101
- await h.awaitBoot(B, { minCount: 2, timeoutMs: 30_000 });
102
- assert.ok(bootCount(h.home, B) >= 2, 'past REVIVE_GRACE_MS: the dead pi is revived → a fresh vehicle boots');
103
- await h.waitForStatus(B, 'active');
104
101
  {
105
102
  const b = h.node(B);
106
- assert.equal(b.status, 'active', 'B active after the grace-window revive');
107
- assert.notEqual(b.pi_pid, boot.pid, 'pi_pid advanced to the fresh vehicle — the revive landed');
103
+ assert.equal(b.cycles ?? 0, 1, 'past REVIVE_GRACE_MS: the dead pi is revived → cycles bumped to 1');
104
+ assert.equal(b.status, 'active', 'B active after the grace-window revive (transition(revive))');
105
+ assert.equal(b.intent ?? null, null, 'intent cleared by the revive');
106
+ assert.equal(b.pi_pid ?? null, null, 'pi_pid cleared by the window-backed revive (clearPid)');
108
107
  }
109
108
  }
110
109
  finally {
111
- const session = h.session;
112
110
  await h.dispose();
113
- assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
114
111
  }
115
112
  });
@@ -1,4 +1,4 @@
1
- import type { NodeMeta, NodeStatus, Mode, Lifecycle } from '../../canvas/types.js';
1
+ import type { NodeMeta, NodeStatus, Mode, Lifecycle, ExitIntent } from '../../canvas/types.js';
2
2
  import type { InboxEntry } from '../../feed/inbox.js';
3
3
  /** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
4
4
  export declare function hasTmux(): boolean;
@@ -30,6 +30,18 @@ export interface CliResult {
30
30
  }
31
31
  export interface HarnessOpts {
32
32
  sessionPrefix?: string;
33
+ /** HEADLESS mode: drive ONLY broker-hosted (paneless) nodes. When set the
34
+ * harness does NOT gate on tmux, does NOT eagerly create the isolated tmux
35
+ * session, and skips the per-pane `set-environment` seeding — nothing in a
36
+ * headless run opens a window. CRTR_NODE_SESSION is still set to the isolated
37
+ * name (and dispose still kill-sessions it) because `spawnChild` in spawn.ts
38
+ * calls `ensureSession()` UNCONDITIONALLY to record each node's durable
39
+ * `home_session` revive target — even a broker birth, which then ignores it.
40
+ * So `node new --headless` may LAZILY create that one isolated session as a
41
+ * side effect, but never a window/pane; the broker boots detached, supervised
42
+ * by pid. Headless tests must use `spawnHeadlessChild`, never `spawnChild`
43
+ * (which opens a real window). See createHeadlessHarness(). */
44
+ headless?: boolean;
33
45
  }
34
46
  export interface SpawnOpts {
35
47
  kind?: string;
@@ -37,10 +49,30 @@ export interface SpawnOpts {
37
49
  lifecycle?: Lifecycle;
38
50
  id?: string;
39
51
  }
52
+ /** Fabricate a broker-hosted node row directly — tmux-free, boot-free, NO
53
+ * subprocess. Builds a full NodeMeta (identity ∪ runtime) and calls createNode,
54
+ * which seeds BOTH halves in one statement (seedRow writes status/intent/pi_pid
55
+ * from the meta; writeMeta persists pi_session_id). The result is a row the
56
+ * daemon supervises by pid exactly as if a real broker had booted — so a test
57
+ * can drive superviseTick(now) liveness decisions against arbitrary liveness
58
+ * state (alive/dead pid, with/without session, any intent) with ZERO real boot.
59
+ * See foundation-spec §E.2. */
60
+ export interface FabricateOpts {
61
+ parent?: string | null;
62
+ kind?: string;
63
+ mode?: Mode;
64
+ lifecycle?: Lifecycle;
65
+ status?: NodeStatus;
66
+ intent?: ExitIntent;
67
+ pi_pid?: number | null;
68
+ pi_session_id?: string | null;
69
+ id?: string;
70
+ }
40
71
  export interface Harness {
41
72
  home: string;
42
73
  session: string;
43
74
  spawnRoot(task: string, o?: SpawnOpts): string;
75
+ fabricateBrokerNode(o?: FabricateOpts): string;
44
76
  spawnChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
45
77
  cli(nodeId: string | null, args: string[]): CliResult;
46
78
  spawnHeadlessChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
@@ -84,4 +116,6 @@ export interface Harness {
84
116
  }[];
85
117
  dispose(): Promise<void>;
86
118
  }
119
+ /** Thin wrapper: a headless-only harness (broker-hosted, paneless nodes). */
120
+ export declare function createHeadlessHarness(opts?: HarnessOpts): Promise<Harness>;
87
121
  export declare function createHarness(opts?: HarnessOpts): Promise<Harness>;
@@ -103,8 +103,13 @@ async function waitFor(probe, opts = {}) {
103
103
  await new Promise((r) => setTimeout(r, intervalMs));
104
104
  }
105
105
  }
106
+ /** Thin wrapper: a headless-only harness (broker-hosted, paneless nodes). */
107
+ export async function createHeadlessHarness(opts = {}) {
108
+ return createHarness({ ...opts, headless: true });
109
+ }
106
110
  export async function createHarness(opts = {}) {
107
- if (!hasTmux())
111
+ const headless = opts.headless === true;
112
+ if (!headless && !hasTmux())
108
113
  throw new Error('createHarness: tmux not available');
109
114
  const origHome = process.env['CRTR_HOME'];
110
115
  const origPiBinary = process.env['CRTR_PI_BINARY'];
@@ -162,17 +167,23 @@ export async function createHarness(opts = {}) {
162
167
  // a custom-socket server (`tmux -L foo`) would be invisible to it; this harness
163
168
  // therefore assumes the default socket and only ever kill-sessions, never the
164
169
  // server.
165
- spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
166
- stdio: 'ignore',
167
- });
168
- // Put CRTR_PI_BINARY in the SESSION environment so EVERY pane spawned in this
169
- // session inherits it — critically the fake-pi's OWN process, so when its real
170
- // stophook fires reviveInPlace (respawn-pane -k on its own pane, the refresh-
171
- // yield path) the in-process piCommand there substitutes the fake-pi too.
172
- spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
173
- stdio: 'ignore',
174
- });
175
- spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
170
+ // HEADLESS: skip ALL eager tmux setup. No node in a headless run opens a
171
+ // window, so there is no pane to pre-seed an env into and no session to pre-
172
+ // create. (`node new --headless` may still lazily ensureSession() the isolated
173
+ // name for home_session dispose kill-sessions it regardless.)
174
+ if (!headless) {
175
+ spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
176
+ stdio: 'ignore',
177
+ });
178
+ // Put CRTR_PI_BINARY in the SESSION environment so EVERY pane spawned in this
179
+ // session inherits it — critically the fake-pi's OWN process, so when its real
180
+ // stophook fires reviveInPlace (respawn-pane -k on its own pane, the refresh-
181
+ // yield path) the in-process piCommand there substitutes the fake-pi too.
182
+ spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
183
+ stdio: 'ignore',
184
+ });
185
+ spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
186
+ }
176
187
  const pidsToKill = new Set();
177
188
  let nextRootSeq = 0;
178
189
  // -- env for a subprocess CLI invocation -----------------------------------
@@ -286,6 +297,27 @@ export async function createHarness(opts = {}) {
286
297
  closeDb();
287
298
  return id;
288
299
  },
300
+ fabricateBrokerNode(o = {}) {
301
+ const id = o.id ?? `brk-${process.pid}-${nextRootSeq++}`;
302
+ const meta = {
303
+ node_id: id,
304
+ name: id,
305
+ created: new Date().toISOString(),
306
+ cwd: CROUTER,
307
+ host_kind: 'broker',
308
+ kind: o.kind ?? 'developer',
309
+ mode: o.mode ?? 'base',
310
+ lifecycle: o.lifecycle ?? 'terminal',
311
+ parent: o.parent ?? null,
312
+ pi_session_id: o.pi_session_id ?? null,
313
+ status: o.status ?? 'active',
314
+ intent: o.intent ?? null,
315
+ pi_pid: o.pi_pid ?? null,
316
+ };
317
+ createNode(meta);
318
+ closeDb();
319
+ return id;
320
+ },
289
321
  async spawnChild(parentId, task, o = {}) {
290
322
  const before = new Set(nodeDirs());
291
323
  const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER];
@@ -479,6 +511,32 @@ export async function createHarness(opts = {}) {
479
511
  },
480
512
  async dispose() {
481
513
  spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
514
+ // Best-effort: a superviseTick-triggered revive of a fabricated node fires
515
+ // a DETACHED throwaway broker (it loads the real SDK; the rmSync of home
516
+ // below makes a mid-boot one self-terminate when its files vanish). Kill
517
+ // any that recorded a pid first — scan every node's row pid + recorded
518
+ // boot pids and add them to the kill set.
519
+ closeDb();
520
+ for (const d of nodeDirs()) {
521
+ try {
522
+ const n = getNode(d);
523
+ if (n?.pi_pid != null)
524
+ pidsToKill.add(n.pi_pid);
525
+ }
526
+ catch {
527
+ /* row gone — fine */
528
+ }
529
+ for (const line of readLines(join(nodeDir(d), 'fake-pi.boots.jsonl'))) {
530
+ try {
531
+ const p = JSON.parse(line).pid;
532
+ if (typeof p === 'number')
533
+ pidsToKill.add(p);
534
+ }
535
+ catch {
536
+ /* skip */
537
+ }
538
+ }
539
+ }
482
540
  for (const p of pidsToKill) {
483
541
  try {
484
542
  process.kill(p, 'SIGKILL');