@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
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,166 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-sdk-wiring.test.ts
2
+ //
3
+ // REAL-SDK regression tests for the latent broker gaps the viewer-reuse scout
4
+ // (node mq5thyli) found (findings-viewer-reuse.md §Q2). These deliberately drive
5
+ // the ACTUAL `@earendil-works/pi-coding-agent` SDK — NOT the `CRTR_BROKER_ENGINE`
6
+ // fake the lifecycle suite uses — because C3 and C4 slipped past the Phase-3 gate
7
+ // PRECISELY because that gate mocked the SDK out. Each test exercises the exact
8
+ // production wiring (`buildBrokerSession` / `makeBrokerUiContext`) and would FAIL
9
+ // against the pre-fix broker:
10
+ //
11
+ // C3 — the broker drives the SERVICES path, so an extension-registered custom
12
+ // model provider is registered into the ModelRegistry and resolves onto
13
+ // the session. Plain createAgentSession never calls registerProvider, so a
14
+ // custom-provider node would get NO model.
15
+ // C4 — a project AGENTS.md is injected into the assembled system prompt (the
16
+ // services path loads project context; 0.78.1 has no project-trust gate so
17
+ // context is unconditionally loaded — see buildBrokerSession's C4 note).
18
+ // C2 — with ZERO viewers attached, the broker UI context resolves confirm() /
19
+ // select() / input() / editor() to deny/cancel IMMEDIATELY (noOp), instead
20
+ // of relying on a per-dialog timeout and hanging the agent turn forever.
21
+ //
22
+ // These run offline (no network/auth): registration + system-prompt assembly +
23
+ // dialog routing are all local. No tmux, no broker process — direct unit drives.
24
+ import { test } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join, dirname } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
31
+ import { buildBrokerSession, makeBrokerUiContext } from '../runtime/broker.js';
32
+ // The REAL engine, assembled from broker-sdk's static SDK re-exports — bypasses
33
+ // the CRTR_BROKER_ENGINE seam entirely so these tests can never accidentally hit
34
+ // the fake (the whole point: prove the wiring the mock hid).
35
+ const realEngine = {
36
+ createAgentSessionServices,
37
+ createAgentSessionFromServices,
38
+ SessionManager,
39
+ VERSION,
40
+ };
41
+ const HERE = dirname(fileURLToPath(import.meta.url));
42
+ const C3_EXT = join(HERE, 'fixtures', 'c3-custom-provider-ext.ts');
43
+ function cfg(cwd, extra = {}) {
44
+ return { cwd, extensionPaths: [], ...extra };
45
+ }
46
+ // ===========================================================================
47
+ // C3 — the SERVICES path registers an extension's custom model provider, so the
48
+ // broker resolves a model that plain createAgentSession (no registerProvider)
49
+ // would never see.
50
+ // ===========================================================================
51
+ test('C3 — services path registers an extension model provider; the broker session gets it', async () => {
52
+ const cwd = mkdtempSync(join(tmpdir(), 'crtr-c3-'));
53
+ try {
54
+ const { session, services } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C3_EXT], model: 'c3prov/c3model' }));
55
+ try {
56
+ // The extension's provider was registered into the SERVICES registry (this
57
+ // is the registerProvider step plain createAgentSession skips).
58
+ const found = services.modelRegistry.find('c3prov', 'c3model');
59
+ assert.ok(found, 'C3: extension-registered model is present in the services ModelRegistry');
60
+ assert.equal(found.provider, 'c3prov', 'C3: provider name round-trips');
61
+ assert.equal(found.id, 'c3model', 'C3: model id round-trips');
62
+ // …and the broker resolved it onto the session AGAINST the services registry
63
+ // (a fresh ModelRegistry would have returned undefined → no model).
64
+ assert.equal(session.model?.id, 'c3model', 'C3: the broker session got the custom-provider model');
65
+ }
66
+ finally {
67
+ session.dispose();
68
+ }
69
+ }
70
+ finally {
71
+ rmSync(cwd, { recursive: true, force: true });
72
+ }
73
+ });
74
+ // ===========================================================================
75
+ // C4 — project context (AGENTS.md) is injected into the system prompt. The
76
+ // services path loads project context files; on the 0.78.1 pin there is no
77
+ // project-trust gate, so they load unconditionally (and buildBrokerSession does
78
+ // NOT re-introduce the CLI's headless trust resolver, which would drop them).
79
+ // ===========================================================================
80
+ test('C4 — a project AGENTS.md is injected into the assembled system prompt', async () => {
81
+ const cwd = mkdtempSync(join(tmpdir(), 'crtr-c4-'));
82
+ try {
83
+ const marker = 'C4_AGENTS_MARKER_' + Math.random().toString(36).slice(2);
84
+ writeFileSync(join(cwd, 'AGENTS.md'), `# Project rules\n\n${marker}\n`);
85
+ const { session } = await buildBrokerSession(realEngine, cfg(cwd));
86
+ try {
87
+ assert.ok(session.systemPrompt.includes(marker), 'C4: project AGENTS.md content is present in the assembled system prompt');
88
+ }
89
+ finally {
90
+ session.dispose();
91
+ }
92
+ }
93
+ finally {
94
+ rmSync(cwd, { recursive: true, force: true });
95
+ }
96
+ });
97
+ // ===========================================================================
98
+ // C2 — zero-viewer dialogs resolve to deny/cancel IMMEDIATELY (noOp), never
99
+ // hanging the turn and never waiting on a per-dialog timeout. Drives the REAL
100
+ // makeBrokerUiContext (the exact ExtensionUIContext the SDK hands extensions),
101
+ // with a controller() that reports zero viewers.
102
+ // ===========================================================================
103
+ test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately (noOp, no hang)', async () => {
104
+ const ctx = makeBrokerUiContext({
105
+ controller: () => null, // ZERO viewers attached
106
+ forward: () => {
107
+ throw new Error('C2: must NOT forward a dialog when no controller is attached');
108
+ },
109
+ pending: new Map(),
110
+ });
111
+ // confirm() WITH a large timeout: the fix must resolve at once (deny), NOT after
112
+ // the timeout — the old timeout-reliant path would have waited 60s (or, with no
113
+ // timeout, hung forever). Race against a 2s "HANG" sentinel to make a regression
114
+ // a hard failure rather than a slow pass.
115
+ const start = Date.now();
116
+ const confirmed = await Promise.race([
117
+ ctx.confirm('proceed?', 'really?', { timeout: 60_000 }),
118
+ new Promise((r) => setTimeout(() => r('HANG'), 2_000)),
119
+ ]);
120
+ assert.equal(confirmed, false, 'C2: confirm() denies (false) with zero viewers');
121
+ assert.ok(Date.now() - start < 1_000, 'C2: resolved immediately, not after the 60s timeout');
122
+ // The other blocking dialogs cancel to undefined — including editor(), which
123
+ // takes NO opts at all (so it could never carry a timeout to lean on).
124
+ assert.equal(await ctx.select('pick', ['a', 'b']), undefined, 'C2: select() cancels (undefined)');
125
+ assert.equal(await ctx.input('name'), undefined, 'C2: input() cancels (undefined)');
126
+ assert.equal(await ctx.editor('edit'), undefined, 'C2: editor() cancels (undefined)');
127
+ });
128
+ // ===========================================================================
129
+ // M2 (review mq5wkqep / T4) — REPLACES the Wave-0 M-1 cancel-on-detach. A dialog
130
+ // forwarded to a controller that then DETACHES must NOT be cancelled: it stays
131
+ // pending so a brief detach/reattach (or a handoff to another controller) does
132
+ // not lose an answerable dialog. The broker-side default timeout is the ONLY
133
+ // non-answer resolution (proven here with a short per-dialog timeout standing in
134
+ // for the 120s default). Guards against regressing to the over-eager cancel.
135
+ // ===========================================================================
136
+ test('M2 — a forwarded dialog stays pending on controller detach, resolving only on the broker-side timeout', async () => {
137
+ const pending = new Map();
138
+ let attached = true;
139
+ const ctx = makeBrokerUiContext({
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
+ controller: () => (attached ? { id: 'c1' } : null),
142
+ forward: () => {
143
+ /* a real controller would receive the request over its socket */
144
+ },
145
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
+ pending: pending,
147
+ });
148
+ // Controller attached → confirm() forwards + registers a pending dialog (with the
149
+ // request retained for welcome.pending_dialog / re-route, T4) and does NOT
150
+ // resolve yet. A short per-dialog timeout stands in for the 120s broker default.
151
+ const p = ctx.confirm('proceed?', 'really?', { timeout: 80 });
152
+ assert.equal(pending.size, 1, 'M2: a forwarded dialog is pending while a controller is attached');
153
+ const entry = [...pending.values()][0];
154
+ assert.ok(entry.request, 'M2: the pending entry retains the request (welcome/re-route need it)');
155
+ assert.equal(entry.cancel, undefined, 'M2: there is no cancel-on-detach path anymore');
156
+ // Controller detaches → the dialog STAYS pending (the broker no longer cancels it).
157
+ attached = false;
158
+ assert.equal(pending.size, 1, 'M2: detach does NOT cancel the in-flight dialog');
159
+ // Only the broker-side timeout resolves it, to the SAFE default (deny).
160
+ const resolved = await Promise.race([
161
+ p,
162
+ new Promise((r) => setTimeout(() => r('HANG'), 2_000)),
163
+ ]);
164
+ assert.equal(resolved, false, 'M2: resolves to deny on the broker-side timeout, never hangs');
165
+ assert.equal(pending.size, 0, 'M2: the pending registry is drained after the timeout');
166
+ });
@@ -134,13 +134,23 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
134
134
  // ASSERT 3 — EXCLUSIVE-SUBTREE OWNERSHIP: nothing ABOVE (A) and nothing
