@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,116 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/full/broker-lifecycle.test.ts
2
+ //
3
+ // HEADLESS RETARGET (foundation-spec §C.6 + §E). The broker-backed lifecycle
4
+ // acceptance gate (plan T11), items 1–3 — no tmux session, no hasTmux() gate.
5
+ // FULL TIER: item 3 boots a REAL broker process (~5s pi-SDK load), so the file
6
+ // lives in full/ (CI), not the fast local loop. (Items 1–2 are pure model reads.) The suite is SPLIT across sibling files (one isolated
7
+ // harness each); the asserting test per acceptance item (plan §4):
8
+ // 1 spawn --headless → host_kind='broker', null placement → "spawn" (here, MODEL)
9
+ // 2 supervised by the broker pid (signal-0) — a healthy broker is left alone → "spawn" (here, MODEL)
10
+ // 3 inbox wakes LIVE (in-broker watcher) AND DORMANT (idle-release → daemon) → "live wake" here + broker-dormant-wake.test.ts
11
+ // 4–6 + M-1/M-2 crash / teardown / boot failure → broker-crash-teardown.test.ts
12
+ // §5.4 C2 + G1…G9 dialogs / attach → broker-dialogs / broker-attach-*.test.ts
13
+ //
14
+ // (1) BUG LOCKED — the headless-broker spawn+supervision contract. Items 1–2:
15
+ // `node new --headless` yields a BROKER-hosted node — host_kind='broker',
16
+ // NULL tmux placement (window/pane/tmux_session) — supervised purely by its
17
+ // recorded pi_pid (signal-0), so the daemon leaves a HEALTHY broker (live
18
+ // pid) untouched. Item 3: the REAL in-broker inbox-watcher delivers an inbox
19
+ // push to a LIVE broker via pi.sendUserMessage WITHOUT any exit or revive.
20
+ //
21
+ // (2) WHY MODEL-LEVEL, NOT TMUX CHROME — items 1–2 are pure row facts (host_kind,
22
+ // the three NULL placement columns, pid-supervision via handleBrokerLiveness),
23
+ // so they are fabricated DIRECTLY in the canvas with a LIVE pid and ZERO real
24
+ // boots: a tick with a live broker pid clears the grace timer and revives
25
+ // nothing. A broker carries no pane anywhere, so nothing here reads tmux.
26
+ // Item 3 alone needs ONE real broker boot — it proves the in-PROCESS watcher
27
+ // actually fires inside a live broker and reaches the engine's sendUserMessage
28
+ // (the wake is observable only as a real injected message).
29
+ //
30
+ // (3) HOW THE HEADLESS DRIVE STILL FAILS IF THE BUG REGRESSES — if spawn stopped
31
+ // setting host_kind='broker' / null placement, the item-1 model asserts fail;
32
+ // if the daemon revived a healthy (live-pid) broker, item-2's cycles bump and
33
+ // the asserts go RED; if the in-broker watcher stopped delivering, item-3's
34
+ // awaitWake times out (no injected message, no wake).
35
+ import { test, before, after } from 'node:test';
36
+ import assert from 'node:assert/strict';
37
+ import { spawn } from 'node:child_process';
38
+ import { createHarness } from '../helpers/harness.js';
39
+ import { appendInbox } from '../../feed/inbox.js';
40
+ import { isPidAlive } from '../../canvas/pid.js';
41
+ let h;
42
+ let root;
43
+ // Disposable LIVE pids — a real, alive process the daemon can signal-0 probe as a
44
+ // healthy broker. SIGKILLed in after().
45
+ const livePids = [];
46
+ function disposableLivePid() {
47
+ const child = spawn('sleep', ['600'], { stdio: 'ignore', detached: true });
48
+ child.unref();
49
+ livePids.push(child.pid);
50
+ return child.pid;
51
+ }
52
+ before(async () => {
53
+ h = await createHarness({ headless: true, sessionPrefix: 'crtr-broker' });
54
+ root = h.spawnRoot('broker-suite root');
55
+ });
56
+ after(async () => {
57
+ for (const pid of livePids) {
58
+ try {
59
+ process.kill(pid, 'SIGKILL');
60
+ }
61
+ catch {
62
+ /* already gone */
63
+ }
64
+ }
65
+ if (h !== undefined)
66
+ await h.dispose();
67
+ });
68
+ // ===========================================================================
69
+ // Items 1 + 2 — spawn --headless: broker host_kind, NULL placement, supervised
70
+ // by the broker pid alone. PURE MODEL READ: fabricate the broker row directly
71
+ // (a LIVE supervised pid), assert the contract, tick once → a healthy broker is
72
+ // LEFT. ZERO real boots.
73
+ // ===========================================================================
74
+ test('spawn --headless → broker host_kind, null placement; daemon leaves a healthy (live-pid) broker', async () => {
75
+ const livePid = disposableLivePid();
76
+ const id = h.fabricateBrokerNode({
77
+ status: 'active',
78
+ intent: null,
79
+ pi_pid: livePid, // a real, alive process — the signal-0 supervision target
80
+ pi_session_id: 'sess-lifecycle', // booted once (identity bound)
81
+ });
82
+ const node = h.node(id);
83
+ // Item 1: the broker host + a paneless placement (the three NULL columns).
84
+ assert.equal(node.host_kind, 'broker', "host_kind === 'broker'");
85
+ assert.equal(node.window ?? null, null, 'window is NULL (paneless broker)');
86
+ assert.equal(node.pane ?? null, null, 'pane is NULL (paneless broker)');
87
+ assert.equal(node.tmux_session ?? null, null, 'tmux_session is NULL (paneless broker)');
88
+ assert.ok(node.pi_pid != null, 'a broker pid is recorded as pi_pid');
89
+ assert.equal(isPidAlive(node.pi_pid), true, 'the broker pid is alive');
90
+ // Item 2: the supervision signal IS the broker pid. handleBrokerLiveness sees a
91
+ // LIVE pid → nothing pending; the daemon leaves a healthy broker untouched.
92
+ await h.tick();
93
+ assert.equal(h.status(id), 'active', 'a healthy broker stays active across a daemon tick');
94
+ assert.equal(h.node(id).pi_pid, livePid, 'pid unchanged — not revived');
95
+ assert.equal(h.node(id).cycles ?? 0, 0, 'no revive — cycles still 0');
96
+ assert.equal(isPidAlive(livePid), true, 'broker still alive');
97
+ });
98
+ // ===========================================================================
99
+ // Item 3 (LIVE) — the in-broker inbox-watcher delivers an inbox push WITHOUT an
100
+ // exit or revive (the broker stays alive; pi.sendUserMessage → injected). This
101
+ // is the one item that genuinely exercises the broker PROCESS, so it pays ONE
102
+ // real boot.
103
+ // ===========================================================================
104
+ test('LIVE wake — in-broker watcher injects an inbox push with no exit/revive', async () => {
105
+ const id = await h.spawnHeadlessChild(root, 'headless worker — live wake');
106
+ const pid = h.node(id).pi_pid;
107
+ const boots = h.bootCount(id);
108
+ appendInbox(id, { from: 'tester', tier: 'normal', kind: 'message', label: 'live-wake', data: { body: 'do this live' } });
109
+ // The REAL in-broker watcher (800ms poll + 1000ms debounce) delivers via
110
+ // pi.sendUserMessage → fake-pi.injected.jsonl.
111
+ await h.awaitWake(id, { match: /do this live/, timeoutMs: 15_000 });
112
+ // No exit, no revive: same broker pid, still alive, no new boot.
113
+ assert.equal(h.bootCount(id), boots, 'no new boot — the live broker was not revived');
114
+ assert.equal(h.node(id).pi_pid, pid, 'pi_pid unchanged — the broker never exited');
115
+ assert.equal(isPidAlive(pid), true, 'the broker is still alive after the live wake');
116
+ });
@@ -46,7 +46,7 @@
46
46
  import { test } from 'node:test';
