@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
@@ -4,9 +4,10 @@ import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
- import { createNode, getNode } from '../canvas/canvas.js';
7
+ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
8
8
  import { closeDb } from '../canvas/db.js';
9
9
  import { appendInbox } from '../feed/inbox.js';
10
+ import { markBusy, clearBusy } from '../runtime/busy.js';
10
11
  import { superviseTick, isPidAlive, livenessVerdict, } from '../../daemon/crtrd.js';
11
12
  let home;
12
13
  function node(id, over = {}) {
@@ -149,8 +150,9 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
149
150
  spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
150
151
  // The window is alive, but the node is anchored on that dead pane. Under the
151
152
  // old window-keyed liveness this node would read healthy (window alive + live
152
- // pid). Pane-keyed: the pane is gone → the crash branch fires. pi_session_id
153
- // is set so it's a clean crash, not a boot-failure push.
153
+ // pid). Pane-keyed: the pane is gone → the gone-branch fires. The node is
154
+ // MID-GENERATION (busy marker present agent_start touched it, agent_end
155
+ // never cleared it), so the gone-branch routes to crash → 'dead'.
154
156
  createNode(node('G', {
155
157
  pane: dead,
156
158
  tmux_session: session,
@@ -159,8 +161,61 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
159
161
  pi_session_id: 'booted',
160
162
  intent: null,
161
163
  }));
164
+ markBusy('G'); // pane killed inside a turn → genuine mid-run crash
162
165
  await superviseTick();
163
- assert.equal(getNode('G').status, 'dead', 'a gone pane fires the gone-branch even with a live window + pid');
166
+ assert.equal(getNode('G').status, 'dead', 'a gone pane mid-generation fires the gone-branch crash (dead)');
167
+ clearBusy('G');
168
+ });
169
+ });
170
+ // ---------------------------------------------------------------------------
171
+ // Gone-pane routing: a pane-gone node in the crash branch is no longer an
172
+ // unconditional 'dead'. It routes on what the node was DOING at pane-kill time:
173
+ // • mid-generation (busy marker PRESENT) → crash ('dead')
174
+ // • finished its turn, awaiting nothing live → finalize ('done')
175
+ // • finished its turn, still awaiting a LIVE child → crash ('dead'), for now
176
+ // (The mid-generation → dead leg is covered by node 'G' above.)
177
+ // ---------------------------------------------------------------------------
178
+ test('pane gone + booted + busy ABSENT + no live subscription → finalize (done): the pane was closed to dismiss a finished node', { skip: !hasTmux() }, async () => {
179
+ await withLivePane('fin1', async (session, window) => {
180
+ const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
181
+ const dead = (sp.stdout ?? '').trim();
182
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
183
+ // Booted (pi_session_id set), no busy marker (agent_end cleared it → the turn
184
+ // finished), and no subscription to any live node. Closing the pane was a
185
+ // dismissal of a node that already did its own work → finalize to 'done'.
186
+ createNode(node('FIN', {
187
+ pane: dead,
188
+ tmux_session: session,
189
+ window,
190
+ pi_pid: process.pid,
191
+ pi_session_id: 'booted',
192
+ intent: null,
193
+ }));
194
+ // no markBusy: the turn ended cleanly.
195
+ await superviseTick();
196
+ assert.equal(getNode('FIN').status, 'done', 'a finished, unsubscribed node whose pane was closed finalizes to done');
197
+ });
198
+ });
199
+ test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → crash (dead), for now', { skip: !hasTmux() }, async () => {
200
+ await withLivePane('fin2', async (session, window) => {
201
+ const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
202
+ const dead = (sp.stdout ?? '').trim();
203
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
204
+ // A live child the parent is subscribed to: hasActiveLiveSubscription === true.
205
+ createNode(node('CHILD', { status: 'active', pi_session_id: 'booted' }));
206
+ createNode(node('PARENT', {
207
+ pane: dead,
208
+ tmux_session: session,
209
+ window,
210
+ pi_pid: process.pid,
211
+ pi_session_id: 'booted',
212
+ intent: null,
213
+ }));
214
+ subscribe('PARENT', 'CHILD', true); // active subscription to a LIVE node
215
+ // Parent finished its turn (no busy marker) but is still awaiting a live
216
+ // child → NOT a finalize. Routes to crash ('dead') for now.
217
+ await superviseTick();
218
+ assert.equal(getNode('PARENT').status, 'dead', 'a finished node still awaiting a live child crashes (dead), not finalizes');
164
219
  });
