@crouton-kit/crouter 0.3.18 → 0.3.19

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 (257) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/orchestrator.md +1 -1
  12. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  13. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  14. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  15. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  16. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  17. package/dist/builtin-personas/runtime-base.md +6 -3
  18. package/dist/builtin-personas/spec/orchestrator.md +1 -1
  19. package/dist/builtin-personas/waiting.md +8 -0
  20. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  21. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  22. package/dist/builtin-views/_lib/states.mjs +161 -0
  23. package/dist/builtin-views/canvas/client.mjs +303 -0
  24. package/dist/builtin-views/canvas/view.mjs +576 -0
  25. package/dist/builtin-views/git-pr/client.mjs +440 -0
  26. package/dist/builtin-views/git-pr/view.mjs +675 -0
  27. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  28. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  29. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  30. package/dist/builtin-views/inbox/view.mjs +889 -0
  31. package/dist/builtin-views/linkedin/client.mjs +610 -0
  32. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  34. package/dist/clients/attach/attach-cmd.js +354 -0
  35. package/dist/clients/attach/chat-view.d.ts +77 -0
  36. package/dist/clients/attach/chat-view.js +450 -0
  37. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  38. package/dist/clients/attach/clipboard-image.js +113 -0
  39. package/dist/clients/attach/config-load.d.ts +31 -0
  40. package/dist/clients/attach/config-load.js +113 -0
  41. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  42. package/dist/clients/attach/extension-dialogs.js +101 -0
  43. package/dist/clients/attach/input-controller.d.ts +54 -0
  44. package/dist/clients/attach/input-controller.js +204 -0
  45. package/dist/clients/attach/slash-commands.d.ts +36 -0
  46. package/dist/clients/attach/slash-commands.js +200 -0
  47. package/dist/clients/attach/view-socket.d.ts +48 -0
  48. package/dist/clients/attach/view-socket.js +126 -0
  49. package/dist/commands/attention.js +3 -3
  50. package/dist/commands/canvas-prune.js +1 -1
  51. package/dist/commands/daemon.js +3 -2
  52. package/dist/commands/human/prompts.js +1 -1
  53. package/dist/commands/human/queue.js +43 -8
  54. package/dist/commands/human/shared.d.ts +2 -0
  55. package/dist/commands/memory/find.d.ts +1 -0
  56. package/dist/commands/memory/find.js +180 -0
  57. package/dist/commands/memory/lint.d.ts +1 -0
  58. package/dist/commands/memory/lint.js +140 -0
  59. package/dist/commands/memory/list.d.ts +1 -0
  60. package/dist/commands/memory/list.js +79 -0
  61. package/dist/commands/memory/read.js +103 -0
  62. package/dist/commands/memory/shared.d.ts +30 -0
  63. package/dist/commands/memory/shared.js +122 -0
  64. package/dist/commands/memory/write.d.ts +1 -0
  65. package/dist/commands/memory/write.js +85 -0
  66. package/dist/commands/memory.d.ts +2 -0
  67. package/dist/commands/memory.js +27 -0
  68. package/dist/commands/node.d.ts +3 -2
  69. package/dist/commands/node.js +607 -61
  70. package/dist/commands/pkg/market-manage.js +1 -1
  71. package/dist/commands/push.js +6 -6
  72. package/dist/commands/revive.js +1 -1
  73. package/dist/commands/skill/author.js +1 -1
  74. package/dist/commands/skill/shared.d.ts +1 -8
  75. package/dist/commands/skill/shared.js +2 -55
  76. package/dist/commands/skill.js +9 -14
  77. package/dist/commands/sys/doctor.js +1 -1
  78. package/dist/commands/sys/update.js +1 -1
  79. package/dist/commands/view-cycle.d.ts +2 -0
  80. package/dist/commands/view-cycle.js +125 -0
  81. package/dist/commands/view-list.d.ts +2 -0
  82. package/dist/commands/view-list.js +66 -0
  83. package/dist/commands/view-new.d.ts +2 -0
  84. package/dist/commands/view-new.js +70 -0
  85. package/dist/commands/view-pick.d.ts +2 -0
  86. package/dist/commands/view-pick.js +119 -0
  87. package/dist/commands/view-run.d.ts +2 -0
  88. package/dist/commands/view-run.js +191 -0
  89. package/dist/commands/view.d.ts +2 -0
  90. package/dist/commands/view.js +29 -0
  91. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  92. package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
  93. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  94. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  95. package/dist/core/__tests__/cascade-close.test.js +12 -2
  96. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  97. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  98. package/dist/core/__tests__/context-intro.test.js +76 -62
  99. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  100. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  101. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  102. package/dist/core/__tests__/detach-focus.test.js +206 -0
  103. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  104. package/dist/core/__tests__/draw-style.test.js +258 -0
  105. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  106. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  107. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  108. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  109. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  110. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  111. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  112. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  113. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  114. package/dist/core/__tests__/helpers/harness.js +111 -1
  115. package/dist/core/__tests__/home-session.test.js +41 -8
  116. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  117. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  118. package/dist/core/__tests__/kickoff.test.js +37 -3
  119. package/dist/core/__tests__/live-mutation.test.js +50 -33
  120. package/dist/core/__tests__/memory.test.js +23 -115
  121. package/dist/core/__tests__/placement-focus.test.js +5 -0
  122. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  123. package/dist/core/__tests__/relaunch.test.js +4 -3
  124. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  125. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  126. package/dist/core/__tests__/spawn-root.test.js +10 -0
  127. package/dist/core/__tests__/spike-harness.test.js +1 -0
  128. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  129. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  130. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  131. package/dist/core/__tests__/wake-origin.test.js +110 -0
  132. package/dist/core/bootstrap.js +1 -1
  133. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  134. package/dist/core/canvas/browse/app.js +24 -2
  135. package/dist/core/canvas/browse/model.d.ts +38 -2
  136. package/dist/core/canvas/browse/model.js +134 -10
  137. package/dist/core/canvas/browse/render.d.ts +6 -12
  138. package/dist/core/canvas/browse/render.js +72 -104
  139. package/dist/core/canvas/canvas.js +15 -22
  140. package/dist/core/canvas/db.js +46 -0
  141. package/dist/core/canvas/index.d.ts +1 -0
  142. package/dist/core/canvas/index.js +1 -0
  143. package/dist/core/canvas/pid.d.ts +4 -0
  144. package/dist/core/canvas/pid.js +23 -0
  145. package/dist/core/canvas/render.d.ts +11 -2
  146. package/dist/core/canvas/render.js +69 -0
  147. package/dist/core/canvas/types.d.ts +85 -3
  148. package/dist/core/canvas/wakeups.d.ts +76 -0
  149. package/dist/core/canvas/wakeups.js +185 -0
  150. package/dist/core/config.js +4 -1
  151. package/dist/core/frontmatter.js +37 -124
  152. package/dist/core/memory-resolver.d.ts +49 -0
  153. package/dist/core/memory-resolver.js +141 -0
  154. package/dist/core/personas/loader.d.ts +7 -0
  155. package/dist/core/personas/loader.js +36 -5
  156. package/dist/core/personas/resolve.js +13 -3
  157. package/dist/core/predicate.d.ts +63 -0
  158. package/dist/core/predicate.js +189 -0
  159. package/dist/core/resolver.js +26 -5
  160. package/dist/core/runtime/bearings.d.ts +53 -12
  161. package/dist/core/runtime/bearings.js +132 -59
  162. package/dist/core/runtime/broker-cli.d.ts +1 -0
  163. package/dist/core/runtime/broker-cli.js +46 -0
  164. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  165. package/dist/core/runtime/broker-protocol.js +153 -0
  166. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  167. package/dist/core/runtime/broker-sdk.js +72 -0
  168. package/dist/core/runtime/broker.d.ts +55 -0
  169. package/dist/core/runtime/broker.js +1128 -0
  170. package/dist/core/runtime/close.js +35 -6
  171. package/dist/core/runtime/host.d.ts +53 -0
  172. package/dist/core/runtime/host.js +186 -0
  173. package/dist/core/runtime/kickoff.d.ts +2 -1
  174. package/dist/core/runtime/kickoff.js +91 -5
  175. package/dist/core/runtime/launch.d.ts +45 -2
  176. package/dist/core/runtime/launch.js +65 -2
  177. package/dist/core/runtime/lifecycle.js +23 -6
  178. package/dist/core/runtime/memory.d.ts +2 -42
  179. package/dist/core/runtime/memory.js +11 -162
  180. package/dist/core/runtime/nodes.d.ts +33 -0
  181. package/dist/core/runtime/nodes.js +59 -1
  182. package/dist/core/runtime/persona.js +21 -11
  183. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  184. package/dist/core/runtime/pi-vendored.js +49 -0
  185. package/dist/core/runtime/placement.d.ts +32 -14
  186. package/dist/core/runtime/placement.js +206 -52
  187. package/dist/core/runtime/promote.d.ts +0 -6
  188. package/dist/core/runtime/promote.js +1 -12
  189. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  190. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  191. package/dist/core/runtime/reset.js +8 -6
  192. package/dist/core/runtime/revive.d.ts +2 -0
  193. package/dist/core/runtime/revive.js +34 -34
  194. package/dist/core/runtime/spawn.d.ts +19 -0
  195. package/dist/core/runtime/spawn.js +75 -22
  196. package/dist/core/runtime/stop-guard.d.ts +1 -1
  197. package/dist/core/runtime/stop-guard.js +6 -1
  198. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  199. package/dist/core/runtime/tmux-chrome.js +1 -1
  200. package/dist/core/runtime/tmux.d.ts +28 -0
  201. package/dist/core/runtime/tmux.js +80 -6
  202. package/dist/core/scope.d.ts +11 -0
  203. package/dist/core/scope.js +39 -0
  204. package/dist/core/spawn.d.ts +5 -0
  205. package/dist/core/spawn.js +20 -2
  206. package/dist/core/substrate/gate.d.ts +13 -0
  207. package/dist/core/substrate/gate.js +21 -0
  208. package/dist/core/substrate/index.d.ts +7 -0
  209. package/dist/core/substrate/index.js +18 -0
  210. package/dist/core/substrate/on-read.d.ts +14 -0
  211. package/dist/core/substrate/on-read.js +292 -0
  212. package/dist/core/substrate/render.d.ts +25 -0
  213. package/dist/core/substrate/render.js +256 -0
  214. package/dist/core/substrate/schema.d.ts +76 -0
  215. package/dist/core/substrate/schema.js +124 -0
  216. package/dist/core/substrate/session-cache.d.ts +30 -0
  217. package/dist/core/substrate/session-cache.js +77 -0
  218. package/dist/core/substrate/subject.d.ts +41 -0
  219. package/dist/core/substrate/subject.js +54 -0
  220. package/dist/core/tui/contract.d.ts +83 -0
  221. package/dist/core/tui/contract.js +8 -0
  222. package/dist/core/tui/draw.d.ts +96 -0
  223. package/dist/core/tui/draw.js +339 -0
  224. package/dist/core/tui/host.d.ts +29 -0
  225. package/dist/core/tui/host.js +379 -0
  226. package/dist/core/tui/loader.d.ts +16 -0
  227. package/dist/core/tui/loader.js +94 -0
  228. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  229. package/dist/core/wake.d.ts +86 -0
  230. package/dist/core/wake.js +308 -0
  231. package/dist/daemon/crtrd.d.ts +29 -4
  232. package/dist/daemon/crtrd.js +662 -46
  233. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  234. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  235. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  236. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  237. package/dist/pi-extensions/canvas-commands.js +10 -0
  238. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  239. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  240. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  241. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  242. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  243. package/dist/pi-extensions/canvas-nav.js +87 -23
  244. package/dist/pi-extensions/canvas-stophook.js +17 -8
  245. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  246. package/dist/pi-extensions/canvas-view.js +75 -0
  247. package/dist/prompts/skill.js +19 -26
  248. package/dist/prompts/view.d.ts +7 -0
  249. package/dist/prompts/view.js +101 -0
  250. package/dist/types.d.ts +4 -0
  251. package/dist/types.js +1 -0
  252. package/package.json +9 -4
  253. package/dist/commands/skill/find.d.ts +0 -4
  254. package/dist/commands/skill/find.js +0 -257
  255. package/dist/commands/skill/read.js +0 -91
  256. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  257. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -1,28 +1,25 @@