135
135
  // SIDEWAYS (S, G) was reaped. close(B) reaped EXACTLY B's own subtree.
136
136
  // ===================================================================
137
- // Ancestor A — fully untouched: still the live resident root, inbox intact.
137
+ // Ancestor A — lifecycle untouched (still the live resident root), but A is
138
+ // the SURVIVING manager of the close-root B, so the wake doctrine fix WAKES
139
+ // it: a "child closed" notice is fanned to a manager OUTSIDE the closing set
140
+ // (D-1 — previously A heard nothing and a dormant A could hang forever).
138
141
  {
139
142
  const a = h.node(A);
140
143
  assert.equal(a.status, 'active', 'A (ancestor) still active — untouched by the cascade');
141
144
  assert.equal(a.lifecycle, 'resident', 'A still resident');
142
145
  assert.equal(a.intent ?? null, null, 'A intent untouched');
143
- assert.deepEqual(h.inbox(A), aInboxBefore, "A's inbox is byte-for-byte intact across the close (cascade touches only closed nodes' inboxes)");
146
+ // A's PRIOR entries are preserved; exactly ONE new child-closed entry
147
+ // from the closed child B is appended (the doctrine wake).
148
+ const aInbox = h.inbox(A);
149
+ assert.deepEqual(aInbox.slice(0, aInboxBefore.length), aInboxBefore, "A's prior inbox entries are preserved across the close");
150
+ const added = aInbox.slice(aInboxBefore.length);
151
+ assert.equal(added.length, 1, 'exactly one new entry appended to the surviving manager A');
152
+ assert.equal(added[0].from, B, 'the new entry is from the closed child B');
153
+ assert.match(added[0].label, /closed/i, 'the new entry tells A its child B was closed');
144
154
  }