165
220
  });
166
221
  // ---------------------------------------------------------------------------
@@ -0,0 +1,151 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/dead-pane-regression.test.ts
2
+ //
3
+ // PLACEMENT-FAMILY DEAD-PANE REGRESSION GUARD.
4
+ //
5
+ // The bug (fixed this session, committed in HEAD `src/core/spawn.ts`): the
6
+ // `human` command tree opens its humanloop TUI in a tmux pane via
7
+ // `spawnAndDetach` (a `split-window` into the CURRENT window). The canvas
8
+ // runtime arms `remain-on-exit on` at the WINDOW scope on a node's vehicle /
9
+ // focus window (the F3 freeze, `runtime/tmux.ts` setRemainOnExit). A
10
+ // `split-window` pane opened into such a window INHERITS the `on`, so when the
11
+ // TUI worker (`crtr human _run`) exits 0 (clean), tmux keeps the pane around as
12
+ // a dead "[Exited]" pane instead of destroying it — a lingering dead pane.
13
+ //
14
+ // The fix: after the split, `spawnAndDetach` forces `remain-on-exit off` at PANE
15
+ // scope on the new pane (`set-option -p -t <pane> remain-on-exit off`). That
16
+ // destroys the pane on a clean exit WITHOUT touching the window's value (so a
17
+ // focus freeze still works) or the user's global config.
18
+ //
19
+ // This file drives the REAL production `spawnAndDetach` against a REAL but
20
+ // isolated tmux session — no mocks. It is ADDITIVE: it adds no harness/fixture
21
+ // capability and edits no production file. The canvas harness
22
+ // (`helpers/harness.ts`) cannot reach this path — it drives canvas nodes via
23
+ // `openNodeWindow` (new-window backstage), never the `spawnAndDetach`
24
+ // split-window/human-pane path — so the tmux drivers below are LOCAL to this
25
+ // file (candidates for harness consolidation: a `splitDetachPane` + pane-scoped
26
+ // `paneAlive`/`paneIsDead`/`waitForPaneGone` keyed on a raw %pane id).
27
+ import { test } from 'node:test';
28
+ import assert from 'node:assert/strict';
29
+ import { spawnSync } from 'node:child_process';
30
+ import { hasTmux } from './helpers/harness.js';
31
+ import { spawnAndDetach } from '../spawn.js';
32
+ const SKIP = !hasTmux();
33
+ // --- LOCAL tmux drivers (keyed on a raw %pane id, not a canvas node) --------
34
+ function tmux(args) {
35
+ const r = spawnSync('tmux', args, { encoding: 'utf8' });
36
+ return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
37
+ }
38
+ /** Every pane in the session (across windows) with its dead flag. */
39
+ function sessionPanes(session) {
40
+ const r = tmux(['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{pane_dead}']);
41
+ if (r.code !== 0)
42
+ return [];
43
+ return r.out
44
+ .split('\n')
45
+ .filter((l) => l.trim() !== '')
46
+ .map((l) => {
47
+ const [id, dead] = l.split(' ');
48
+ return { id: id, dead: dead === '1' };
49
+ });
50
+ }
51
+ function paneAlive(session, pane) {
52
+ return sessionPanes(session).some((p) => p.id === pane && !p.dead);
53
+ }
54
+ function paneExists(session, pane) {
55
+ return sessionPanes(session).some((p) => p.id === pane);
56
+ }
57
+ function paneIsDead(session, pane) {
58
+ return sessionPanes(session).find((p) => p.id === pane)?.dead ?? false;
59
+ }
60
+ async function waitUntil(probe, label, timeoutMs = 15_000, intervalMs = 100) {
61
+ const deadline = Date.now() + timeoutMs;
62
+ for (;;) {
63
+ if (probe())
64
+ return;
65
+ if (Date.now() > deadline)
66
+ throw new Error(`waitUntil timed out: ${label}`);
67
+ await new Promise((r) => setTimeout(r, intervalMs));
68
+ }
69
+ }
70
+ test('dead-pane regression: spawnAndDetach into a remain-on-exit-ON window is DESTROYED on clean exit', { skip: SKIP ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
71
+ const session = `crtr-deadpane-${process.pid}-${Date.now().toString(36)}`;
72
+ const origTmux = process.env['TMUX'];
73
+ let tmuxOverridden = false;
74
+ try {
75
+ // --- isolated session on whatever server the current env targets -------
76
+ const created = tmux(['new-session', '-d', '-s', session, '-c', process.cwd(), 'sleep 100000']);
77
+ assert.equal(created.code, 0, `create isolated session failed: ${created.err}`);
78
+ const info = tmux([
79
+ 'display-message', '-p', '-t', `${session}:`,
80
+ '#{pane_id}\t#{window_id}\t#{socket_path}',
81
+ ]);
82
+ assert.equal(info.code, 0, `display-message failed: ${info.err}`);
83
+ const [basePane, baseWindow, socketPath] = info.out.split('\t');
84
+ assert.ok(basePane && baseWindow && socketPath, `parsed session info: ${info.out}`);
85
+ // --- arm the BUG PRECONDITION: window default remain-on-exit ON --------
86
+ // (mirrors the canvas runtime's F3 freeze on a node's vehicle/focus window).
87
+ const armed = tmux(['set-window-option', '-t', baseWindow, 'remain-on-exit', 'on']);
88
+ assert.equal(armed.code, 0, `arm window remain-on-exit on failed: ${armed.err}`);
89
+ assert.equal(tmux(['show-window-options', '-t', baseWindow, 'remain-on-exit']).out, 'remain-on-exit on', 'precondition: the window into which we split has remain-on-exit ON');
90
+ // spawnAndDetach gates on isInTmux() (Boolean(process.env.TMUX)) and shells
91
+ // `tmux` with no -L, so point TMUX at THIS session's socket — both our
92
+ // helpers and spawnAndDetach then drive the same server holding `session`.
93
+ process.env['TMUX'] = `${socketPath},0,0`;
94
+ tmuxOverridden = true;
95
+ // ====================================================================
96
+ // MAIN — the REAL production spawnAndDetach split into the ON window.
97
+ // ====================================================================
98
+ const res = spawnAndDetach({
99
+ command: 'sleep 1', // a clean-exiting (status 0) worker — stands in for `crtr human _run`
100
+ cwd: process.cwd(),
101
+ placement: 'split-h',
102
+ killAfterSeconds: 0, // no self-kill of the originating pane
103
+ targetPane: basePane, // pin the split to our isolated window (never the user's pane)
104
+ });
105
+ assert.equal(res.status, 'spawned', `spawnAndDetach should spawn: ${res.message}`);
106
+ const pane = res.paneId;
107
+ assert.ok(pane && pane.startsWith('%'), `spawnAndDetach returned a %pane id: ${pane}`);
108
+ // (a) the pane landed alive in the remain-on-exit-ON window.
109
+ assert.equal(paneAlive(session, pane), true, 'spawned pane is alive right after the split');
110
+ // (b) THE FIX: the spawned pane carries a PANE-SCOPED remain-on-exit OFF
111
+ // override — the exact line under regression
112
+ // (`set-option -p -t <pane> remain-on-exit off`). If a refactor drops
113
+ // it, this fails loudly.
114
+ assert.equal(tmux(['show-options', '-p', '-t', pane, 'remain-on-exit']).out, 'remain-on-exit off', 'FIX present: pane-scoped remain-on-exit OFF on the spawnAndDetach pane');
115
+ // (c) the override is pane-scoped only — the WINDOW value is untouched, so
116
+ // a real focus-freeze on this window still works.
117
+ assert.equal(tmux(['show-window-options', '-t', baseWindow, 'remain-on-exit']).out, 'remain-on-exit on', 'window remain-on-exit untouched (override is pane-scoped, focus freeze intact)');
118
+ // (d) THE GUARANTEE: on the worker's CLEAN exit the pane is DESTROYED,
119
+ // never lingering as a dead [Exited] pane.
120
+ await waitUntil(() => !paneExists(session, pane), 'spawnAndDetach pane destroyed on clean exit');
121
+ assert.equal(paneExists(session, pane), false, 'spawnAndDetach pane DESTROYED on clean exit (not lingering)');
122
+ // ====================================================================
123
+ // CONTROL — prove the guard is NON-VACUOUS: the SAME split WITHOUT the
124
+ // pane-scoped override (i.e. pre-fix behavior) lingers as a dead pane.
125
+ // ====================================================================
126
+ const ctl = tmux([
127
+ 'split-window', '-h', '-d', '-P', '-F', '#{pane_id}',
128
+ '-t', basePane, '-c', process.cwd(), 'sleep 1',
129
+ ]);
130
+ assert.equal(ctl.code, 0, `control split failed: ${ctl.err}`);
131
+ const ctlPane = ctl.out;
132
+ assert.ok(ctlPane.startsWith('%'), `control pane id: ${ctlPane}`);
133
+ // No pane-scoped override → inherits the window's remain-on-exit ON →
134
+ // after its clean exit the pane is kept as DEAD rather than destroyed.
135
+ await waitUntil(() => paneIsDead(session, ctlPane), 'control pane reaches dead state after clean exit');
136
+ assert.equal(paneExists(session, ctlPane), true, 'CONTROL: un-fixed pane LINGERS (still listed) after clean exit');
137
+ assert.equal(paneIsDead(session, ctlPane), true, 'CONTROL: the lingering pane is DEAD — confirms remain-on-exit ON causes the exact bug the fix prevents');
138
+ }
139
+ finally {
140
+ // Tear down on the SAME server the session lives on (TMUX still points there),
141
+ // then restore the original TMUX env.
142
+ tmux(['kill-session', '-t', session]);
143
+ if (tmuxOverridden) {
144
+ if (origTmux === undefined)
145
+ delete process.env['TMUX'];
146
+ else
147
+ process.env['TMUX'] = origTmux;
148
+ }
149
+ assert.equal(spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0, false, 'isolated session killed — no stray');
150
+ }
151
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env node
2
+ // fake-pi-host.ts — a deterministic stand-in for the pi LLM vehicle.
3
+ //
4
+ // This is NOT an LLM and NOT a mock of the extensions. It is the real pi
5
+ // vehicle's place in the system, occupied by a tiny event driver that loads the
6
+ // REAL canvas extensions (the `-e <path>` modules the runtime put in our argv)
7
+ // and fires REAL lifecycle events under harness control, so the real hooks
8
+ // drive real canvas state across the process boundary.
9
+ //
10
+ // The runtime exec's us exactly where it would exec `pi`, via the CRTR_PI_BINARY
11
+ // seam in piCommand (src/core/runtime/tmux.ts). We therefore receive:
12
+ // • argv: -e <ext> … -n <label> [--session <path>] [--model …] [--tools …]
13
+ // [--append-system-prompt <file>] [<kickoff prompt>] (buildPiArgv)
14
+ // • env: CRTR_NODE_ID, CRTR_KIND, CRTR_MODE, CRTR_LIFECYCLE, CRTR_HOME,
15
+ // CRTR_PARENT_NODE_ID, CRTR_ROOT_SESSION, CRTR_FRONT_DOOR (tmux -e)
16
+ //
17
+ // Run under tsx (so the `-e` paths resolve to the same .ts modules the CLI
18
+ // referenced when launched via `node --import tsx/esm src/cli.ts`).
19
+ //
20
+ // Control channel (one-way, polled file under the node dir):
21
+ // <CRTR_HOME>/nodes/<id>/fake-pi.cmd — harness writes one JSON command
22
+ // <CRTR_HOME>/nodes/<id>/fake-pi.ack — host appends an ack per command
23
+ // Proof / observability the harness reads:
24
+ // <CRTR_HOME>/nodes/<id>/fake-pi.boot.json — argv + env + loaded exts (latest boot)
25
+ // <CRTR_HOME>/nodes/<id>/fake-pi.boots.jsonl — append-only, one line per boot
26
+ // <CRTR_HOME>/nodes/<id>/fake-pi.events.jsonl — append-only, one line per fired event
27
+ // <CRTR_HOME>/nodes/<id>/fake-pi.injected.jsonl — every sendUserMessage
28
+ // <CRTR_HOME>/nodes/<id>/fake-pi.error — any boot/import failure
29
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { pathToFileURL } from 'node:url';
32
+ const env = process.env;
33
+ const nodeId = (env['CRTR_NODE_ID'] ?? '').trim();
34
+ const home = (env['CRTR_HOME'] ?? '').trim();
35
+ const rawArgv = process.argv.slice(2); // everything after `node host.ts`
36
+ function recordError(msg) {
37
+ try {
38
+ if (home && nodeId) {
39
+ const dir = join(home, 'nodes', nodeId);
40
+ mkdirSync(dir, { recursive: true });
41
+ appendFileSync(join(dir, 'fake-pi.error'), msg + '\n');
42
+ }
43
+ }
44
+ catch {
45
+ /* best effort */
46
+ }
47
+ try {
48
+ process.stderr.write('[fake-pi] ' + msg + '\n');
49
+ }
50
+ catch {
51
+ /* ignore */
52
+ }
53
+ }
54
+ function fail(msg) {
55
+ recordError(msg);
56
+ process.exit(17);
57
+ }
58
+ if (nodeId === '')
59
+ fail('CRTR_NODE_ID missing in env');
60
+ if (home === '')
61
+ fail('CRTR_HOME missing in env');
62
+ const nodeDir = join(home, 'nodes', nodeId);
63
+ mkdirSync(nodeDir, { recursive: true });
64
+ // --- parse the argv the runtime built (buildPiArgv → piCommand) ------------
65
+ const extPaths = [];
66
+ let sessionArg;
67
+ let label;
68
+ let prompt;
69
+ for (let i = 0; i < rawArgv.length; i++) {
70
+ const a = rawArgv[i];
71
+ if (a === '-e')
72
+ extPaths.push(rawArgv[++i]);
73
+ else if (a === '-n')
74
+ label = rawArgv[++i];
75
+ else if (a === '--session')
76
+ sessionArg = rawArgv[++i];
77
+ else if (a === '--model' || a === '--tools' || a === '--append-system-prompt' || a === '--fork')
78
+ i++;
79
+ else if (!a.startsWith('-'))
80
+ prompt = a; // positional kickoff (fresh start only)
81
+ }
82
+ const resuming = sessionArg !== undefined;
83
+ const sessionId = sessionArg ?? `fake-sess-${nodeId}-${Date.now()}`;
84
+ const sessionFile = join(nodeDir, 'fake-session.jsonl');
85
+ if (!existsSync(sessionFile))
86
+ writeFileSync(sessionFile, '');
87
+ const handlers = {};
88
+ let shutdownRequested = false;
89
+ let streaming = false;
90
+ const injected = [];
91
+ function recordInjected(rec) {
92
+ injected.push(rec);
93
+ try {
94
+ appendFileSync(join(nodeDir, 'fake-pi.injected.jsonl'), JSON.stringify({ ...rec, ts: Date.now() }) + '\n');
95
+ }
96
+ catch {
97
+ /* best effort */
98
+ }
99
+ }
100
+ // The faithful pi vehicle surface — the union of every method the 7 canvas
101
+ // extensions call at register time. `on` + `sendUserMessage` carry the
102
+ // lifecycle behavior under test; the rest are recording stubs so the chrome
103
+ // extensions (context-intro renderer, commands/nav slash-commands, goal-capture
104
+ // session name) register without throwing, exactly as they would against real pi.
105
+ const pi = {
106
+ on(event, h) {
107
+ (handlers[event] ??= []).push(h);
108
+ },
109
+ sendUserMessage(content, options) {
110
+ recordInjected({ content, deliverAs: options?.deliverAs });
111
+ },
112
+ sendMessage(message, options) {
113
+ recordInjected({ content: JSON.stringify(message), deliverAs: options?.deliverAs });
114
+ },
115
+ registerMessageRenderer(_customType, _renderer) {
116
+ /* spike: no-op recording stub */
117
+ },
118
+ registerCommand(_name, _options) {
119
+ /* spike: no-op recording stub */
120
+ },
121
+ registerShortcut(_shortcut, _options) {
122
+ /* spike: no-op recording stub */
123
+ },
124
+ setSessionName(_name) {
125
+ /* spike: no-op recording stub */
126
+ },
127
+ };
128
+ let eventSeq = 0;
129
+ // Append a durable record of every fired event BEFORE its handlers run. This is
130
+ // the harness's robust "the host received my command and is dispatching it"
131
+ // signal — it survives even when a handler tears the process down mid-flight
132
+ // (e.g. agent_end→reviveInPlace does respawn-pane -k on our own pane), which is
133
+ // exactly the case where an after-the-fact ack would be lost.
134
+ function recordEvent(event, ev) {
135
+ try {
136
+ const reason = ev?.reason;
137
+ appendFileSync(join(nodeDir, 'fake-pi.events.jsonl'), JSON.stringify({ seq: ++eventSeq, event, reason: reason ?? null, ts: Date.now() }) + '\n');
138
+ }
139
+ catch {
140
+ /* best effort */
141
+ }
142
+ }
143
+ async function fire(event, ev, ctx) {
144
+ recordEvent(event, ev);
145
+ for (const h of handlers[event] ?? []) {
146
+ try {
147
+ await h(ev, ctx);
148
+ }
149
+ catch (e) {
150
+ recordError(`handler ${event} threw: ${String(e)}`);
151
+ }
152
+ }
153
+ }
154
+ // The minimum fake-ctx shape (union across both focus extensions), all
155
+ // dereferenced defensively by the hooks (ctx?.x?.()).
156
+ const ctx = {
157
+ sessionManager: {
158
+ getSessionId: () => sessionId,
159
+ getSessionFile: () => sessionFile,
160
+ },
161
+ getContextUsage: () => ({ tokens: 1000 }),
162
+ shutdown: () => {
163
+ shutdownRequested = true;
164
+ },
165
+ isIdle: () => !streaming,
166
+ abort: () => {
167
+ /* spike: no-op */
168
+ },
169
+ };
170
+ // --- load the REAL extension modules from the -e paths ----------------------
171
+ const loaded = [];
172
+ const failedExt = [];
173
+ for (const p of extPaths) {
174
+ try {
175
+ const mod = await import(pathToFileURL(p).href);
176
+ const reg = (mod['default'] ?? mod['register']);
177
+ if (typeof reg === 'function') {
178
+ reg(pi);
179
+ loaded.push(p);
180
+ }
181
+ else {
182
+ failedExt.push(`${p} (no default export fn)`);
183
+ }
184
+ }
185
+ catch (e) {
186
+ failedExt.push(`${p} :: ${String(e)}`);
187
+ recordError(`import ${p} failed: ${String(e)}`);
188
+ }
189
+ }
190
+ // --- session_start: the real boot-confirm hook captures session id + pid -----
191
+ await fire('session_start', { reason: resuming ? 'resume' : 'startup' }, ctx);
192
+ // --- the boot proof the harness asserts on ----------------------------------
193
+ const boot = {
194
+ pid: process.pid,
195
+ nodeId,
196
+ home,
197
+ rawArgv,
198
+ extPaths,
199
+ loaded,
200
+ failedExt,
201
+ sessionId,
202
+ sessionFile,
203
+ resuming,
204
+ label: label ?? null,
205
+ prompt: prompt ?? null,
206
+ env: {
207
+ CRTR_NODE_ID: env['CRTR_NODE_ID'] ?? null,
208
+ CRTR_KIND: env['CRTR_KIND'] ?? null,
209
+ CRTR_MODE: env['CRTR_MODE'] ?? null,
210
+ CRTR_LIFECYCLE: env['CRTR_LIFECYCLE'] ?? null,
211
+ CRTR_NODE_CWD: env['CRTR_NODE_CWD'] ?? null,
212
+ CRTR_HOME: env['CRTR_HOME'] ?? null,
213
+ CRTR_PARENT_NODE_ID: env['CRTR_PARENT_NODE_ID'] ?? null,
214
+ CRTR_ROOT_SESSION: env['CRTR_ROOT_SESSION'] ?? null,
215
+ CRTR_FRONT_DOOR: env['CRTR_FRONT_DOOR'] ?? null,
216
+ },
217
+ injectedDuringBoot: injected.slice(),
218
+ };
219
+ writeFileSync(join(nodeDir, 'fake-pi.boot.json'), JSON.stringify(boot, null, 2));
220
+ // Append-only boot log so the harness can count re-boots (a resume after an
221
+ // idle-release wake, or a fresh pi after a refresh-yield reviveInPlace).
222
+ try {
223
+ appendFileSync(join(nodeDir, 'fake-pi.boots.jsonl'), JSON.stringify(boot) + '\n');
224
+ }
225
+ catch {
226
+ /* best effort */
227
+ }
228
+ // --- control loop: poll for one harness command at a time -------------------
229
+ const cmdFile = join(nodeDir, 'fake-pi.cmd');
230
+ const ackFile = join(nodeDir, 'fake-pi.ack');
231
+ function ack(id, body) {
232
+ try {
233
+ appendFileSync(ackFile, JSON.stringify({ id, ...body, ts: Date.now() }) + '\n');
234
+ }
235
+ catch {
236
+ /* best effort */
237
+ }
238
+ }
239
+ async function doShutdown() {
240
+ // A clean pi /quit. The stophook's session_shutdown handler resolves a still-
241
+ // active node to done (markCleanExitDone → finalize). Mirrors real pi exiting.
242
+ await fire('session_shutdown', { reason: 'quit' }, ctx);
243
+ ack('shutdown', { ok: true });
244
+ clearInterval(timer);
245
+ process.exit(0);
246
+ }
247
+ async function dispatch(cmd) {
248
+ const id = cmd.id ?? cmd.cmd ?? 'cmd';
249
+ switch (cmd.cmd) {
250
+ case 'shutdown':
251
+ await doShutdown();
252
+ break;
253
+ case 'stop':
254
+ streaming = false;
255
+ await fire('agent_end', {
256
+ messages: [
257
+ { role: 'assistant', stopReason: cmd.reason ?? 'stop', content: [{ type: 'text', text: cmd.text ?? '' }] },
258
+ ],
259
+ }, ctx);
260
+ ack(id, { ok: true });
261
+ break;
262
+ case 'turn':
263
+ streaming = true;
264
+ await fire('agent_start', {}, ctx);
265
+ streaming = false;
266
+ await fire('turn_end', { message: { role: 'assistant', usage: { input: 10, output: 5 }, model: 'fake' } }, ctx);
267
+ await fire('agent_end', { messages: [{ role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: cmd.text ?? '' }] }] }, ctx);
268
+ ack(id, { ok: true });
269
+ break;
270
+ default:
271
+ ack(id, { ok: false, error: `unknown cmd: ${String(cmd.cmd)}` });
272
+ }
273
+ }
274
+ async function step() {
275
+ if (shutdownRequested) {
276
+ await doShutdown();
277
+ return;
278
+ }
279
+ if (!existsSync(cmdFile))
280
+ return;
281
+ let cmd = null;
282
+ try {
283
+ cmd = JSON.parse(readFileSync(cmdFile, 'utf8'));
284
+ }
285
+ catch {
286
+ cmd = null;
287
+ }
288
+ try {
289
+ unlinkSync(cmdFile);
290
+ }
291
+ catch {
292
+ /* ignore */
293
+ }
294
+ if (cmd)
295
+ await dispatch(cmd);
296
+ if (shutdownRequested)
297
+ await doShutdown();
298
+ }
299
+ const timer = setInterval(() => {
300
+ void step().catch((e) => recordError(`step: ${String(e)}`));
301
+ }, 100);