47
47
  import assert from 'node:assert/strict';
48
48
  import { spawnSync } from 'node:child_process';
49
- import { createHarness, hasTmux } from './helpers/harness.js';
49
+ import { createHarness, hasTmux } from '../helpers/harness.js';
50
50
  function sessionExists(session) {
51
51
  return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
52
52
  }
@@ -103,8 +103,9 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
103
103
  // ===================================================================
104
104
  const closeRes = h.cli(A, ['node', 'close', '--node', B]);
105
105
  assert.equal(closeRes.code, 0, `node close exit 0\n${closeRes.stderr}`);
106
- // The CLI's own rendered report: cascade root B, exactly 4 closed, 0 spared.
107
- assert.match(closeRes.stdout, new RegExp(`<closed id="${B}" count="4" spared="0"\\s*/>`), `close report names B as root, count=4, spared=0\n${closeRes.stdout}`);
106
+ // The CLI's own rendered report (plain markdown): cascade root B, exactly
107
+ // 4 closed, 0 spared (no spared clause is emitted when none were spared).
108
+ assert.match(closeRes.stdout, new RegExp(`^Closed ${B} and its exclusive subtree — 4 node\\(s\\) closed\\.`), `close report names B as root, count=4, spared=0\n${closeRes.stdout}`);
108
109
  // ===================================================================
