@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.
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/base.md +1 -1
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/spec/base.md +1 -1
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +14 -2
- package/dist/commands/skill/author.js +2 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/close.test.js +2 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/focuses.test.js +5 -68
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/home-session.test.js +1 -1
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/placement-focus.test.js +106 -46
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +22 -16
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/__tests__/tmux-surface.test.js +8 -9
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/db.js +2 -3
- package/dist/core/canvas/focuses.d.ts +2 -2
- package/dist/core/canvas/focuses.js +4 -3
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/demote.js +2 -7
- package/dist/core/runtime/launch.d.ts +3 -1
- package/dist/core/runtime/launch.js +4 -1
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +39 -10
- package/dist/core/runtime/placement.js +100 -44
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +36 -31
- package/dist/core/runtime/revive.d.ts +1 -1
- package/dist/core/runtime/revive.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +13 -6
- package/dist/core/runtime/tmux.js +21 -12
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +40 -28
- package/dist/pi-extensions/canvas-resume.d.ts +21 -0
- package/dist/pi-extensions/canvas-resume.js +82 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +21 -9
- package/dist/prompts/skill.js +6 -1
- package/package.json +2 -2
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- package/dist/core/runtime/presence.d.ts +0 -30
- package/dist/core/runtime/presence.js +0 -178
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /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
|
|
153
|
-
//
|
|
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
|
|
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,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);
|