145
155
  // Sideways subtree S→G — wholly alive: status active AND real panes alive.
146
156
  for (const id of sideways) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,245 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/child-death-wake.test.ts
2
+ //
3
+ // D-1 BUG REGRESSION + node/canvas LIFECYCLE — "the runtime wakes a dormant
4
+ // parent on ANY terminal child outcome."
5
+ //
6
+ // The bug (D-1 finding, context/d1-finding.md): a parent that delegates and
7
+ // then JUST STOPS — retaining its inbox-wait, arming no self-wake — was woken
8
+ // ONLY when a child `push final`s or never boots. A child that crashes AFTER
9
+ // booting, is daemon-finalized after a quiet turn, or is `node close`d marked
10
+ // the child dead/done/canceled with NO push to the parent, so a purely-inbox-
11
+ // waiting parent hung dormant forever. The wake doctrine ("delegate and stop;
12
+ // the runtime wakes you") is only honest if EVERY terminal child outcome reaches
13
+ // the parent — this locks that in.
14
+ //
15
+ // The fix fans a system inbox entry to subscribersOf(child) on three previously-
16
+ // silent terminal outcomes — mirroring surfaceBootFailure:
17
+ // • daemon CRASH after boot (crtrd pane-gone, mid-generation → 'dead')
18
+ // • daemon FINALIZE on quiet (crtrd pane-gone, turn ended, nothing live → 'done')
19
+ // • `node close` (close.ts, the surviving manager outside the set)
20
+ //
21
+ // THE CRUX (the whole risk): the wake must fire ONLY on genuine death/close —
22
+ // NEVER on healthy dormancy. A child that ended its turn STILL waiting (an active
23
+ // live grandchild, or a pending self-wake) is alive-and-dormant, not dead; waking
24
+ // its parent then would re-create the spurious-wake storm the doctrine exists to
25
+ // kill (the concrete case: a developer child that finished its edits, spawned its
26
+ // own reviewer, and went dormant waiting on it — its parent must stay asleep).
27
+ // The daemon draws that boundary EXACTLY where the stop-guard does:
28
+ // hasActiveLiveSubscription || hasPendingSelfWake ⇒ healthy dormancy ⇒ release,
29
+ // no wake.
30
+ //
31
+ // Faithful: REAL canvas data layer, REAL tmux panes, the REAL daemon decision
32
+ // pass (superviseTick) and the REAL closeNode. The wake travels through the ONE
33
+ // channel the d1-finding identified — an entry appended to the parent's
34
+ // inbox.jsonl — so each direction asserts that channel: the system entry appears
35
+ // (parent woken: its dormant pi is then revived by the daemon's second pass,
36
+ // observed via a cycles bump) or it does NOT (parent stays asleep, cycles flat).
37
+ import { test, before, after, beforeEach } from 'node:test';
38
+ import assert from 'node:assert/strict';
39
+ import { mkdtempSync, rmSync } from 'node:fs';
40
+ import { tmpdir } from 'node:os';
41
+ import { join } from 'node:path';
42
+ import { spawnSync } from 'node:child_process';
43
+ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
44
+ import { armWake } from '../canvas/wakeups.js';
45
+ import { closeDb } from '../canvas/db.js';
46
+ import { readInboxSince } from '../feed/inbox.js';
47
+ import { markBusy, clearBusy } from '../runtime/busy.js';
48
+ import { closeNode } from '../runtime/close.js';
49
+ import { superviseTick } from '../../daemon/crtrd.js';
50
+ let home;
51
+ function node(id, over = {}) {
52
+ return {
53
+ node_id: id,
54
+ name: id,
55
+ created: new Date().toISOString(),
56
+ cwd: '/tmp/work',
57
+ kind: 'general',
58
+ mode: 'base',
59
+ lifecycle: 'terminal',
60
+ status: 'active',
61
+ ...over,
62
+ };
63
+ }
64
+ function hasTmux() {
65
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
66
+ }
67
+ /** A pid that is guaranteed dead. */
68
+ function deadPid() {
69
+ const r = spawnSync('true', [], { stdio: 'ignore' });
70
+ return r.pid ?? 0x7ffffffe;
71
+ }
72
+ const cyclesOf = (id) => getNode(id)?.cycles ?? 0;
73
+ before(() => {
74
+ home = mkdtempSync(join(tmpdir(), 'crtr-child-death-wake-'));
75
+ process.env['CRTR_HOME'] = home;
76
+ });
77
+ beforeEach(() => {
78
+ closeDb();
79
+ rmSync(home, { recursive: true, force: true });
80
+ });
81
+ after(() => {
82
+ closeDb();
83
+ rmSync(home, { recursive: true, force: true });
84
+ delete process.env['CRTR_HOME'];
85
+ });
86
+ /** Run `fn` with a real, live tmux session + its window's live `%pane_id`. The
87
+ * PARENT occupies this (frozen) pane while pi-dead + idle-released — the
88
+ * faithful shape of an unfocused dormant orchestrator the daemon's second pass
89
+ * revives on an unseen inbox entry. */
90
+ async function withLivePane(tag, fn) {
91
+ const session = `crtr-cdw-${process.pid}-${tag}`;
92
+ spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
93
+ try {
94
+ const w = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
95
+ const window = (w.stdout ?? '').trim().split('\n')[0];
96
+ const p = spawnSync('tmux', ['display-message', '-p', '-t', `${session}:${window}`, '#{pane_id}'], { encoding: 'utf8' });
97
+ const pane = (p.stdout ?? '').trim();
98
+ await fn(session, window, pane);
99
+ }
100
+ finally {
101
+ spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
102
+ }
103
+ }
104
+ /** A guaranteed-DEAD pane id inside `window`: split a fresh pane, then kill it.
105
+ * The window survives via its original pane. This is how a child's pane reads
106
+ * "gone" to the daemon while the session/window are still alive. */
107
+ function deadPaneIn(session, window) {
108
+ const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
109
+ const dead = (sp.stdout ?? '').trim();
110
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
111
+ return dead;
112
+ }
113
+ /** A dormant, unfocused PARENT: pi-dead, idle-released, frozen on the live pane,
114
+ * home_session set so the daemon's second pass can revive it. */
115
+ function dormantParent(id, session, window, pane) {
116
+ createNode(node(id, {
117
+ pane,
118
+ tmux_session: session,
119
+ window,
120
+ pi_pid: deadPid(),
121
+ pi_session_id: 'booted',
122
+ intent: 'idle-release',
123
+ status: 'idle',
124
+ home_session: session,
125
+ }));
126
+ }
127
+ // ===========================================================================
128
+ // POSITIVE — a genuine terminal child outcome WAKES the dormant parent.
129
+ // ===========================================================================
130
+ test('CRASH after boot (mid-generation, pane gone) wakes the dormant inbox-waiting parent', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
131
+ await withLivePane('crash', async (session, window, pane) => {
132
+ dormantParent('PARENT', session, window, pane);
133
+ const dead = deadPaneIn(session, window);
134
+ createNode(node('CHILD', {
135
+ pane: dead, tmux_session: session, window,
136
+ pi_pid: deadPid(), pi_session_id: 'booted', intent: null, status: 'active',
137
+ }));
138
+ subscribe('PARENT', 'CHILD', true); // the spawn-time spine edge
139
+ markBusy('CHILD'); // pane died INSIDE a turn → genuine mid-run crash
140
+ const before = cyclesOf('PARENT');
141
+ await superviseTick();
142
+ assert.equal(getNode('CHILD').status, 'dead', 'CHILD crashed → dead');
143
+ // THE WAKE CHANNEL: a system entry from CHILD lands in PARENT's inbox.
144
+ const wake = readInboxSince('PARENT').find((e) => e.from === 'CHILD');
145
+ assert.ok(wake, 'crash fanned a system inbox entry to the dormant parent (mirrors surfaceBootFailure)');
146
+ assert.match(wake.label, /died/i, 'the entry tells the parent WHICH child died and how');
147
+ // END-TO-END: the dormant parent is REVIVED on the same tick (second pass).
148
+ assert.equal(cyclesOf('PARENT'), before + 1, 'dormant PARENT revived on the same tick — no longer hangs');
149
+ clearBusy('CHILD');
150
+ });
151
+ });
152
+ test('quiet-turn FINALIZE (pane gone, nothing live to wait for) wakes the dormant parent', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
153
+ await withLivePane('finalize', async (session, window, pane) => {
154
+ dormantParent('PARENT', session, window, pane);
155
+ const dead = deadPaneIn(session, window);
156
+ // Booted, turn finished (NO busy marker), no live subscription, no pending
157
+ // self-wake → the daemon finalizes it (done): a dismissal of a finished node.
158
+ createNode(node('CHILD', {
159
+ pane: dead, tmux_session: session, window,
160
+ pi_pid: deadPid(), pi_session_id: 'booted', intent: null, status: 'active',
161
+ }));
162
+ subscribe('PARENT', 'CHILD', true);
163
+ const before = cyclesOf('PARENT');
164
+ await superviseTick();
165
+ assert.equal(getNode('CHILD').status, 'done', 'CHILD finalized → done');
166
+ const wake = readInboxSince('PARENT').find((e) => e.from === 'CHILD');
167
+ assert.ok(wake, 'finalize fanned a system inbox entry to the dormant parent');
168
+ assert.match(wake.label, /without a final report/i, 'the entry tells the parent the child ended with no final');
169
+ assert.equal(cyclesOf('PARENT'), before + 1, 'dormant PARENT revived on the same tick');
170
+ });
171
+ });
172
+ test('node close of a child wakes its SURVIVING manager (the parent outside the closing set)', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
173
+ await withLivePane('close', async (session, window, pane) => {
174
+ dormantParent('PARENT', session, window, pane);
175
+ const childPane = deadPaneIn(session, window); // any pane handle; closeNode tears it down
176
+ createNode(node('CHILD', {
177
+ pane: childPane, tmux_session: session, window,
178
+ pi_session_id: 'booted', status: 'active',
179
+ }));
180
+ subscribe('PARENT', 'CHILD', true); // PARENT is a manager OUTSIDE the close set
181
+ // Closing CHILD: closingSet({CHILD}) cannot pull in PARENT (PARENT is a
182
+ // SUBSCRIBER of CHILD, never a descendant), so PARENT survives + must wake.
183
+ const res = closeNode('CHILD');
184
+ assert.deepEqual(res.closed, ['CHILD'], 'only CHILD is closed; PARENT spared');
185
+ assert.equal(getNode('CHILD').status, 'canceled', 'CHILD canceled by close');
186
+ const wake = readInboxSince('PARENT').find((e) => e.from === 'CHILD');
187
+ assert.ok(wake, 'node close fanned a child-closed entry to the surviving manager (D-1: previously none)');
188
+ assert.match(wake.label, /closed/i, 'the entry names the closed child');
189
+ // The dormant parent is then revived by the daemon's second pass.
190
+ const before = cyclesOf('PARENT');
191
+ await superviseTick();
192
+ assert.equal(cyclesOf('PARENT'), before + 1, 'dormant PARENT revived on the close entry — no longer hangs');
193
+ });
194
+ });
195
+ // ===========================================================================
196
+ // NEGATIVE — HEALTHY DORMANCY must NOT wake the parent (the correctness crux).
197
+ // ===========================================================================
198
+ test('CRUX: a child dormant while awaiting its OWN live grandchild does NOT wake the parent', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
199
+ await withLivePane('dorm-sub', async (session, window, pane) => {
200
+ dormantParent('PARENT', session, window, pane);
201
+ const dead = deadPaneIn(session, window);
202
+ // CHILD finished its turn (no busy) and its pane is gone, BUT it actively
203
+ // subscribes to a LIVE grandchild — exactly the developer-spawned-reviewer
204
+ // case. This is HEALTHY dormancy, not death.
205
+ createNode(node('CHILD', {
206
+ pane: dead, tmux_session: session, window,
207
+ pi_pid: deadPid(), pi_session_id: 'booted', intent: null, status: 'active',
208
+ }));
209
+ createNode(node('GRANDCHILD', { status: 'active', pi_session_id: 'booted' })); // live, no pane (inline)
210
+ subscribe('PARENT', 'CHILD', true);
211
+ subscribe('CHILD', 'GRANDCHILD', true); // CHILD awaits a LIVE grandchild
212
+ const before = cyclesOf('PARENT');
213
+ await superviseTick();
214
+ // CHILD is RELEASED (revivable), NOT finalized — the daemon must not orphan
215
+ // its in-flight grandchild and must not wake the parent.
216
+ assert.equal(getNode('CHILD').status, 'idle', 'CHILD released (revivable), NOT finalized');
217
+ assert.equal(getNode('CHILD').intent, 'idle-release', 'CHILD routed to idle-release');
218
+ assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — healthy dormancy raises no wake');
219
+ assert.equal(cyclesOf('PARENT'), before, 'dormant PARENT stays asleep (no revive) — no spurious wake');
220
+ });
221
+ });
222
+ test('CRUX: a child dormant on a pending self-wake (no live sub) does NOT wake the parent', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
223
+ await withLivePane('dorm-wake', async (session, window, pane) => {
224
+ dormantParent('PARENT', session, window, pane);
225
+ const dead = deadPaneIn(session, window);
226
+ createNode(node('CHILD', {
227
+ pane: dead, tmux_session: session, window,
228
+ pi_pid: deadPid(), pi_session_id: 'booted', intent: null, status: 'active',
229
+ }));
230
+ subscribe('PARENT', 'CHILD', true);
231
+ // CHILD has NO live subscription, but holds a pending self-wake (far-future
232
+ // so the wakeups pass never fires it this tick). hasPendingSelfWake(CHILD)
233
+ // ⇒ the daemon treats it as STILL WAITING (the stop-guard's boundary), so
234
+ // it RELEASES instead of finalizing. NON-VACUOUS: drop the hasPendingSelfWake
235
+ // guard and CHILD finalizes → PARENT is spuriously woken.
236
+ const future = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
237
+ armWake({ wakeup_id: 'wk-cdw-child', node_id: 'CHILD', owner_id: 'CHILD', fire_at: future, kind: 'bare' });
238
+ const before = cyclesOf('PARENT');
239
+ await superviseTick();
240
+ assert.equal(getNode('CHILD').status, 'idle', 'CHILD with a pending self-wake is RELEASED, not finalized');
241
+ assert.equal(getNode('CHILD').intent, 'idle-release', 'CHILD routed to idle-release (its clock will wake it)');
242
+ assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — a pending clock is healthy dormancy');
243
+ assert.equal(cyclesOf('PARENT'), before, 'dormant PARENT stays asleep — pending self-wake is not death');
244
+ });
245
+ });