109
110
  // ASSERT 1 — the A5 DELIVERABLE: the EXACT terminal status of every
110
111
  // cascade-reaped node. CURRENT behavior: status='canceled', intent=null,
@@ -0,0 +1,292 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/full/daemon-liveness-pane.full.test.ts
2
+ //
3
+ // FULL-TIER (genuine tmux) half of the daemon liveness suite — split out of
4
+ // daemon-liveness.test.ts (foundation-spec §C.8 + §E). These lock the daemon's
5
+ // PANE-existence reconciliation: a live pane vs a stale window cache, the gone-
6
+ // branch routing (crash / finalize / release), and the frozen-pane idle-release
7
+ // revive. Every one of them needs a REAL live tmux window+pane to be faithful —
8
+ // the verdict turns on `isNodePaneAlive → paneExists/paneLocation/listLivePanes`
9
+ // probing the tmux server — so it cannot be model-fabricated; it stays CI-only.
10
+ // (The pure decision primitives — livenessVerdict, isPidAlive — are the fast
11
+ // half, in src/core/__tests__/daemon-liveness.test.ts.)
12
+ import { test, before, after, beforeEach } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { spawn, spawnSync } from 'node:child_process';
18
+ import { createNode, getNode, subscribe } from '../../canvas/canvas.js';
19
+ import { closeDb } from '../../canvas/db.js';
20
+ import { appendInbox } from '../../feed/inbox.js';
21
+ import { markBusy, clearBusy } from '../../runtime/busy.js';
22
+ import { superviseTick } from '../../../daemon/crtrd.js';
23
+ let home;
24
+ function node(id, over = {}) {
25
+ return {
26
+ node_id: id,
27
+ name: id,
28
+ created: new Date().toISOString(),
29
+ cwd: '/tmp/work',
30
+ kind: 'general',
31
+ mode: 'base',
32
+ lifecycle: 'terminal',
33
+ status: 'active',
34
+ ...over,
35
+ };
36
+ }
37
+ function hasTmux() {
38
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
39
+ }
40
+ /** A pid that is guaranteed dead: spawn a no-op and wait for it to exit. */
41
+ function deadPid() {
42
+ const r = spawnSync('true', [], { stdio: 'ignore' });
43
+ return r.pid ?? 0x7ffffffe; // fall back to an implausibly-high pid
44
+ }
45
+ /** A pid that is ALIVE for the test's duration but expendable. Pane-gone
46
+ * fixtures must NEVER use process.pid for an alive pi: the gone-pane zombie
47
+ * sweep (b92082e) SIGKILLs a pane-gone node's live pid, which took the whole
48
+ * test runner down with it. The daemon (or the after hook) reaps these. */
49
+ const livePids = [];
50
+ function disposableLivePid() {
51
+ const child = spawn('sleep', ['600'], { stdio: 'ignore', detached: true });
52
+ child.unref();
53
+ livePids.push(child.pid);
54
+ return child.pid;
55
+ }
56
+ before(() => {
57
+ home = mkdtempSync(join(tmpdir(), 'crtr-daemon-liveness-pane-'));
58
+ process.env['CRTR_HOME'] = home;
59
+ });
60
+ beforeEach(() => {
61
+ closeDb();
62
+ rmSync(home, { recursive: true, force: true });
63
+ });
64
+ after(() => {
65
+ closeDb();
66
+ rmSync(home, { recursive: true, force: true });
67
+ delete process.env['CRTR_HOME'];
68
+ for (const pid of livePids) {
69
+ try {
70
+ process.kill(pid, 'SIGKILL');
71
+ }
72
+ catch {
73
+ /* already reaped by the daemon's zombie sweep — fine */
74
+ }
75
+ }
76
+ });
77
+ // ---------------------------------------------------------------------------
78
+ // superviseTick + a REAL live tmux window (gated on tmux availability)
79
+ // ---------------------------------------------------------------------------
80
+ /** Run `fn` with a real, live tmux window held open for its whole duration, then
81
+ * tear the session down. Gives superviseTick a genuinely-alive window to judge. */
82
+ async function withLiveWindow(tag, fn) {
83
+ const session = `crtr-livetest-${process.pid}-${tag}`;
84
+ spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
85
+ try {
86
+ const r = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
87
+ const window = (r.stdout ?? '').trim().split('\n')[0];
88
+ await fn(session, window);
89
+ }
90
+ finally {
91
+ spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
92
+ }
93
+ }
94
+ /** Like withLiveWindow but also resolves the window's live `%pane_id` — the v3
95
+ * pane-anchored handle the daemon now keys liveness on. */
96
+ async function withLivePane(tag, fn) {
97
+ await withLiveWindow(tag, async (session, window) => {
98
+ const r = spawnSync('tmux', ['display-message', '-p', '-t', `${session}:${window}`, '#{pane_id}'], { encoding: 'utf8' });
99
+ const pane = (r.stdout ?? '').trim();
100
+ await fn(session, window, pane);
101
+ });
102
+ }
103
+ test('window alive + ALIVE pi pid → healthy, untouched', { skip: !hasTmux() }, async () => {
104
+ await withLiveWindow('a', async (session, window) => {
105
+ // pi_pid = this test process: definitely alive.
106
+ createNode(node('A', { tmux_session: session, window, pi_pid: process.pid, pi_session_id: 'booted' }));
107
+ await superviseTick();
108
+ assert.equal(getNode('A').status, 'active', 'a node with a live pi is left active');
109
+ });
110
+ });
111
+ test('window alive + dead pi pid → pending on first observation (not reaped, not revived)', { skip: !hasTmux() }, async () => {
112
+ await withLiveWindow('b', async (session, window) => {
113
+ createNode(node('D', {
114
+ tmux_session: session,
115
+ window,
116
+ pi_pid: deadPid(),
117
+ pi_session_id: 'booted-once',
118
+ intent: null,
119
+ status: 'active',
120
+ }));
121
+ await superviseTick();
122
+ // First observation: inside the grace window → pending. The node must NOT be
123
+ // reaped to 'dead' (its window is alive) nor revived yet (no new pi spawned).
124
+ assert.equal(getNode('D').status, 'active', 'pending node stays active on first tick');
125
+ });
126
+ });
127
+ // ---------------------------------------------------------------------------
128
+ // Step 3 (Q6): PANE-existence drives the daemon verdict, not window-existence.
129
+ // A manual move-pane/join-pane/break-pane must never read as a death; and a
130
+ // pane that is genuinely gone fires the existing gone-branch even when the
131
+ // window it used to live in is still alive.
132
+ // ---------------------------------------------------------------------------
133
+ test('pane alive but window/session cache STALE → LIVE (reconciled, not revived/reaped)', { skip: !hasTmux() }, async () => {
134
+ await withLivePane('p1', async (session, window, pane) => {
135
+ // The pane is live, the pi is alive (this process) — but the row's window
136
+ // cache is bogus (as if a manual move desynced it). Pane-existence must win:
137
+ // the node stays active and reconcile FOLLOWS the pane back to its real window.
138
+ createNode(node('M', {
139
+ pane,
140
+ tmux_session: session,
141
+ window: '@99999', // stale/bogus — windowAlive() would call this gone
142
+ pi_pid: process.pid,
143
+ pi_session_id: 'booted',
144
+ }));
145
+ await superviseTick();
146
+ const m = getNode('M');
147
+ assert.equal(m.status, 'active', 'a live pane keeps the node active despite a stale window cache');
148
+ assert.equal(m.window, window, 'reconcile FOLLOWED the live pane back to its real window');
149
+ assert.equal(m.pane, pane, 'the durable pane id is unchanged');
150
+ });
151
+ });
152
+ test('pane GONE while its old window is still alive → the gone-branch fires (crash → dead)', { skip: !hasTmux() }, async () => {
153
+ await withLivePane('p2', async (session, window) => {
154
+ // Make a guaranteed-DEAD pane id inside the still-live window: split a fresh
155
+ // pane, then kill it (the window survives via its original pane).
156
+ const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
157
+ const dead = (sp.stdout ?? '').trim();
158
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
159
+ // The window is alive, but the node is anchored on that dead pane. Under the
160
+ // old window-keyed liveness this node would read healthy (window alive + live
161
+ // pid). Pane-keyed: the pane is gone → the gone-branch fires. The node is
162
+ // MID-GENERATION (busy marker present — agent_start touched it, agent_end
163
+ // never cleared it), so the gone-branch routes to crash → 'dead'.
164
+ createNode(node('G', {
165
+ pane: dead,
166
+ tmux_session: session,
167
+ window,
168
+ pi_pid: disposableLivePid(), // alive zombie — the gone-pane sweep SIGKILLs it
169
+ pi_session_id: 'booted',
170
+ intent: null,
171
+ }));
172
+ markBusy('G'); // pane killed inside a turn → genuine mid-run crash
173
+ await superviseTick();
174
+ assert.equal(getNode('G').status, 'dead', 'a gone pane mid-generation fires the gone-branch → crash (dead)');
175
+ clearBusy('G');
176
+ });
177
+ });
178
+ // ---------------------------------------------------------------------------
179
+ // Gone-pane routing: a pane-gone node in the crash branch is no longer an
180
+ // unconditional 'dead'. It routes on what the node was DOING at pane-kill time:
181
+ // • mid-generation (busy marker PRESENT) → crash ('dead')
182
+ // • finished its turn, awaiting nothing live → finalize ('done')
183
+ // • finished its turn, still awaiting a LIVE child → release ('idle' + idle-release), revivable
184
+ // (The mid-generation → dead leg is covered by node 'G' above.)
185
+ // ---------------------------------------------------------------------------
186
+ test('pane gone + booted + busy ABSENT + no live subscription → finalize (done): the pane was closed to dismiss a finished node', { skip: !hasTmux() }, async () => {
187
+ await withLivePane('fin1', async (session, window) => {
188
+ const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
189
+ const dead = (sp.stdout ?? '').trim();
190
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
191
+ // Booted (pi_session_id set), no busy marker (agent_end cleared it → the turn
192
+ // finished), and no subscription to any live node. Closing the pane was a
193
+ // dismissal of a node that already did its own work → finalize to 'done'.
194
+ createNode(node('FIN', {
195
+ pane: dead,
196
+ tmux_session: session,
197
+ window,
198
+ pi_pid: disposableLivePid(), // alive zombie — swept before finalize routing
199
+ pi_session_id: 'booted',
200
+ intent: null,
201
+ }));
202
+ // no markBusy: the turn ended cleanly.
203
+ await superviseTick();
204
+ assert.equal(getNode('FIN').status, 'done', 'a finished, unsubscribed node whose pane was closed finalizes to done');
205
+ });
206
+ });
207
+ test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → idle-release (revivable, NOT dead)', { skip: !hasTmux() }, async () => {
208
+ await withLivePane('fin2', async (session, window) => {
209
+ const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
210
+ const dead = (sp.stdout ?? '').trim();
211
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
212
+ // A live child the parent is subscribed to: hasActiveLiveSubscription === true.
213
+ createNode(node('CHILD', { status: 'active', pi_session_id: 'booted' }));
214
+ createNode(node('PARENT', {
215
+ pane: dead,
216
+ tmux_session: session,
217
+ window,
218
+ pi_pid: deadPid(), // the user closed the pane → pi is dead
219
+ pi_session_id: 'booted',
220
+ intent: null,
221
+ }));
222
+ subscribe('PARENT', 'CHILD', true); // active subscription to a LIVE node
223
+ // REGRESSION: closing the pane of a waiting orchestrator must NOT kill it.
224
+ // PARENT finished its turn (no busy marker) but still awaits a live child, so
225
+ // a hard 'dead' would orphan the child's report. It routes to idle-release
226
+ // (revivable); the second pass revives it on the next inbox push. No inbox
227
+ // entry here, so it stays dormant (idle + idle-release) — revivable, not dead.
228
+ await superviseTick();
229
+ const p = getNode('PARENT');
230
+ assert.equal(p.status, 'idle', 'a finished node still awaiting a live child is RELEASED, not killed');
231
+ assert.equal(p.intent, 'idle-release', 'routed to idle-release so a child push revives it');
232
+ assert.equal(p.window ?? null, null, 'the stale window ref is dropped');
233
+ });
234
+ });
235
+ // ---------------------------------------------------------------------------
236
+ // Step 7 (§5.3, F3): a focused-dormant node frozen via remain-on-exit is
237
+ // PANE-alive but pi-DEAD with intent='idle-release'. The daemon must NOT
238
+ // grace-revive it on liveness (handleLiveWindow early-returns for idle-release);
239
+ // it revives ONLY when an unseen inbox entry arrives (second pass, gated on pi
240
+ // liveness — NOT pane existence, which would skip a frozen pane forever).
241
+ // ---------------------------------------------------------------------------
242
+ test('idle-release + live (frozen) pane + DEAD pi → handleLiveWindow does NOT grace-revive it', { skip: !hasTmux() }, async () => {
243
+ await withLivePane('idle1', async (session, window, pane) => {
244
+ createNode(node('F', {
245
+ pane,
246
+ tmux_session: session,
247
+ window,
248
+ pi_pid: deadPid(), // pi is DEAD; the pane is frozen (remain-on-exit stand-in)
249
+ pi_session_id: 'booted',
250
+ intent: 'idle-release',
251
+ status: 'idle',
252
+ home_session: session,
253
+ }));
254
+ // Two ticks: the SECOND is far past REVIVE_GRACE_MS (20s). Without the
255
+ // idle-release early-return in handleLiveWindow, tick 1 would mark the dead
256
+ // pi 'pending' and tick 2 (now past grace) would grace-revive it, bumping
257
+ // cycles. The early-return means it is never even marked pending. No inbox
258
+ // entry, so the second pass never revives it either.
259
+ await superviseTick(1_000_000);
260
+ await superviseTick(1_000_000 + 60_000);
261
+ const m = getNode('F');
262
+ assert.equal(m.status, 'idle', 'still idle — the frozen focused-dormant node is left alone');
263
+ assert.equal(m.cycles ?? 0, 0, 'cycles NOT bumped → reviveNode never fired');
264
+ assert.equal(m.pane, pane, 'pane unchanged');
265
+ assert.equal(m.window, window, 'window unchanged');
266
+ // Non-vacuous: drop the `intent==='idle-release'` early-return and tick 2
267
+ // (deadFor 60s > 20s grace) revives → cycles===1, status flips → both fail.
268
+ });
269
+ });
270
+ test('idle-release + live (frozen) pane + DEAD pi + UNSEEN inbox → REVIVED on the second pass', { skip: !hasTmux() }, async () => {
271
+ await withLivePane('idle2', async (session, window, pane) => {
272
+ createNode(node('W', {
273
+ pane,
274
+ tmux_session: session,
275
+ window,
276
+ pi_pid: deadPid(),
277
+ pi_session_id: 'booted',
278
+ intent: 'idle-release',
279
+ status: 'idle',
280
+ home_session: session, // the revive window lands here (torn down with the session)
281
+ }));
282
+ appendInbox('W', { from: 'child', tier: 'normal', kind: 'update', label: 'work for you' });
283
+ await superviseTick();
284
+ // The OLD second-pass guard `if (isNodePaneAlive(r)) continue;` would SKIP a
285
+ // pane-alive node forever; the new `if (r.pi_pid != null && isPidAlive(...))`
286
+ // gate sees the DEAD pi and revives. reviveNode bumps cycles BEFORE placing
287
+ // the window, so the bump is the observable even though the launch is a real
288
+ // (short-lived, torn-down) window. Non-vacuous: the old pane-existence gate
289
+ // leaves cycles unbumped.
290
+ assert.equal(getNode('W').cycles, 1, 'reviveNode fired on the inbox push despite a live (frozen) pane');
291
+ });
292
+ });
@@ -27,8 +27,8 @@
27
27
  import { test } from 'node:test';