1
- // Tests for the <crtr-context> bearings preamble + scoped memory:
2
- // 1. A plain spawned node gets NO MEMORY.md; promotion seeds it (the template).
3
- // 2. seedMemory is idempotent (never clobbers an evolved memory).
4
- // 3. Worker bearings = shared-doc framing only (no <memory> block);
5
- // orchestrator bearings add the across-cycles framing + a <memory> block
6
- // that MERGES the applicable stores, each a `label · dir` header over its
7
- // live index POINTER LINES only (the how-to boilerplate is dropped — it
8
- // lives once in the kernel); promotion delivers that same orchestrator
1
+ // Tests for the <crtr-context> bearings preamble:
2
+ // 1. Worker and orchestrator bearings carry the `## References` block
3
+ // (substrate reference docs + node-local docs as `###` sub-sections) in
4
+ // place of the removed `<memory>` block no `label · dir` stanzas, no
5
+ // `(empty)` markers, no MEMORY.md index inlining. Orchestrators add the
6
+ // across-cycles framing; promotion delivers that same orchestrator
9
7
  // context-dir framing to a node that spawned base.
10
- // 4. canvas-context-intro injects the block as its own session message at
8
+ // 2. canvas-context-intro injects the block as its own session message at
11
9
  // session_start (before the first prompt), idempotent across resumes.