28
28
  import assert from 'node:assert/strict';
29
29
  import { spawnSync } from 'node:child_process';
30
- import { hasTmux } from './helpers/harness.js';
31
- import { spawnAndDetach } from '../spawn.js';
30
+ import { hasTmux } from '../helpers/harness.js';
31
+ import { spawnAndDetach } from '../../spawn.js';
32
32
  const SKIP = !hasTmux();
33
33
  // --- LOCAL tmux drivers (keyed on a raw %pane id, not a canvas node) --------
34
34
  function tmux(args) {
@@ -32,11 +32,11 @@
32
32
  import { test } from 'node:test';
33
33
  import assert from 'node:assert/strict';
34
34
  import { spawnSync } from 'node:child_process';
35
- import { createHarness, hasTmux } from './helpers/harness.js';
36
- import { setPresence, getNode } from '../canvas/canvas.js';
37
- import { openFocusRow, getFocusByNode, getFocusByPane, listFocuses } from '../canvas/focuses.js';
38
- import { closeDb } from '../canvas/db.js';
39
- import { markBusy } from '../runtime/busy.js';
35
+ import { createHarness, hasTmux } from '../helpers/harness.js';
36
+ import { setPresence, getNode } from '../../canvas/canvas.js';
37
+ import { openFocusRow, getFocusByNode, getFocusByPane, listFocuses } from '../../canvas/focuses.js';
38
+ import { closeDb } from '../../canvas/db.js';
39
+ import { markBusy } from '../../runtime/busy.js';
40
40
  const SKIP = !hasTmux() ? 'tmux unavailable' : false;
41
41
  function sessionExists(session) {
42
42
  return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
@@ -109,7 +109,7 @@ test('node demote --detach on a FOCUSED node: flips terminal, relocates the pane
109
109
  // breaks the pane back into h.session.
110
110
  const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
111
111
  assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
112
- assert.match(res.stdout, /detached="true"/, `the agent was detached\n${res.stdout}`);
112
+ assert.match(res.stdout, /relocated to the background/, `the agent was detached\n${res.stdout}`);
113
113
  closeDb();
114
114
  const b = getNode(B);
115
115
  // (a) lifecycle flipped to terminal IN PLACE.
@@ -180,7 +180,7 @@ test('node demote --detach on a NON-GENERATING focused node: RELEASES to dormant
180
180
  // --- Drive the REAL verb on the non-generating focused node.
181
181
  const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
182
182
  assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
183
- assert.match(res.stdout, /detached="true"/, `the agent was detached\n${res.stdout}`);
183
+ assert.match(res.stdout, /relocated to the background/, `the agent was detached\n${res.stdout}`);
184
184
  closeDb();
185
185
  const b = getNode(B);
186
186
  // (b) RELEASED to dormant — idle + intent='idle-release' (revivable via inbox
@@ -21,8 +21,8 @@
21
21
  import { test } from 'node:test';
22
22
  import assert from 'node:assert/strict';
23
23
  import { spawnSync } from 'node:child_process';
24
- import { hasTmux } from './helpers/harness.js';
25
- import { spawnAndDetach } from '../spawn.js';
24
+ import { hasTmux } from '../helpers/harness.js';
25
+ import { spawnAndDetach } from '../../spawn.js';
26
26
  const SKIP = !hasTmux();
27
27
  function tmux(args) {
28
28
  const r = spawnSync('tmux', args, { encoding: 'utf8' });
@@ -18,11 +18,11 @@ import { mkdtempSync, rmSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join } from 'node:path';
20
20
  import { spawnSync } from 'node:child_process';
21
- import { createNode, getNode, setPresence } from '../canvas/canvas.js';
22
- import { openFocusRow, getFocusByNode, getFocusById, listFocuses, } from '../canvas/focuses.js';
23
- import { closeDb } from '../canvas/db.js';
24
- import { outgoingDisposition, retargetFocus, openFocus, focus as placementFocus, registerRootFocus, focusByPane, detachToBackground, } from '../runtime/placement.js';
25
- import { markBusy, isBusy } from '../runtime/busy.js';
21
+ import { createNode, getNode, setPresence } from '../../canvas/canvas.js';
22
+ import { openFocusRow, getFocusByNode, getFocusById, listFocuses, } from '../../canvas/focuses.js';
23
+ import { closeDb } from '../../canvas/db.js';
24
+ import { outgoingDisposition, retargetFocus, openFocus, focus as placementFocus, registerRootFocus, focusByPane, detachToBackground, } from '../../runtime/placement.js';
25
+ import { markBusy, isBusy } from '../../runtime/busy.js';
26
26
  let home;
27
27
  let savedTmux;
28
28
  function node(id, over = {}) {
@@ -17,9 +17,9 @@ import { mkdtempSync, rmSync } from 'node:fs';
17
17
  import { tmpdir } from 'node:os';
18
18
  import { join } from 'node:path';
19
19
  import { spawnSync } from 'node:child_process';
20
- import { createNode, getNode, getRow } from '../canvas/canvas.js';
21
- import { closeDb } from '../canvas/db.js';
22
- import { reconcileDecision, reconcile, isNodePaneAlive, } from '../runtime/placement.js';
20
+ import { createNode, getNode, getRow } from '../../canvas/canvas.js';
21
+ import { closeDb } from '../../canvas/db.js';
22
+ import { reconcileDecision, reconcile, isNodePaneAlive, } from '../../runtime/placement.js';
23
23
  let home;
24
24
  function node(id, over = {}) {
25
25
  return {
@@ -27,11 +27,11 @@ import { mkdtempSync, rmSync } from 'node:fs';
27
27
  import { tmpdir } from 'node:os';
28
28
  import { join } from 'node:path';
29
29
  import { spawnSync } from 'node:child_process';
30
- import { createNode, getNode } from '../canvas/canvas.js';
31
- import { openFocusRow, getFocusByNode, getFocusById } from '../canvas/focuses.js';
32
- import { closeDb } from '../canvas/db.js';
33
- import { reviveTarget, reviveIntoPlacement, reconcileFocus } from '../runtime/placement.js';
34
- import { reviveInPlace } from '../runtime/revive.js';
30
+ import { createNode, getNode } from '../../canvas/canvas.js';
31
+ import { openFocusRow, getFocusByNode, getFocusById } from '../../canvas/focuses.js';
32
+ import { closeDb } from '../../canvas/db.js';
33
+ import { reviveTarget, reviveIntoPlacement, reconcileFocus } from '../../runtime/placement.js';
34
+ import { reviveInPlace } from '../../runtime/revive.js';
35
35
  let home;
36
36
  function node(id, over = {}) {
37
37
  return {
@@ -18,10 +18,10 @@ import { mkdtempSync, rmSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join } from 'node:path';
20
20
  import { spawnSync } from 'node:child_process';
21
- import { createNode, getNode } from '../canvas/canvas.js';
22
- import { openFocusRow, getFocusByNode, getFocusById } from '../canvas/focuses.js';
23
- import { closeDb } from '../canvas/db.js';
24
- import { handFocusToManager, tearDownNode } from '../runtime/placement.js';
21
+ import { createNode, getNode } from '../../canvas/canvas.js';
22
+ import { openFocusRow, getFocusByNode, getFocusById } from '../../canvas/focuses.js';
23
+ import { closeDb } from '../../canvas/db.js';
24
+ import { handFocusToManager, tearDownNode } from '../../runtime/placement.js';
25
25
  let home;
26
26
  function node(id, over = {}) {
27
27
  return {
@@ -32,8 +32,8 @@ import { tmpdir } from 'node:os';
32
32
  import { join, dirname } from 'node:path';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { createRequire } from 'node:module';
35
- import { hasTmux } from './helpers/harness.js';
36
- const CROUTER = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
35
+ import { hasTmux } from '../helpers/harness.js';
36
+ const CROUTER = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
37
37
  const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
38
38
  const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
39
39
  function tmux(args) {
@@ -0,0 +1 @@
1
+ export {};