12
10
  //
13
11
  // Run: node --import tsx/esm --test src/core/__tests__/context-intro.test.ts
14
12
  import { test, before, beforeEach, after } from 'node:test';
15
13
  import assert from 'node:assert/strict';
16
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
17
15
  import { tmpdir } from 'node:os';
18
16
  import { join } from 'node:path';
19
- import { existsSync } from 'node:fs';
20
17
  import { closeDb } from '../canvas/db.js';
21
18
  import { contextDir } from '../canvas/paths.js';
22
19
  import { spawnNode } from '../runtime/nodes.js';
23
20
  import { promote } from '../runtime/promote.js';
24
21
  import { personaDrift } from '../runtime/persona.js';
25
- import { memoryPath, memoryDir, readMemory, seedMemory, hasMemory, MEMORY_TEMPLATE, userMemoryDir, userMemoryPath, projectMemoryDir, projectMemoryPath, } from '../runtime/memory.js';
22
+ import { memoryDir } from '../runtime/memory.js';
26
23
  import registerCanvasContextIntro, { buildContextIntro, renderContextMessage, CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
27
24
  let home;
28
25
  before(() => {
@@ -39,69 +36,80 @@ after(() => {
39
36
  delete process.env['CRTR_HOME'];
40
37
  delete process.env['CRTR_NODE_ID'];
41
38
  });
42
- test('a plain spawned node gets NO memory; promotion seeds the index + dir', () => {
43
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
44
- assert.equal(hasMemory(meta.node_id), false, 'no memory before promotion');
45
- promote(meta.node_id);
46
- assert.ok(hasMemory(meta.node_id), 'MEMORY.md index exists after promotion');
47
- assert.ok(existsSync(memoryDir(meta.node_id)), 'memory dir created for direct writes');
48
- assert.ok(memoryPath(meta.node_id).endsWith('/memory/MEMORY.md'), 'index lives inside the memory dir');
49
- assert.equal(readMemory(meta.node_id), MEMORY_TEMPLATE);
50
- });
51
- test('seedMemory is idempotent — never clobbers an evolved memory', () => {
52
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
53
- seedMemory(meta.node_id);
54
- const evolved = '# Memory\n\n## Lessons\n- never trust the cache\n';
55
- writeFileSync(memoryPath(meta.node_id), evolved);
56
- assert.equal(seedMemory(meta.node_id), false, 'returns false when one exists');
57
- assert.equal(readMemory(meta.node_id), evolved, 'left untouched');
58
- });
59
- test('worker bearings: shared-doc framing only, no orchestrator/memory framing', () => {
39
+ test('worker bearings: base framing + ## References block (no <memory>, no label·dir stanzas), NO across-cycles framing', () => {
40
+ // Bug-regression: review finding M1 buildContextBearings was changed from
41
+ // buildMemoryBlock (<memory> + label·dir per-store stanzas) to
42
+ // renderReferencesBlock (## References + ### <name> sub-sections). This test
43
+ // locks in the new contract.
60
44
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
45
+ // Seed a node-local substrate doc so the ## References block is non-empty.
46
+ const dir = memoryDir(meta.node_id);
47
+ mkdirSync(dir, { recursive: true });
48
+ writeFileSync(join(dir, 'test-ref.md'), '---\nkind: reference\nwhen: when testing\nwhy: regression fixture\nsystem-prompt-visibility: preview\n---\nTest body.\n');
61
49
  const block = buildContextIntro(meta.node_id);
62
50
  assert.match(block, new RegExp(`<crtr-context dir="${contextDir(meta.node_id)}">`));
63
51
  assert.match(block, /shared document store, not a task tracker/, 'base = shared docs, not tasks');
64
- assert.doesNotMatch(block, /MEMORY\.md/, 'no memory pointer for a memory-less worker');
65
- assert.doesNotMatch(block, /<memory>/, 'no memory block for a memory-less worker');
52
+ // New contract: NO <memory> block replaced by ## References.
53
+ assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
54
+ // New contract: NO label·dir stanza headers (the removed per-store format).
55
+ assert.doesNotMatch(block, /user-global · /, 'no user-global label·dir stanza');
56
+ assert.doesNotMatch(block, /node-local · /, 'no node-local label·dir stanza');
57
+ // New contract: NO (empty) placeholder (the removed empty-store marker).
58
+ assert.doesNotMatch(block, /\(empty\)/, 'no (empty) placeholder');
59
+ // Reference content renders as ## References + ### sub-sections — no other shape.
60
+ assert.match(block, /## References\n\n###/, '## References followed by ### sub-sections');
61
+ // A terminal worker must NOT carry the orchestrator across-cycles framing.
66
62
  assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
67
63
  assert.match(block, /<\/crtr-context>/);
68
64
  });
69
- test('orchestrator bearings: across-cycles framing + a <memory> block whose body is the live pointer lines under `label · dir`', () => {
65
+ test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into ## References; the MEMORY.md index never renders', () => {
66
+ // Bug-regression: review finding M1 — buildContextBearings replaced the old
67
+ // <memory> pointer-line block with ## References. Node-local substrate docs
68
+ // render as ### sub-sections at their rung; the old index file never surfaces.
70
69
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
71
- seedMemory(meta.node_id);
72
- // The seeded index carries the how-to comment + the empty marker; an evolved
73
- // index adds a pointer line. The block must render ONLY the pointer line.
74
- writeFileSync(memoryPath(meta.node_id), '# memory index one pointer line per memory; how-to in "Your long-term memory".\n\n- [Flaky build](flaky-build.md) — first run fails\n');
70
+ promote(meta.node_id); // flip to orchestrator mode — the across-cycles gate
71
+ const dir = memoryDir(meta.node_id);
72
+ mkdirSync(dir, { recursive: true });
73
+ // Write a MEMORY.md file to verify it is never surfaced in the block.
74
+ const legacyIndexPath = join(dir, 'MEMORY.md');
75
+ writeFileSync(legacyIndexPath, '# memory index — one pointer line per memory; how-to in "Your long-term memory".\n\n- [Flaky build](flaky-build.md) — first run fails\n');
76
+ // A node-local substrate doc DOES ride into ## References at its rung.
77
+ writeFileSync(join(dir, 'flaky-build.md'), '---\nkind: reference\nwhen: when the build flakes\nwhy: first run fails\nsystem-prompt-visibility: preview\n---\nFirst run always fails; rerun once.\n');
75
78
  const block = buildContextIntro(meta.node_id);
76
79
  assert.match(block, /shared document store, not a task tracker/, 'still carries the base framing');
77
80
  assert.match(block, /refresh cycles/, 'orchestrator gets the across-cycles framing');
78
- assert.match(block, /<memory>/, 'has a memory block');
79
- // (a) the store renders just its pointer line under a `label · dir` header.
80
- assert.ok(block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'compact `label · dir` header');
81
- assert.match(block, /- \[Flaky build\]\(flaky-build\.md\) first run fails/, 'the live pointer line is inlined');
82
- // (c) no `index:` line, no absolute index path, no inlined how-to boilerplate.
83
- assert.ok(!block.includes('index:'), 'no index: substring');
84
- assert.ok(!block.includes(memoryPath(meta.node_id)), 'no absolute index (MEMORY.md) path');
85
- assert.ok(!block.includes('how-to in'), 'the index how-to boilerplate is NOT inlined');
81
+ assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
82
+ assert.match(block, /## References\n/, 'references block present');
83
+ assert.match(block, /### flaky-build\n/, 'node-local doc gets its ### sub-section');
84
+ assert.match(block, /when the build flakes, read this reference\. first run fails\./, 'preview rung renders the routing line');
85
+ // The index file itself never renders: no header comment, no pointer line, no path.
86
86
  assert.ok(!block.includes('# memory index'), 'the index header comment is NOT inlined');
87
+ assert.ok(!block.includes('- [Flaky build](flaky-build.md)'), 'index pointer lines are NOT inlined');
88
+ assert.ok(!block.includes(legacyIndexPath), 'no absolute index (MEMORY.md) path');
89
+ assert.ok(!block.includes('node-local · '), 'no label·dir stanza header');
87
90
  assert.match(block, /<\/crtr-context>/);
88
91
  });
89
- test('orchestrator bearings: a promoted node merges all THREE stores; template-only stores render (empty)', () => {
92
+ test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-none node-local doc still surfaces as a ### title stub', () => {
93
+ // Bug-regression: review findings M1 + M6 — the three-store `label · dir`
94
+ // stanzas and (empty) markers are gone, and node-local docs are NOT filtered
95
+ // on rung: a migrated node-local reference defaults
96
+ // system-prompt-visibility: none and must still ride into ## References as
97
+ // its bare title (never its body).
90
98
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
91
- promote(meta.node_id); // seeds user-global + project + node-local (all template-only)
99
+ promote(meta.node_id); // flip to orchestrator mode
100
+ const dir = memoryDir(meta.node_id);
101
+ mkdirSync(dir, { recursive: true });
102
+ writeFileSync(join(dir, 'rung-none-fact.md'), '---\nkind: reference\n---\nbody that must not render at the none rung\n');
92
103
  const block = buildContextIntro(meta.node_id);
93
- // Each scope present as a `label · dir` header.
94
- assert.ok(block.includes(`user-global · ${userMemoryDir()}`), 'merges the user-global store');
95
- assert.ok(block.includes(`project · ${projectMemoryDir('/tmp/work')}`), 'merges the project store');
96
- assert.ok(block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'merges the node-local store');
97
- // (b) a template-only / not-yet-written store renders the (empty) marker.
98
- assert.match(block, /user-global · .*\n\(empty\)/, 'template-only user store renders (empty)');
99
- assert.equal(block.match(/\(empty\)/g)?.length, 3, 'all three template-only stores render (empty)');
100
- // (c) no absolute index paths leak in.
101
- assert.ok(!block.includes(userMemoryPath()), 'no user-global MEMORY.md path');
102
- assert.ok(!block.includes(projectMemoryPath('/tmp/work')), 'no project MEMORY.md path');
103
- // The type→store mapping is taught by the kernel; the block points at it.
104
- assert.match(block, /type/, 'the block references the type that decides the store');
104
+ // No per-store stanza headers, no (empty) markers, no index paths.
105
+ assert.ok(!block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'no node-local stanza header');
106
+ assert.ok(!block.includes('user-global · '), 'no user-global stanza header');
107
+ assert.ok(!block.includes('project · '), 'no project stanza header');
108
+ assert.doesNotMatch(block, /\(empty\)/, 'no (empty) markers');
109
+ assert.ok(!block.includes('MEMORY.md'), 'no MEMORY.md path in the block');
110
+ // M6: rung-none node-local doc surfaces as its title stub only.
111
+ assert.match(block, /### rung-none-fact\s*\n/, 'rung-none node-local doc surfaces as its title stub');
112
+ assert.ok(!block.includes('body that must not render'), 'none rung renders the title only, not the body');
105
113
  });
106
114
  test('promotion guidance delivers the orchestrator context-dir framing', () => {
107
115
  // A node spawns as a base worker (no orchestrator bearings). On promotion the
@@ -145,12 +153,18 @@ test('session_start injects the block as the first message of an empty session',
145
153
  test('session_start is idempotent across resume (skips if already in history)', () => {
146
154
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
147
155
  process.env['CRTR_NODE_ID'] = meta.node_id;
148
- // Restored session already carries the block → no duplicate injection.
156
+ // Restored session already carries OUR block (which names this node id) → no
157
+ // duplicate injection. The content matters: the guard is fork-aware and only
158
+ // skips when the present bearings name THIS node (a fork inherits the
159
+ // SOURCE's bearings, which name a different node — see canvas-context-intro
160
+ // tests), so a realistic resume entry must carry the real block content.
149
161
  const pi = makeFakePi();
150
162
  registerCanvasContextIntro(pi);
151
- const entries = [{ type: 'custom_message', customType: CONTEXT_INTRO_CUSTOM_TYPE }];
163
+ const entries = [
164
+ { type: 'custom_message', customType: CONTEXT_INTRO_CUSTOM_TYPE, content: buildContextIntro(meta.node_id) },
165
+ ];
152
166
  pi.handler({ reason: 'resume' }, fakeCtx(entries));
153
- assert.equal(pi.sent.length, 0, 'block already in history → skip');
167
+ assert.equal(pi.sent.length, 0, 'our own block already in history → skip');
154
168
  });
155
169
  test('session_start is inert when CRTR_NODE_ID is absent', () => {
156
170
  delete process.env['CRTR_NODE_ID'];
@@ -64,10 +64,14 @@ test('never-booted node is marked dead and surfaced to the parent as urgent', as
64
64
  assert.equal(inbox[0].from, 'C');
65
65
  assert.match(inbox[0].label, /Spawn failed/);
66
66
  });
67
- // A node that HAD booted (pi_session_id set) then lost its window is a genuine
68
- // crash, not a boot failure it is marked dead but NOT surfaced as a spawn
69
- // failure (no false alarm to the parent).
70
- test('a crash after boot is marked dead without a boot-failure push', async () => {
67
+ // A node that HAD booted (pi_session_id set) then died mid-generation is a
68
+ // genuine crash, not a boot failure. It is marked dead AND wakes the inbox-
69
+ // waiting parent but with a "child died" notice (surfaceChildDeath), NOT a
70
+ // "Spawn failed" boot-failure pointer (surfaceBootFailure). The parent-wake
71
+ // itself was added by main's b5abf6e (parent-wake on child death) and is locked
72
+ // in by child-death-wake.test.ts; this test preserves the discriminating intent
73
+ // — a crash is never surfaced as a false spawn-failure alarm.
74
+ test('a crash after boot wakes the parent with a child-death notice, not a boot-failure push', async () => {
71
75
  createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
72
76
  createNode(node('C2', {
73
77
  parent: 'P2',
@@ -85,7 +89,12 @@ test('a crash after boot is marked dead without a boot-failure push', async () =
85
89
  markBusy('C2');
86
90
  await superviseTick();
87
91
  assert.equal(getNode('C2').status, 'dead', 'crashed child is dead');
88
- assert.equal(readInboxSince('P2').length, 0, 'a real crash does not push a boot-failure pointer');
92
+ const inbox = readInboxSince('P2');
93
+ assert.equal(inbox.length, 1, 'a genuine mid-run crash wakes the inbox-waiting parent (D-1 parent-wake)');
94
+ assert.equal(inbox[0].tier, 'urgent', 'mid-run crash is delivered as urgent');
95
+ assert.equal(inbox[0].from, 'C2');
96
+ assert.match(inbox[0].label, /died/i, 'the wake is a child-death notice (surfaceChildDeath), not silence');
97
+ assert.doesNotMatch(inbox[0].label, /Spawn failed/, 'a crash is NOT surfaced as a false boot-failure alarm');
89
98
  clearBusy('C2');
90
99
  });
91
100
  // A still-booting node whose window is alive must be left untouched — boot is
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { spawnSync } from 'node:child_process';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
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';
@@ -32,6 +32,17 @@ function deadPid() {
32
32
  // spawnSync has reaped it by the time it returns; pid may be on r.pid.
33
33
  return r.pid ?? 0x7ffffffe; // fall back to an implausibly-high pid
34
34
  }
35
+ /** A pid that is ALIVE for the test's duration but expendable. Pane-gone
36
+ * fixtures must NEVER use process.pid for an alive pi: the gone-pane zombie
37
+ * sweep (b92082e) SIGKILLs a pane-gone node's live pid, which took the whole
38
+ * test runner down with it. The daemon (or the after hook) reaps these. */
39
+ const livePids = [];
40
+ function disposableLivePid() {
41
+ const child = spawn('sleep', ['600'], { stdio: 'ignore', detached: true });
42
+ child.unref();
43
+ livePids.push(child.pid);
44
+ return child.pid;
45
+ }
35
46
  before(() => {
36
47
  home = mkdtempSync(join(tmpdir(), 'crtr-daemon-liveness-'));
37
48
  process.env['CRTR_HOME'] = home;
@@ -44,6 +55,14 @@ after(() => {
44
55
  closeDb();
45
56
  rmSync(home, { recursive: true, force: true });
46
57
  delete process.env['CRTR_HOME'];
58
+ for (const pid of livePids) {
59
+ try {
60
+ process.kill(pid, 'SIGKILL');
61
+ }
62
+ catch {
63
+ /* already reaped by the daemon's zombie sweep — fine */
64
+ }
65
+ }
47
66
  });
48
67
  // ---------------------------------------------------------------------------
49
68
  // livenessVerdict — the pure grace-window decision
@@ -157,7 +176,7 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
157
176
  pane: dead,
158
177
  tmux_session: session,
159
178
  window,
160
- pi_pid: process.pid, // alive — irrelevant once the pane reads gone
179
+ pi_pid: disposableLivePid(), // alive zombie — the gone-pane sweep SIGKILLs it
161
180
  pi_session_id: 'booted',
162
181
  intent: null,
163
182
  }));
@@ -172,7 +191,7 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
172
191
  // unconditional 'dead'. It routes on what the node was DOING at pane-kill time:
173
192
  // • mid-generation (busy marker PRESENT) → crash ('dead')
174
193
  // • finished its turn, awaiting nothing live → finalize ('done')
175
- // • finished its turn, still awaiting a LIVE child → crash ('dead'), for now
194
+ // • finished its turn, still awaiting a LIVE child → release ('idle' + idle-release), revivable
176
195
  // (The mid-generation → dead leg is covered by node 'G' above.)
177
196
  // ---------------------------------------------------------------------------
178
197
  test('pane gone + booted + busy ABSENT + no live subscription → finalize (done): the pane was closed to dismiss a finished node', { skip: !hasTmux() }, async () => {
@@ -187,7 +206,7 @@ test('pane gone + booted + busy ABSENT + no live subscription → finalize (done
187
206
  pane: dead,
188
207
  tmux_session: session,
189
208
  window,
190
- pi_pid: process.pid,
209
+ pi_pid: disposableLivePid(), // alive zombie — swept before finalize routing
191
210
  pi_session_id: 'booted',
192
211
  intent: null,
193
212
  }));
@@ -196,7 +215,7 @@ test('pane gone + booted + busy ABSENT + no live subscription → finalize (done
196
215
  assert.equal(getNode('FIN').status, 'done', 'a finished, unsubscribed node whose pane was closed finalizes to done');
197
216
  });
198
217
  });
199
- test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → crash (dead), for now', { skip: !hasTmux() }, async () => {
218
+ test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → idle-release (revivable, NOT dead)', { skip: !hasTmux() }, async () => {
200
219
  await withLivePane('fin2', async (session, window) => {
201
220
  const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
202
221
  const dead = (sp.stdout ?? '').trim();
@@ -207,15 +226,21 @@ test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → crash (dead
207
226
  pane: dead,
208
227
  tmux_session: session,
209
228
  window,
210
- pi_pid: process.pid,
229
+ pi_pid: deadPid(), // the user closed the pane → pi is dead
211
230
  pi_session_id: 'booted',
212
231
  intent: null,
213
232
  }));
214
233
  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.
234
+ // REGRESSION: closing the pane of a waiting orchestrator must NOT kill it.
235
+ // PARENT finished its turn (no busy marker) but still awaits a live child, so
236
+ // a hard 'dead' would orphan the child's report. It routes to idle-release
237
+ // (revivable); the second pass revives it on the next inbox push. No inbox
238
+ // entry here, so it stays dormant (idle + idle-release) — revivable, not dead.
217
239
  await superviseTick();
218
- assert.equal(getNode('PARENT').status, 'dead', 'a finished node still awaiting a live child crashes (dead), not finalizes');
240
+ const p = getNode('PARENT');
241
+ assert.equal(p.status, 'idle', 'a finished node still awaiting a live child is RELEASED, not killed');
242
+ assert.equal(p.intent, 'idle-release', 'routed to idle-release so a child push revives it');
243
+ assert.equal(p.window ?? null, null, 'the stale window ref is dropped');
219
244
  });
220
245
  });
221
246
  // ---------------------------------------------------------------------------
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,206 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/detach-focus.test.ts
2
+ //
3
+ // GAP CLOSE (flagship-lifecycle.test.ts coverage boundary): "node demote
4
+ // --detach (A3: orphaned-focus-row hazard)". An END-TO-END test that drives the
5
+ // REAL `crtr node demote --detach` verb (subprocess, AS the node) — the actual
6
+ // Alt+C → D menu path — against a live fake-pi node genuinely FOCUSED in a user
7
+ // viewport, and
8
+ // proves the three things the A3 hazard is about:
9
+ // (a) lifecycle flips terminal IN PLACE,
10
+ // (b) the still-running pi's pane is RELOCATED to the backstage crtr session
11
+ // (the pi keeps generating off-screen, not killed),
12
+ // (c) the focus row it occupied is CLOSED — no orphaned/phantom viewport row
13
+ // lingering on the relocated %pane_id (Invariant P / Invariant F5).
14
+ //
15
+ // This is the harness-driven counterpart to placement-focus.test.ts's
16
+ // `detachToBackground` unit (which calls the function directly): here the FLIP
17
+ // and the detach both go through the real CLI leaf (node.ts nodeDemote →
18
+ // setLifecycle), so the whole `crtr node demote --detach` wiring — the Alt+C → D
19
+ // menu path — is exercised. Modeled on live-mutation.test.ts (harness +
20
+ // `crtr node demote` + firstPaneOf) and
21
+ // placement-focus.test.ts (user/back sessions + focus-row asserts).
22
+ //
23
+ // NOTE — the SIBLING focused-finish→manager-TAKEOVER path is NOT added here: the
24
+ // harness mints its root via in-process createNode (never a real bootRoot — see
25
+ // flagship S1), so a manager is always a paneless, never-booted row. Driving B
26
+ // through agent_end's done-branch (h.finish) with such a manager would only ever
27
+ // hit handFocusToManager's "live-but-paneless inline root → false" guard
28
+ // (closeFocusToShell), never a genuine takeover; a LIVE-backstage or dormant
29
+ // idle-release manager swap is unreachable through the harness. That swap is
30
+ // unit-covered in placement-teardown.test.ts (MAJOR 1 + the dormant idle-release
31
+ // case), so it is deliberately not re-driven here.
32
+ import { test } from 'node:test';
33
+ import assert from 'node:assert/strict';
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';
40
+ const SKIP = !hasTmux() ? 'tmux unavailable' : false;
41
+ function sessionExists(session) {
42
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
43
+ }
44
+ function tmuxOut(args) {
45
+ return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
46
+ }
47
+ /** The first %pane_id of a tmux window (spawn records window+session, not pane). */
48
+ function firstPaneOf(window) {
49
+ const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
50
+ if (r.status !== 0)
51
+ return null;
52
+ return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
53
+ }
54
+ /** A pane's CURRENT session/window (display-message on its durable %id). */
55
+ function paneLoc(pane) {
56
+ const out = tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
57
+ const [session, window] = out.split('\t');
58
+ if (!session || !window)
59
+ return null;
60
+ return { session, window };
61
+ }
62
+ function paneSessionReal(pane) {
63
+ return tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}']);
64
+ }
65
+ function paneExistsReal(pane) {
66
+ return tmuxOut(['display-message', '-p', '-t', pane, '#{pane_id}']) === pane;
67
+ }
68
+ // ===========================================================================
69
+ // `crtr node demote --detach` on a FOCUSED node (A3 gap close; Alt+C → D).
70
+ // ===========================================================================
71
+ test('node demote --detach on a FOCUSED node: flips terminal, relocates the pane to the backstage, CLOSES the focus row (A3 — no orphaned focus row)', { skip: SKIP, timeout: 120_000 }, async () => {
72
+ const h = await createHarness({ sessionPrefix: 'crtr-detach-focus' });
73
+ const user = `crtr-detach-user-${process.pid}-${Date.now().toString(36)}`;
74
+ try {
75
+ // A resident root + a live terminal child B (born into the backstage =
76
+ // h.session, the harness's CRTR_NODE_SESSION). B holds an active live sub
77
+ // to nothing extra — it just needs to be a live, focusable terminal node.
78
+ const A = h.spawnRoot('resident root');
79
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
80
+ const b0 = h.node(B);
81
+ assert.equal(b0.lifecycle, 'terminal', 'B born terminal');
82
+ assert.equal(b0.status, 'active', 'B active after boot');
83
+ // Resolve B's live %pane_id from its window (the spawn path records only
84
+ // window+session; pane is null until a reconcile/focus).
85
+ const bPane = firstPaneOf(b0.window);
86
+ assert.ok(typeof bPane === 'string' && bPane !== '', 'B has a live pane');
87
+ // --- Put B into a USER viewport, FOCUSED. Create a separate user session
88
+ // and break B's live pi pane out into it (the pi keeps running; the
89
+ // %id survives the break), then anchor its presence + open a focus row.
90
+ // This is the genuine "focused in a user pane" precondition — distinct
91
+ // from the backstage (h.session) the detach will relocate it back to.
92
+ spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600'], { stdio: 'ignore' });
93
+ assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
94
+ const moved = paneLoc(bPane);
95
+ assert.equal(moved?.session, user, 'B pane physically in the user session now');
96
+ assert.equal(paneExistsReal(bPane), true, "B's pi is still alive after the move");
97
+ closeDb();
98
+ setPresence(B, { pane: bPane, tmux_session: user, window: moved.window });
99
+ openFocusRow('f-detach', bPane, user, B);
100
+ assert.equal(getFocusByNode(B)?.focus_id, 'f-detach', 'precondition: B is FOCUSED in the user viewport');
101
+ // B must read as GENERATING for the backstage-park path: isGenerating now
102
+ // gates the break-pane (Bug 1 / Invariant P) on the mid-turn busy marker
103
+ // AND a live pi_pid. The boot only recorded pi_pid (session_start); no turn
104
+ // fired, so set the busy marker to model an agent genuinely mid-turn —
105
+ // otherwise the new gate would RELEASE B (the non-generating path below).
106
+ markBusy(B);
107
+ // --- Drive the REAL verb on the FOCUSED node. The subprocess inherits
108
+ // CRTR_NODE_SESSION = h.session (the backstage), so detachToBackground
109
+ // breaks the pane back into h.session.
110
+ const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
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}`);
113
+ closeDb();
114
+ const b = getNode(B);
115
+ // (a) lifecycle flipped to terminal IN PLACE.
116
+ assert.equal(b.lifecycle, 'terminal', '(a) B lifecycle=terminal after the flip');
117
+ assert.equal(b.status, 'active', "(a) detach does NOT end the pi — B stays active");
118
+ // (b) the still-running pane was RELOCATED to the backstage crtr session
119
+ // (NOT killed): the pi keeps generating off-screen.
120
+ assert.equal(paneExistsReal(bPane), true, "(b) B's pi keeps generating (pane alive, relocated not killed)");
121
+ assert.equal(paneSessionReal(bPane), h.session, '(b) B pane relocated to the backstage crtr session');
122
+ assert.equal(b.tmux_session, h.session, "(b) B's LOCATION followed the pane to the backstage");
123
+ // (c) the focus row is CLOSED — no orphaned/phantom viewport (A3 hazard).
124
+ assert.equal(getFocusByNode(B), null, '(c) B no longer occupies any focus (row CLOSED — Invariant P)');
125
+ assert.equal(getFocusByPane(bPane), null, '(c) NO phantom focus resolves on the relocated %id');
126
+ assert.equal(listFocuses().length, 0, '(c) no dangling focus rows remain');
127
+ }
128
+ finally {
129
+ spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
130
+ const session = h.session;
131
+ await h.dispose();
132
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
133
+ }
134
+ });
135
+ // ===========================================================================
136
+ // Bug 1 (Invariant P / detach-idle-node): `crtr node demote --detach` on a
137
+ // NON-GENERATING focused node must NOT park it alive in the backstage — it must
138
+ // be RELEASED to dormant and its pane reaped.
139
+ //
140
+ // The backstage holds ONLY generating-but-unfocused nodes (Invariant P).
141
+ // detachToBackground used to break-pane to the backstage UNCONDITIONALLY, with no
142
+ // generating-ness check: a resident root parked idle between turns (pi alive,
143
+ // idle, NOT mid-turn) detached via Alt+C → D became unfocused + pi-alive-idle +
144
+ // holding a backstage pane forever — nothing re-enters agent_end to release it
145
+ // and, with no live subscription, nothing wakes it → a leaked live-idle pi that
146
+ // never reaps. The fix gates the break-pane on isGenerating() and RELEASES a
147
+ // non-generating node to dormant instead (mirrors retargetFocus's parked-viewer
148
+ // release on focus-away).
149
+ //
150
+ // This drives the SAME real verb as the happy-path test above, but on a node
151
+ // with NO busy marker (not mid-turn). isGenerating() = busy && pidAlive, so a
152
+ // freshly-booted node (pi_pid recorded at session_start, no turn fired) reads as
153
+ // NOT generating.
154
+ // ===========================================================================
155
+ test('node demote --detach on a NON-GENERATING focused node: RELEASES to dormant (idle + idle-release), reaps the pane, closes the focus row — NOT parked in the backstage (Bug 1 — Invariant P)', { skip: SKIP, timeout: 120_000 }, async () => {
156
+ const h = await createHarness({ sessionPrefix: 'crtr-detach-idle' });
157
+ const user = `crtr-detach-idle-user-${process.pid}-${Date.now().toString(36)}`;
158
+ try {
159
+ // A resident root + a live terminal child B. B is freshly booted: pi alive
160
+ // (pi_pid recorded at session_start), status active, and — critically — NO
161
+ // busy marker, since no turn ever fired. It is parked idle, not mid-turn:
162
+ // exactly the non-generating occupant Bug 1 is about.
163
+ const A = h.spawnRoot('resident root');
164
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
165
+ const b0 = h.node(B);
166
+ assert.equal(b0.status, 'active', 'B active after boot');
167
+ const bPane = firstPaneOf(b0.window);
168
+ assert.ok(typeof bPane === 'string' && bPane !== '', 'B has a live pane');
169
+ // --- Put B into a USER viewport, FOCUSED (same precondition as the happy
170
+ // path), but DO NOT mark it busy: it stays NOT generating.
171
+ spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600'], { stdio: 'ignore' });
172
+ assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
173
+ const moved = paneLoc(bPane);
174
+ assert.equal(moved?.session, user, 'B pane physically in the user session now');
175
+ assert.equal(paneExistsReal(bPane), true, "B's pi is alive (parked idle, not mid-turn)");
176
+ closeDb();
177
+ setPresence(B, { pane: bPane, tmux_session: user, window: moved.window });
178
+ openFocusRow('f-detach-idle', bPane, user, B);
179
+ assert.equal(getFocusByNode(B)?.focus_id, 'f-detach-idle', 'precondition: B is FOCUSED, NON-generating');
180
+ // --- Drive the REAL verb on the non-generating focused node.
181
+ const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
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}`);
184
+ closeDb();
185
+ const b = getNode(B);
186
+ // (b) RELEASED to dormant — idle + intent='idle-release' (revivable via inbox
187
+ // / re-focus), NOT left active in the backstage.
188
+ assert.equal(b.status, 'idle', '(b) B released to dormant (status idle)');
189
+ assert.equal(b.intent, 'idle-release', "(b) B's intent is idle-release (daemon revives on inbox)");
190
+ // (c) the pane is REAPED / presence nulled — NOT relocated to the backstage
191
+ // crtr session. The leaked live-idle pi must be gone.
192
+ assert.equal(paneExistsReal(bPane), false, '(c) B pane reaped (NOT parked alive in the backstage)');
193
+ assert.equal(b.pane, null, "(c) B's presence pane is nulled");
194
+ assert.equal(b.tmux_session, null, "(c) B's presence session is nulled (NOT the backstage crtr session)");
195
+ // (a) the focus row is CLOSED — no orphaned/phantom viewport.
196
+ assert.equal(getFocusByNode(B), null, '(a) B no longer occupies any focus (row CLOSED — Invariant P)');
197
+ assert.equal(getFocusByPane(bPane), null, '(a) NO phantom focus resolves on the reaped %id');
198
+ assert.equal(listFocuses().length, 0, '(a) no dangling focus rows remain');
199
+ }
200
+ finally {
201
+ spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
202
+ const session = h.session;
203
+ await h.dispose();
204
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
205
+ }
206
+ });
@@ -0,0 +1 @@
1
+ export {};