@crouton-kit/crouter 0.3.18 → 0.3.20

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 (289) 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/PERSONA.md +1 -0
  12. package/dist/builtin-personas/design/orchestrator.md +2 -1
  13. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  14. package/dist/builtin-personas/developer/orchestrator.md +2 -1
  15. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  16. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  17. package/dist/builtin-personas/general/PERSONA.md +1 -0
  18. package/dist/builtin-personas/general/orchestrator.md +4 -0
  19. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  20. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  21. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  22. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/orchestrator.md +2 -1
  24. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  26. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  27. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  28. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  29. package/dist/builtin-personas/review/PERSONA.md +1 -0
  30. package/dist/builtin-personas/review/orchestrator.md +4 -0
  31. package/dist/builtin-personas/runtime-base.md +6 -3
  32. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  33. package/dist/builtin-personas/spec/orchestrator.md +2 -1
  34. package/dist/builtin-personas/waiting.md +8 -0
  35. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  36. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  37. package/dist/builtin-views/_lib/states.mjs +161 -0
  38. package/dist/builtin-views/canvas/client.mjs +303 -0
  39. package/dist/builtin-views/canvas/view.mjs +576 -0
  40. package/dist/builtin-views/git-pr/client.mjs +440 -0
  41. package/dist/builtin-views/git-pr/view.mjs +675 -0
  42. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  43. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  44. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  45. package/dist/builtin-views/inbox/view.mjs +889 -0
  46. package/dist/builtin-views/linkedin/client.mjs +610 -0
  47. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  48. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  49. package/dist/clients/attach/attach-cmd.js +354 -0
  50. package/dist/clients/attach/chat-view.d.ts +77 -0
  51. package/dist/clients/attach/chat-view.js +450 -0
  52. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  53. package/dist/clients/attach/clipboard-image.js +113 -0
  54. package/dist/clients/attach/config-load.d.ts +31 -0
  55. package/dist/clients/attach/config-load.js +113 -0
  56. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  57. package/dist/clients/attach/extension-dialogs.js +101 -0
  58. package/dist/clients/attach/input-controller.d.ts +54 -0
  59. package/dist/clients/attach/input-controller.js +204 -0
  60. package/dist/clients/attach/slash-commands.d.ts +36 -0
  61. package/dist/clients/attach/slash-commands.js +200 -0
  62. package/dist/clients/attach/view-socket.d.ts +48 -0
  63. package/dist/clients/attach/view-socket.js +126 -0
  64. package/dist/commands/attention.js +3 -3
  65. package/dist/commands/canvas-prune.js +1 -1
  66. package/dist/commands/daemon.js +3 -2
  67. package/dist/commands/human/prompts.js +1 -1
  68. package/dist/commands/human/queue.js +54 -8
  69. package/dist/commands/human/shared.d.ts +2 -0
  70. package/dist/commands/memory/find.d.ts +1 -0
  71. package/dist/commands/memory/find.js +180 -0
  72. package/dist/commands/memory/lint.d.ts +1 -0
  73. package/dist/commands/memory/lint.js +140 -0
  74. package/dist/commands/memory/list.d.ts +1 -0
  75. package/dist/commands/memory/list.js +79 -0
  76. package/dist/commands/memory/read.js +103 -0
  77. package/dist/commands/memory/shared.d.ts +30 -0
  78. package/dist/commands/memory/shared.js +122 -0
  79. package/dist/commands/memory/write.d.ts +1 -0
  80. package/dist/commands/memory/write.js +85 -0
  81. package/dist/commands/memory.d.ts +2 -0
  82. package/dist/commands/memory.js +27 -0
  83. package/dist/commands/node.d.ts +3 -2
  84. package/dist/commands/node.js +610 -61
  85. package/dist/commands/pkg/market-manage.js +1 -1
  86. package/dist/commands/push.js +6 -6
  87. package/dist/commands/revive.js +1 -1
  88. package/dist/commands/skill/author.js +1 -1
  89. package/dist/commands/skill/shared.d.ts +1 -8
  90. package/dist/commands/skill/shared.js +2 -55
  91. package/dist/commands/skill.js +9 -14
  92. package/dist/commands/sys/doctor.js +1 -1
  93. package/dist/commands/sys/update.js +1 -1
  94. package/dist/commands/view-cycle.d.ts +2 -0
  95. package/dist/commands/view-cycle.js +125 -0
  96. package/dist/commands/view-list.d.ts +2 -0
  97. package/dist/commands/view-list.js +66 -0
  98. package/dist/commands/view-new.d.ts +2 -0
  99. package/dist/commands/view-new.js +70 -0
  100. package/dist/commands/view-pick.d.ts +2 -0
  101. package/dist/commands/view-pick.js +119 -0
  102. package/dist/commands/view-run.d.ts +2 -0
  103. package/dist/commands/view-run.js +191 -0
  104. package/dist/commands/view.d.ts +2 -0
  105. package/dist/commands/view.js +29 -0
  106. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  107. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  108. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  109. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  110. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  111. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  112. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  114. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  116. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  117. package/dist/core/__tests__/broker-lifecycle.test.js +87 -0
  118. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  119. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  120. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  121. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  122. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  123. package/dist/core/__tests__/cascade-close.test.js +12 -2
  124. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  125. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  126. package/dist/core/__tests__/context-intro.test.js +76 -62
  127. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  128. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  129. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  130. package/dist/core/__tests__/detach-focus.test.js +206 -0
  131. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  132. package/dist/core/__tests__/draw-style.test.js +258 -0
  133. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  134. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  135. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  136. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  137. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  138. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  139. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  140. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  141. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  142. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  143. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  144. package/dist/core/__tests__/helpers/harness.js +111 -1
  145. package/dist/core/__tests__/home-session.test.js +41 -8
  146. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  147. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  148. package/dist/core/__tests__/kickoff.test.js +37 -3
  149. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  150. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  151. package/dist/core/__tests__/live-mutation.test.js +6 -132
  152. package/dist/core/__tests__/memory.test.js +23 -115
  153. package/dist/core/__tests__/placement-focus.test.js +5 -0
  154. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  155. package/dist/core/__tests__/relaunch.test.js +4 -3
  156. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  157. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  158. package/dist/core/__tests__/spawn-root.test.js +10 -0
  159. package/dist/core/__tests__/spike-harness.test.js +1 -0
  160. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  161. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  162. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  163. package/dist/core/__tests__/wake-origin.test.js +110 -0
  164. package/dist/core/bootstrap.js +1 -1
  165. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  166. package/dist/core/canvas/browse/app.js +24 -2
  167. package/dist/core/canvas/browse/model.d.ts +38 -2
  168. package/dist/core/canvas/browse/model.js +134 -10
  169. package/dist/core/canvas/browse/render.d.ts +6 -12
  170. package/dist/core/canvas/browse/render.js +72 -104
  171. package/dist/core/canvas/canvas.js +16 -23
  172. package/dist/core/canvas/db.js +46 -0
  173. package/dist/core/canvas/index.d.ts +1 -0
  174. package/dist/core/canvas/index.js +1 -0
  175. package/dist/core/canvas/pid.d.ts +4 -0
  176. package/dist/core/canvas/pid.js +23 -0
  177. package/dist/core/canvas/render.d.ts +11 -2
  178. package/dist/core/canvas/render.js +69 -0
  179. package/dist/core/canvas/types.d.ts +91 -3
  180. package/dist/core/canvas/wakeups.d.ts +76 -0
  181. package/dist/core/canvas/wakeups.js +185 -0
  182. package/dist/core/config.js +4 -1
  183. package/dist/core/frontmatter.js +37 -124
  184. package/dist/core/memory-resolver.d.ts +49 -0
  185. package/dist/core/memory-resolver.js +141 -0
  186. package/dist/core/personas/loader.d.ts +7 -0
  187. package/dist/core/personas/loader.js +36 -5
  188. package/dist/core/personas/resolve.js +13 -3
  189. package/dist/core/predicate.d.ts +63 -0
  190. package/dist/core/predicate.js +189 -0
  191. package/dist/core/resolver.js +26 -5
  192. package/dist/core/runtime/bearings.d.ts +53 -12
  193. package/dist/core/runtime/bearings.js +132 -59
  194. package/dist/core/runtime/broker-cli.d.ts +1 -0
  195. package/dist/core/runtime/broker-cli.js +46 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  197. package/dist/core/runtime/broker-protocol.js +153 -0
  198. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  199. package/dist/core/runtime/broker-sdk.js +72 -0
  200. package/dist/core/runtime/broker.d.ts +55 -0
  201. package/dist/core/runtime/broker.js +1128 -0
  202. package/dist/core/runtime/close.js +35 -6
  203. package/dist/core/runtime/host.d.ts +53 -0
  204. package/dist/core/runtime/host.js +186 -0
  205. package/dist/core/runtime/kickoff.d.ts +2 -1
  206. package/dist/core/runtime/kickoff.js +91 -5
  207. package/dist/core/runtime/launch.d.ts +54 -4
  208. package/dist/core/runtime/launch.js +85 -5
  209. package/dist/core/runtime/lifecycle.js +23 -6
  210. package/dist/core/runtime/memory.d.ts +2 -42
  211. package/dist/core/runtime/memory.js +11 -162
  212. package/dist/core/runtime/nodes.d.ts +37 -0
  213. package/dist/core/runtime/nodes.js +60 -1
  214. package/dist/core/runtime/persona.js +21 -11
  215. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  216. package/dist/core/runtime/pi-vendored.js +49 -0
  217. package/dist/core/runtime/placement.d.ts +32 -14
  218. package/dist/core/runtime/placement.js +206 -52
  219. package/dist/core/runtime/promote.d.ts +0 -6
  220. package/dist/core/runtime/promote.js +4 -12
  221. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  222. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  223. package/dist/core/runtime/reset.js +11 -8
  224. package/dist/core/runtime/revive.d.ts +2 -0
  225. package/dist/core/runtime/revive.js +34 -34
  226. package/dist/core/runtime/spawn.d.ts +23 -0
  227. package/dist/core/runtime/spawn.js +77 -23
  228. package/dist/core/runtime/stop-guard.d.ts +1 -1
  229. package/dist/core/runtime/stop-guard.js +6 -1
  230. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  231. package/dist/core/runtime/tmux-chrome.js +1 -1
  232. package/dist/core/runtime/tmux.d.ts +28 -0
  233. package/dist/core/runtime/tmux.js +80 -6
  234. package/dist/core/scope.d.ts +11 -0
  235. package/dist/core/scope.js +39 -0
  236. package/dist/core/spawn.d.ts +5 -0
  237. package/dist/core/spawn.js +20 -2
  238. package/dist/core/substrate/gate.d.ts +13 -0
  239. package/dist/core/substrate/gate.js +21 -0
  240. package/dist/core/substrate/index.d.ts +7 -0
  241. package/dist/core/substrate/index.js +18 -0
  242. package/dist/core/substrate/on-read.d.ts +14 -0
  243. package/dist/core/substrate/on-read.js +292 -0
  244. package/dist/core/substrate/render.d.ts +25 -0
  245. package/dist/core/substrate/render.js +256 -0
  246. package/dist/core/substrate/schema.d.ts +76 -0
  247. package/dist/core/substrate/schema.js +124 -0
  248. package/dist/core/substrate/session-cache.d.ts +30 -0
  249. package/dist/core/substrate/session-cache.js +77 -0
  250. package/dist/core/substrate/subject.d.ts +41 -0
  251. package/dist/core/substrate/subject.js +54 -0
  252. package/dist/core/tui/contract.d.ts +83 -0
  253. package/dist/core/tui/contract.js +8 -0
  254. package/dist/core/tui/draw.d.ts +96 -0
  255. package/dist/core/tui/draw.js +339 -0
  256. package/dist/core/tui/host.d.ts +29 -0
  257. package/dist/core/tui/host.js +379 -0
  258. package/dist/core/tui/loader.d.ts +16 -0
  259. package/dist/core/tui/loader.js +94 -0
  260. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  261. package/dist/core/wake.d.ts +86 -0
  262. package/dist/core/wake.js +308 -0
  263. package/dist/daemon/crtrd.d.ts +29 -4
  264. package/dist/daemon/crtrd.js +662 -46
  265. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  266. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  267. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  268. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  269. package/dist/pi-extensions/canvas-commands.js +10 -0
  270. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  271. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  272. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  273. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  274. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  275. package/dist/pi-extensions/canvas-nav.js +87 -23
  276. package/dist/pi-extensions/canvas-stophook.js +17 -8
  277. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  278. package/dist/pi-extensions/canvas-view.js +75 -0
  279. package/dist/prompts/skill.js +19 -26
  280. package/dist/prompts/view.d.ts +7 -0
  281. package/dist/prompts/view.js +101 -0
  282. package/dist/types.d.ts +4 -0
  283. package/dist/types.js +1 -0
  284. package/package.json +9 -4
  285. package/dist/commands/skill/find.d.ts +0 -4
  286. package/dist/commands/skill/find.js +0 -257
  287. package/dist/commands/skill/read.js +0 -91
  288. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  289. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -0,0 +1,1128 @@
1
+ // broker.ts — the headless node broker (design §4, §5; plan T4).
2
+ //
3
+ // One broker process per `--headless` node. It hosts ONE pi engine IN-PROCESS
4
+ // via the SDK (createAgentSession), is the SOLE writer of the node's session
5
+ // `.jsonl`, listens on `nodeDir(id)/view.sock`, fans the single engine event
6
+ // stream out to N viewers, serializes a single controller's drive commands, and
7
+ // routes blocking extension dialogs. It is the headless analog of pi-in-a-pane:
8
+ // it runs one turn-cycle and, when the engine settles (the stophook calls
9
+ // ctx.shutdown(), or the engine goes idle), disposes the engine and exits 0 —
10
+ // the daemon then routes revival per the intent the bound stophook set.
11
+ //
12
+ // Engine resolution flows through broker-sdk.ts (`loadBrokerEngine`) so the T11
13
+ // `CRTR_BROKER_ENGINE` test seam can swap a fake engine in. The resource loader
14
+ // + model registry are real SDK construction helpers (NOT part of the swappable
15
+ // engine seam — the fake engine receives the real, extension-loaded loader so
16
+ // the real canvas-stophook fires session_start).
17
+ import { createServer } from 'node:net';
18
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { randomUUID } from 'node:crypto';
21
+ import { getAgentDir, initTheme, } from '@earendil-works/pi-coding-agent';
22
+ import { jobDir, nodeDir } from '../canvas/paths.js';
23
+ import { FRONT_DOOR_ENV } from './front-door.js';
24
+ import { piInvocationToSdkConfig } from './launch.js';
25
+ import { assertEngineVersion, loadBrokerEngine } from './broker-sdk.js';
26
+ import { BUILTIN_SLASH_COMMANDS } from './pi-vendored.js';
27
+ import { encodeFrame, FrameDecoder, FrameOverflowError, BROKER_READ_CAPS, } from './broker-protocol.js';
28
+ // ---------------------------------------------------------------------------
29
+ // Tunables (T3 backpressure / T4 dialog anti-deadlock)
30
+ // ---------------------------------------------------------------------------
31
+ /** Per-viewer outbound high-water mark (M1). A viewer whose unflushed backlog
32
+ * exceeds EITHER bound is DROPPED (socket destroyed) rather than allowed to
33
+ * apply indefinite backpressure to the shared engine. Modeled on pi's RPC
34
+ * output-guard: bound the queue, shed the slow viewer. */
35
+ const MAX_QUEUED_FRAMES = 1000;
36
+ const MAX_PENDING_BYTES = 32 * 1024 * 1024; // 32 MiB
37
+ /** F2 (attach typing-lag fix, 2026-06-09): coalesce window for relayed
38
+ * `message_update` frames. pi emits one per content delta and each carries the
39
+ * FULL accumulated assistant message, so a verbatim relay re-serializes (and
40
+ * every viewer re-parses + re-renders) the whole message per token — O(len²)
41
+ * bytes over a long reply, and measured ~3× the viewer's render CPU vs 75 ms
42
+ * coalescing. Only the LATEST pending update matters (each supersedes the
43
+ * last), so the relay keeps one and flushes it on this timer — or immediately,
44
+ * BEFORE any other event type, preserving ordering (an update never arrives
45
+ * after its message_end). */
46
+ const MESSAGE_UPDATE_COALESCE_MS = 75;
47
+ /** Broker-side default dialog timeout (C2 anti-deadlock, T4). When an extension
48
+ * dialog is forwarded to a controller, the broker ALWAYS arms a timeout (this
49
+ * default, or a shorter per-dialog `opts.timeout` if the extension passed one)
50
+ * so a controller that never answers — or detaches and is never replaced — can
51
+ * never hang the agent turn forever. On fire it resolves to the SAFE default
52
+ * (deny for confirm; cancel/undefined for select/input/editor). */
53
+ const DEFAULT_DIALOG_TIMEOUT_MS = 120_000; // 120 s
54
+ // ---------------------------------------------------------------------------
55
+ // M3 (scout mq5thyli): the active engine session, exposed so broker-cli's FATAL
56
+ // handlers (uncaughtException / unhandledRejection / the runBroker reject) can
57
+ // dispose it before process.exit. The bash tool spawns children `detached` (own
58
+ // pgid); only session.dispose() (→ abortBash / agent.abort → killProcessTree)
59
+ // reaps them. The graceful exit path already disposes; without this the crash
60
+ // path would `process.exit(1)` and ORPHAN every in-flight bash subprocess.
61
+ // ---------------------------------------------------------------------------
62
+ let activeSession = null;
63
+ /** Dispose the live engine session if one exists (idempotent). Called by
64
+ * broker-cli's fatal handlers before exit so a crash never orphans detached
65
+ * bash children. No-op before the session is built or after a clean dispose. */
66
+ export function disposeActiveSession() {
67
+ const s = activeSession;
68
+ activeSession = null;
69
+ if (s !== null) {
70
+ try {
71
+ s.dispose();
72
+ }
73
+ catch {
74
+ /* dispose must not block the fatal exit */
75
+ }
76
+ }
77
+ }
78
+ // ---------------------------------------------------------------------------
79
+ // runBroker — the broker process body (plan T4 steps 1–6)
80
+ // ---------------------------------------------------------------------------
81
+ export async function runBroker(nodeId) {
82
+ // Defensive: any `crtr` child the engine/extensions spawn must inherit the
83
+ // front-door recursion guard (runtime/CLAUDE.md fork-bomb invariant). The host
84
+ // (T6) also sets this; we set it again so the broker is self-sufficient.
85
+ process.env[FRONT_DOOR_ENV] = '1';
86
+ // The broker is PANELESS. A `--headless` child spawned by a tmux-resident
87
+ // parent inherits the parent's TMUX_PANE/TMUX in its env (T6 spawns with
88
+ // {...process.env}); left in place, the bound stophook's refresh-yield branch
89
+ // (canvas-stophook.ts) would call reviveInPlace on the PARENT's %pane_id
90
+ // (respawn-pane -k) — hijacking the parent's pane with a fresh pi, killing the
91
+ // parent and orphaning this broker. Strip both so every stophook pane-branch
92
+ // takes its no-pane path (→ ctx.shutdown() → our shutdownHandler → clean exit;
93
+ // the daemon then respawns the node as a broker).
94
+ delete process.env['TMUX_PANE'];
95
+ delete process.env['TMUX'];
96
+ // 1. Version tripwire + load the launch recipe the host serialized.
97
+ const engine = await loadBrokerEngine();
98
+ assertEngineVersion(engine.VERSION);
99
+ // M4 / M6 (scout mq5thyli) — DELIBERATELY SKIPPED on the 0.78.1 pin. Both
100
+ // `configureHttpDispatcher` (M4: proxy support + the undici decompression fix)
101
+ // and `runMigrations` (M6: config/auth migration for a box where the pi CLI
102
+ // never ran) are CLI-only helpers that the pinned SDK does NOT re-export: the
103
+ // package's `exports` map is `.`-only, neither symbol is in `dist/index.d.ts`,
104
+ // a deep import (`.../dist/core/http-dispatcher.js`) is blocked with
105
+ // ERR_PACKAGE_PATH_NOT_EXPORTED, and the only exports-map entries are `.` and
106
+ // `./hooks` (neither carries these symbols). Per the "verify export; skip-with-
107
+ // note if absent" directive we do not vendor or reach around the export wall.
108
+ // Revisit when the SDK pin bumps to a version that re-exports them publicly.
109
+ const dir = nodeDir(nodeId);
110
+ const launchFile = join(dir, 'broker-launch.json');
111
+ const inv = JSON.parse(readFileSync(launchFile, 'utf8'));
112
+ const cfg = piInvocationToSdkConfig(inv); // also merges inv.env
113
+ // Fork-on-spawn is the runtime.fork() path (createAgentSessionRuntime), not
114
+ // wired headless in Phase 3. Fail loud rather than silently mis-resume.
115
+ if (cfg.forkFrom !== undefined && cfg.forkFrom !== '') {
116
+ throw new Error(`[broker] --fork is not supported by the headless broker in Phase 3 ` +
117
+ `(forkFrom=${cfg.forkFrom}); the SDK fork path is createAgentSessionRuntime.fork()`);
118
+ }
119
+ // 2–4. Build the engine session via the SERVICES path (C3) — see
120
+ // buildBrokerSession below. Register it so the FATAL exit path (M3) can
121
+ // dispose it and reap detached bash children.
122
+ // `session` + `services` are MUTABLE holders (T3 session-rebind): when the
123
+ // controller drives new_session/switch_session/fork, the AgentSessionRuntime
124
+ // tears down the old session and builds the next one, then runs our rebind
125
+ // callback which reassigns these. Every closure below (buildSnapshot,
126
+ // driveEngine, handleFrame, disposeAndExit) reads through them, so they all
127
+ // follow the live session automatically. `runtime` is undefined for an engine
128
+ // that does not expose the replacement API (the fake-engine fixture).
129
+ // eslint-disable-next-line prefer-const
130
+ let { session, services, resuming, runtime } = await buildBrokerSession(engine, cfg);
131
+ activeSession = session;
132
+ // Initialize pi's process-global theme. The pi binary does this at boot
133
+ // (main.js: initTheme(settingsManager.getTheme(), appMode === 'interactive')),
134
+ // but the SDK convenience path the broker uses does NOT. Without it, ANY
135
+ // extension hook that reads `ctx.ui.theme` throws "Theme not initialized" —
136
+ // and the extension runner SILENTLY swallows that throw and discards the
137
+ // hook's returned payload. The visible symptom was the OAuth billing adapter's
138
+ // before_provider_request handler throwing, so its injected Claude-Code billing
139
+ // header was dropped and subscription turns 400'd ("draws from extra usage").
140
+ // headless ⇒ no file watcher (false). Idempotent global; call once at boot.
141
+ try {
142
+ initTheme(services.settingsManager.getTheme(), false);
143
+ }
144
+ catch (err) {
145
+ process.stderr.write(`[broker] WARNING: initTheme failed: ${String(err)}\n`);
146
+ }
147
+ // N3: the node's display name is re-applied inside rebindSession (below) so it
148
+ // survives a session replacement (new_session/switch_session/fork), not only at
149
+ // boot.
150
+ // -------------------------------------------------------------------------
151
+ // Socket fan-out + controller arbitration state
152
+ // -------------------------------------------------------------------------
153
+ const sockPath = join(dir, 'view.sock');
154
+ const attachPath = join(jobDir(nodeId), 'attach.json');
155
+ const clients = new Set();
156
+ const pendingDialogs = new Map();
157
+ let controllerId = null;
158
+ let disposed = false;
159
+ let server;
160
+ const controllerClient = () => {
161
+ if (controllerId === null)
162
+ return null;
163
+ for (const c of clients)
164
+ if (c.id === controllerId)
165
+ return c;
166
+ return null;
167
+ };
168
+ // Persist viewer presence to job/attach.json on every viewer state change
169
+ // (hello accepted, client drop/shed, control handoff) so out-of-process
170
+ // readers (the GRAPH view's attached-row tint) can see whether a human is
171
+ // watching this paneless node. Plain writeFileSync, matching telemetry.json's
172
+ // convention; best-effort — presence writing must never crash the broker.
173
+ // disposeAndExit unlinks the file, so a clean exit never leaves a stale claim
174
+ // (readers additionally trust it only while the node is 'active', fencing off
175
+ // a crash-orphaned file).
176
+ const persistAttachState = () => {
177
+ try {
178
+ let viewers = 0;
179
+ for (const c of clients)
180
+ if (c.helloed)
181
+ viewers += 1;
182
+ const dirPath = jobDir(nodeId);
183
+ if (!existsSync(dirPath))
184
+ mkdirSync(dirPath, { recursive: true });
185
+ writeFileSync(attachPath, JSON.stringify({ viewers, controller_id: controllerId, updated: new Date().toISOString() }, null, 2), 'utf8');
186
+ }
187
+ catch {
188
+ /* presence is best-effort; never crash the broker */
189
+ }
190
+ };
191
+ // The host redirects the broker's stdout+stderr to job/broker.log (host.ts), so
192
+ // stderr is the broker's durable log sink (boot/crash diagnostics already use it).
193
+ const logBroker = (msg) => {
194
+ try {
195
+ process.stderr.write(`[broker] ${msg}\n`);
196
+ }
197
+ catch {
198
+ /* best effort */
199
+ }
200
+ };
201
+ // Free control if the departing/dropped client held it (shared by drop +
202
+ // dropSlowClient). controllerId can outlive the socket until 'close' fires, so
203
+ // releasing here keeps arbitration correct the instant a controller is shed.
204
+ const releaseControlIfHeldBy = (client) => {
205
+ if (client.id !== '' && client.id === controllerId) {
206
+ controllerId = null;
207
+ broadcastControlChanged();
208
+ }
209
+ };
210
+ // Shed a client — destroy the socket + remove it + release its control — used by
211
+ // both the M1 backpressure drop and the G7 frame-overflow drop. 'close' (→ drop)
212
+ // follows the destroy; releasing control here makes the shed immediate so a
213
+ // misbehaving controller can't keep arbitration pinned until 'close' fires.
214
+ const dropClient = (client, reason) => {
215
+ if (!clients.has(client))
216
+ return; // already gone
217
+ logBroker(`dropping viewer ${client.id || '(pre-hello)'} — ${reason}`);
218
+ clients.delete(client);
219
+ persistAttachState(); // a viewer was shed
220
+ releaseControlIfHeldBy(client);
221
+ try {
222
+ client.socket.destroy();
223
+ }
224
+ catch {
225
+ /* ignore */
226
+ }
227
+ };
228
+ // `bypassHwm` (F1): the catch-up `welcome` carries the full message history and
229
+ // can legitimately be tens of MiB — far larger than a steady-state live frame.
230
+ // It is still ACCOUNTED (so a genuinely stalled client that also floods on live
231
+ // events is still shed by the NEXT frame's check), but it must never be the
232
+ // REASON a healthy client is dropped before it has had a chance to drain it.
233
+ const sendFrame = (client, frame, bypassHwm = false) => {
234
+ let data;
235
+ try {
236
+ data = encodeFrame(frame);
237
+ }
238
+ catch {
239
+ return; // an unserializable frame must never crash the broker
240
+ }
241
+ const bytes = Buffer.byteLength(data);
242
+ // M1 high-water mark: a viewer that has fallen too far behind on draining its
243
+ // socket is shed rather than allowed to grow the broker's memory unbounded.
244
+ if (!bypassHwm &&
245
+ (client.queuedFrames + 1 > MAX_QUEUED_FRAMES ||
246
+ client.pendingBytes + bytes > MAX_PENDING_BYTES)) {
247
+ dropClient(client, `backpressure high-water mark exceeded (queued=${client.queuedFrames}, pending=${client.pendingBytes}B)`);
248
+ return;
249
+ }
250
+ client.queuedFrames += 1;
251
+ client.pendingBytes += bytes;
252
+ try {
253
+ // The per-write completion callback fires when THIS chunk is flushed to the
254
+ // OS — a finer-grained drain signal than a socket-wide 'drain' event, and it
255
+ // keeps the accounting exact. write()'s boolean return is the coarse
256
+ // "buffer full" hint; the per-chunk callback is what we account on.
257
+ client.socket.write(data, () => {
258
+ client.queuedFrames -= 1;
259
+ client.pendingBytes -= bytes;
260
+ });
261
+ }
262
+ catch {
263
+ // Dead socket: undo this frame's accounting; 'close' → drop() cleans the rest.
264
+ client.queuedFrames -= 1;
265
+ client.pendingBytes -= bytes;
266
+ }
267
+ };
268
+ const broadcast = (frame) => {
269
+ for (const c of clients)
270
+ if (c.helloed)
271
+ sendFrame(c, frame);
272
+ };
273
+ // ---------------------------------------------------------------------------
274
+ // F2: message_update coalescing. `relayEvent` is the single entry point for
275
+ // engine events headed to viewers: a message_update is held (latest wins) and
276
+ // flushed on a short timer; ANY other event flushes the held update first,
277
+ // synchronously, so cross-type ordering is exactly the engine's. The timer is
278
+ // unref'd — it must never hold the broker process open past dispose.
279
+ // ---------------------------------------------------------------------------
280
+ let pendingUpdate = null;
281
+ let pendingUpdateTimer = null;
282
+ const flushPendingUpdate = () => {
283
+ if (pendingUpdateTimer !== null) {
284
+ clearTimeout(pendingUpdateTimer);
285
+ pendingUpdateTimer = null;
286
+ }
287
+ if (pendingUpdate !== null) {
288
+ const frame = pendingUpdate;
289
+ pendingUpdate = null;
290
+ broadcast(frame);
291
+ }
292
+ };
293
+ const relayEvent = (event) => {
294
+ if (event.type === 'message_update') {
295
+ pendingUpdate = event; // latest full-message update supersedes the held one
296
+ if (pendingUpdateTimer === null) {
297
+ pendingUpdateTimer = setTimeout(flushPendingUpdate, MESSAGE_UPDATE_COALESCE_MS);
298
+ if (typeof pendingUpdateTimer.unref === 'function')
299
+ pendingUpdateTimer.unref();
300
+ }
301
+ return;
302
+ }
303
+ // Any non-update event: flush the held update FIRST so a viewer never sees
304
+ // (e.g.) message_end before the update it supersedes — then relay verbatim.
305
+ flushPendingUpdate();
306
+ broadcast(event);
307
+ };
308
+ const broadcastControlChanged = () => {
309
+ persistAttachState(); // every control change is a viewer-state change
310
+ broadcast({ type: 'control_changed', controller_id: controllerId });
311
+ };
312
+ const buildSnapshot = () => ({
313
+ messages: session.messages,
314
+ stats: session.getSessionStats(),
315
+ state: {
316
+ sessionId: session.sessionId,
317
+ sessionFile: session.sessionFile,
318
+ model: session.model?.id,
319
+ isStreaming: session.isStreaming,
320
+ // §1.3.2 — pure getter reads mirroring RPC get_state (viewer footer parity).
321
+ thinkingLevel: session.thinkingLevel,
322
+ steeringMode: session.steeringMode,
323
+ followUpMode: session.followUpMode,
324
+ sessionName: session.sessionName,
325
+ autoCompactionEnabled: session.autoCompactionEnabled,
326
+ pendingMessageCount: session.pendingMessageCount,
327
+ },
328
+ });
329
+ // Send a client its catch-up snapshot. welcome.pending_dialog carries a single
330
+ // still-in-flight dialog to a controller attaching mid-dialog (T4); only the
331
+ // controller can answer one, so observers get null. The pendingDialogs map is
332
+ // insertion-ordered — the first entry is the canonical one carried here; any
333
+ // extras are re-routed explicitly by the caller (rare: concurrent dialogs).
334
+ const sendWelcome = (client) => {
335
+ const first = client.role === 'controller' ? pendingDialogs.values().next().value : undefined;
336
+ sendFrame(client, {
337
+ type: 'welcome',
338
+ snapshot: buildSnapshot(),
339
+ role: client.role,
340
+ controller_id: controllerId,
341
+ pending_dialog: first !== undefined ? first.request : null,
342
+ }, true);
343
+ };
344
+ // T4 re-route on become-controller: a dialog raised while a prior controller was
345
+ // attached stays pending after that controller detaches (it is NOT cancelled —
346
+ // see makeBrokerUiContext / the M2 keep-pending fix), so whoever takes control
347
+ // next must be handed it to answer.
348
+ const reroutePendingDialogsTo = (client) => {
349
+ for (const d of pendingDialogs.values())
350
+ sendFrame(client, d.request);
351
+ };
352
+ // After a session-replacing op (new_session/switch_session/fork) the engine's
353
+ // entire message history changed, so every attached viewer must rebuild from a
354
+ // fresh snapshot of the NEW session.
355
+ const reWelcomeAll = () => {
356
+ for (const c of clients)
357
+ if (c.helloed)
358
+ sendWelcome(c);
359
+ };
360
+ // -------------------------------------------------------------------------
361
+ // The single exit helper (plan §0): dispose the engine, close + unlink the
362
+ // socket, exit 0. Idempotent — every trigger converges here.
363
+ // -------------------------------------------------------------------------
364
+ const disposeAndExit = (_reason) => {
365
+ if (disposed)
366
+ return;
367
+ disposed = true;
368
+ activeSession = null; // graceful path owns dispose; keep the fatal hook a no-op
369
+ try {
370
+ session.dispose();
371
+ }
372
+ catch {
373
+ /* dispose must not block exit */
374
+ }
375
+ try {
376
+ server?.close();
377
+ }
378
+ catch {
379
+ /* ignore */
380
+ }
381
+ try {
382
+ if (existsSync(sockPath))
383
+ unlinkSync(sockPath);
384
+ }
385
+ catch {
386
+ /* ignore */
387
+ }
388
+ try {
389
+ // A clean exit never leaves a stale presence claim behind.
390
+ if (existsSync(attachPath))
391
+ unlinkSync(attachPath);
392
+ }
393
+ catch {
394
+ /* ignore */
395
+ }
396
+ process.exit(0);
397
+ };
398
+ // -------------------------------------------------------------------------
399
+ // Extension-dialog routing (C2). makeBrokerUiContext owns the dialogPromise;
400
+ // here we just give it the three broker-side hooks it needs: who the current
401
+ // controller is (null = zero viewers → noOp fallback), how to forward a dialog
402
+ // to that controller, and the pending-dialog registry the controller answers
403
+ // through. The zero-viewer path NEVER hangs and NEVER waits on a per-dialog
404
+ // timeout (design §5.4's timeout premise is false — see makeBrokerUiContext).
405
+ // -------------------------------------------------------------------------
406
+ const uiContext = makeBrokerUiContext({
407
+ controller: controllerClient,
408
+ forward: (client, request) => sendFrame(client, request),
409
+ pending: pendingDialogs,
410
+ });
411
+ // -------------------------------------------------------------------------
412
+ // 3+4 (plan): bind the FULL canvas extensions (mode 'print') AND fan the single
413
+ // engine event stream to all clients, VERBATIM — both wrapped in one
414
+ // `rebindSession` so a session-replacing op (new_session/switch_session/fork)
415
+ // can re-run them against the NEW session. This mirrors pi's rpc-mode
416
+ // rebindSession (rpc-mode.js:227): the AgentSessionRuntime tears down the old
417
+ // session, builds the next, then calls this back; we re-bind extensions and
418
+ // re-subscribe so the canvas hooks + the fan-out follow the live session.
419
+ //
420
+ // bindExtensions fires session_start → the stophook records pi_session_id/file
421
+ // + recordPid(pid); the shutdownHandler is the precise broker-exit trigger (the
422
+ // stophook calls ctx.shutdown() only in its done / idle-release / refresh
423
+ // branches, NOT on reprompt). The engine-driven exit is deliberately NOT
424
+ // inferred from raw agent_end in the relay: the bound stophook is the SOLE
425
+ // authority on when the node stops — it exits synchronously at
426
+ // agent-session.js:266, BEFORE this subscriber runs at :268, with intent
427
+ // already persisted; in every OTHER branch it STAYS ALIVE. An agent_end idle
428
+ // heuristic would override those stay-alive decisions, so there is no such
429
+ // backstop; shutdownHandler + the inbound `shutdown` frame are the only exits.
430
+ //
431
+ // m7: the subscriber body is wrapped in try/catch — a synchronous throw out of
432
+ // a subscriber otherwise propagates as a run failure that would crash the broker.
433
+ // -------------------------------------------------------------------------
434
+ let unsubscribe;
435
+ const rebindSession = async () => {
436
+ if (runtime !== undefined) {
437
+ session = runtime.session; // the runtime replaced it; follow the new one
438
+ services = runtime.services;
439
+ }
440
+ activeSession = session; // keep the fatal-exit hook pointed at the live session
441
+ await session.bindExtensions({
442
+ uiContext,
443
+ mode: 'print',
444
+ shutdownHandler: () => disposeAndExit('shutdown-hook'),
445
+ });
446
+ if (cfg.editorName !== undefined && cfg.editorName !== '') {
447
+ try {
448
+ session.setSessionName(cfg.editorName); // N3: re-apply across session replacement
449
+ }
450
+ catch {
451
+ /* non-fatal: naming is cosmetic */
452
+ }
453
+ }
454
+ unsubscribe?.();
455
+ // F2: a session replacement abandons the old session's stream — drop (don't
456
+ // flush) a held update from it; its message history is superseded by the
457
+ // reWelcomeAll() snapshot of the NEW session.
458
+ if (pendingUpdateTimer !== null) {
459
+ clearTimeout(pendingUpdateTimer);
460
+ pendingUpdateTimer = null;
461
+ }
462
+ pendingUpdate = null;
463
+ unsubscribe = session.subscribe((event) => {
464
+ try {
465
+ relayEvent(event);
466
+ }
467
+ catch (err) {
468
+ logBroker(`event relay threw: ${String(err)}`);
469
+ }
470
+ });
471
+ };
472
+ // Register the rebind BEFORE the first bind so a replacement triggered during
473
+ // startup is honored; then do the initial bind + subscribe.
474
+ runtime?.setRebindSession(async () => {
475
+ await rebindSession();
476
+ });
477
+ await rebindSession();
478
+ // -------------------------------------------------------------------------
479
+ // Drive the engine on behalf of the single controller.
480
+ // -------------------------------------------------------------------------
481
+ const driveEngine = (client, frame) => {
482
+ const relayError = (err) => sendFrame(client, { type: 'error', code: 'engine_error', message: String(err) });
483
+ switch (frame.type) {
484
+ case 'prompt': {
485
+ // C1: forward any pasted/attached images (frame.images) to the engine —
486
+ // they ride as a field on PromptOptions (pi: `prompt(text, { images })`).
487
+ // m-B (streaming-safe): the controller routes prompt-vs-steer off a
488
+ // possibly-STALE `isStreaming` snapshot, so a `prompt` frame can arrive
489
+ // mid-stream; PromptOptions.streamingBehavior is "Required if streaming"
490
+ // (prompt() throws without it). Make the broker authoritative — when the
491
+ // LIVE session is streaming, supply it so the client's routing is a HINT,
492
+ // not a correctness requirement. "steer" mirrors pi interactive-mode's
493
+ // own Enter-while-streaming submit (interactive-mode.js:
494
+ // `prompt(text, { streamingBehavior: "steer" })`).
495
+ const options = {};
496
+ if (frame.images !== undefined)
497
+ options.images = frame.images;
498
+ if (session.isStreaming)
499
+ options.streamingBehavior = 'steer';
500
+ void session.prompt(frame.text, options).catch(relayError);
501
+ break;
502
+ }
503
+ case 'steer':
504
+ // C1: pi's steer() takes images as a POSITIONAL 2nd arg (`steer(text, images?)`).
505
+ void session.steer(frame.text, frame.images).catch(relayError);
506
+ break;
507
+ case 'follow_up':
508
+ // C1: pi's followUp() takes images as a POSITIONAL 2nd arg (`followUp(text, images?)`).
509
+ void session.followUp(frame.text, frame.images).catch(relayError);
510
+ break;
511
+ case 'abort':
512
+ void session.abort().catch(relayError);
513
+ break;
514
+ }
515
+ };
516
+ // -------------------------------------------------------------------------
517
+ // Command-op helpers (T3, §2.3). The controller guard is hoisted here (it was
518
+ // inlined twice) and reused by all controller-only ops; the ack/error replies
519
+ // and a few resolvers keep the per-op cases one-liners.
520
+ // -------------------------------------------------------------------------
521
+ /** Reject a non-controller for a controller-only op. Returns true when rejected
522
+ * (the caller should `break`). */
523
+ const notController = (client, what) => {
524
+ if (client.id === controllerId)
525
+ return false;
526
+ sendFrame(client, {
527
+ type: 'error',
528
+ code: 'not_controller',
529
+ message: `only the controlling client may ${what}`,
530
+ });
531
+ return true;
532
+ };
533
+ const ackTo = (client, op, ok = true, detail) => sendFrame(client, { type: 'ack', for: op, ok, ...(detail !== undefined ? { detail } : {}) });
534
+ const engineErrorTo = (client) => (err) => sendFrame(client, { type: 'error', code: 'engine_error', message: String(err) });
535
+ /** Resolve a `provider/id` model spec against the LIVE services registry (which
536
+ * carries any extension-registered providers). Pure: returns undefined on a
537
+ * malformed spec or an unknown model. */
538
+ const findModelSpec = (spec) => {
539
+ const slash = spec.indexOf('/');
540
+ if (slash <= 0)
541
+ return undefined;
542
+ return services.modelRegistry.find(spec.slice(0, slash), spec.slice(slash + 1));
543
+ };
544
+ /** The merged command list for `get_commands` (C6/M9): the engine's registered
545
+ * extension + skill commands and file-based prompt templates, MERGED with the
546
+ * vendored BUILTIN_SLASH_COMMANDS (RPC omits builtins). Returned to the viewer
547
+ * as JSON in `ack.detail` (see the get_commands case). */
548
+ const buildCommandList = () => {
549
+ const out = [];
550
+ for (const b of BUILTIN_SLASH_COMMANDS) {
551
+ out.push({ name: b.name, description: b.description, source: 'builtin' });
552
+ }
553
+ try {
554
+ // Skill commands surface here too — pi registers each skill as a command.
555
+ for (const c of session.extensionRunner.getRegisteredCommands()) {
556
+ out.push({ name: c.invocationName, description: c.description ?? '', source: 'command' });
557
+ }
558
+ }
559
+ catch {
560
+ /* an engine without a live extensionRunner (e.g. the fake) — builtins only */
561
+ }
562
+ try {
563
+ for (const t of session.promptTemplates) {
564
+ out.push({ name: t.name, description: t.description, source: 'template' });
565
+ }
566
+ }
567
+ catch {
568
+ /* ignore */
569
+ }
570
+ return out;
571
+ };
572
+ /** Run a session-replacing op (new_session/switch_session/fork). The runtime
573
+ * rebinds extensions + re-subscribes via setRebindSession before it resolves,
574
+ * so on success we just re-snapshot every viewer onto the new session. */
575
+ const runReplacement = (client, op, run) => {
576
+ if (runtime === undefined) {
577
+ sendFrame(client, {
578
+ type: 'error',
579
+ code: 'engine_error',
580
+ message: 'session replacement unsupported at this engine pin',
581
+ });
582
+ return;
583
+ }
584
+ void run(runtime)
585
+ .then((r) => {
586
+ if (!r.cancelled)
587
+ reWelcomeAll();
588
+ ackTo(client, op, !r.cancelled, r.cancelled ? 'cancelled by extension' : undefined);
589
+ })
590
+ .catch(engineErrorTo(client));
591
+ };
592
+ const handleFrame = (client, frame) => {
593
+ switch (frame.type) {
594
+ case 'hello': {
595
+ client.id = frame.client_id;
596
+ client.helloed = true;
597
+ // First-attach-wins controller (§5.3): admit as controller iff one was
598
+ // requested and none is currently held; otherwise read-only observer.
599
+ if (frame.role === 'controller' && controllerId === null) {
600
+ client.role = 'controller';
601
+ controllerId = client.id;
602
+ }
603
+ else {
604
+ client.role = 'observer';
605
+ }
606
+ sendWelcome(client);
607
+ persistAttachState(); // a helloed viewer arrived
608
+ if (client.role === 'controller') {
609
+ // welcome carried the FIRST pending dialog (T4); forward any extras so a
610
+ // controller attaching mid-dialog can answer every in-flight dialog.
611
+ const pend = [...pendingDialogs.values()];
612
+ for (let i = 1; i < pend.length; i++)
613
+ sendFrame(client, pend[i].request);
614
+ broadcastControlChanged();
615
+ }
616
+ break;
617
+ }
618
+ case 'prompt':
619
+ case 'steer':
620
+ case 'follow_up':
621
+ case 'abort': {
622
+ if (notController(client, 'drive the engine'))
623
+ break;
624
+ driveEngine(client, frame);
625
+ break;
626
+ }
627
+ case 'extension_ui_response': {
628
+ if (notController(client, 'answer dialogs'))
629
+ break;
630
+ pendingDialogs.get(frame.id)?.resolve(frame);
631
+ break;
632
+ }
633
+ case 'request_control': {
634
+ if (controllerId === null) {
635
+ controllerId = client.id;
636
+ client.role = 'controller';
637
+ broadcastControlChanged();
638
+ reroutePendingDialogsTo(client); // T4: hand the new controller pending dialogs
639
+ }
640
+ else {
641
+ sendFrame(client, {
642
+ type: 'error',
643
+ code: 'control_held',
644
+ message: 'another client holds control',
645
+ });
646
+ }
647
+ break;
648
+ }
649
+ case 'release_control': {
650
+ if (client.id === controllerId) {
651
+ controllerId = null;
652
+ client.role = 'observer';
653
+ // M2 (T4): do NOT cancel in-flight dialogs on release — keep them pending
654
+ // under the broker-side default timeout so a brief release/reattach (or a
655
+ // handoff to another observer) never loses an answerable dialog.
656
+ broadcastControlChanged();
657
+ }
658
+ break;
659
+ }
660
+ // --- extended engine-command ops (T3, §1.2 floor set) ------------------
661
+ case 'set_model': {
662
+ if (notController(client, 'set the model'))
663
+ break;
664
+ let model;
665
+ try {
666
+ model = findModelSpec(frame.model);
667
+ }
668
+ catch (err) {
669
+ // N2: registry.find should never throw on the real SDK, but a degenerate
670
+ // engine must still get a reply rather than a silently-dropped frame.
671
+ engineErrorTo(client)(err);
672
+ break;
673
+ }
674
+ if (model === undefined) {
675
+ sendFrame(client, {
676
+ type: 'error',
677
+ code: 'engine_error',
678
+ message: `model '${frame.model}' not found in the registry`,
679
+ });
680
+ break;
681
+ }
682
+ void session.setModel(model).then(() => ackTo(client, 'set_model')).catch(engineErrorTo(client));
683
+ break;
684
+ }
685
+ case 'cycle_model': {
686
+ if (notController(client, 'cycle the model'))
687
+ break;
688
+ void session.cycleModel().then(() => ackTo(client, 'cycle_model')).catch(engineErrorTo(client));
689
+ break;
690
+ }
691
+ case 'set_thinking_level': {
692
+ if (notController(client, 'set the thinking level'))
693
+ break;
694
+ try {
695
+ session.setThinkingLevel(frame.level);
696
+ ackTo(client, 'set_thinking_level');
697
+ }
698
+ catch (err) {
699
+ engineErrorTo(client)(err);
700
+ }
701
+ break;
702
+ }
703
+ case 'set_auto_retry': {
704
+ if (notController(client, 'set auto-retry'))
705
+ break;
706
+ try {
707
+ session.setAutoRetryEnabled(frame.enabled);
708
+ ackTo(client, 'set_auto_retry');
709
+ }
710
+ catch (err) {
711
+ engineErrorTo(client)(err);
712
+ }
713
+ break;
714
+ }
715
+ case 'set_auto_compaction': {
716
+ if (notController(client, 'set auto-compaction'))
717
+ break;
718
+ try {
719
+ session.setAutoCompactionEnabled(frame.enabled);
720
+ ackTo(client, 'set_auto_compaction');
721
+ }
722
+ catch (err) {
723
+ engineErrorTo(client)(err);
724
+ }
725
+ break;
726
+ }
727
+ case 'compact': {
728
+ if (notController(client, 'compact the session'))
729
+ break;
730
+ void session
731
+ .compact(frame.instructions)
732
+ .then(() => ackTo(client, 'compact'))
733
+ .catch(engineErrorTo(client));
734
+ break;
735
+ }
736
+ case 'set_session_name': {
737
+ if (notController(client, 'rename the session'))
738
+ break;
739
+ try {
740
+ session.setSessionName(frame.name);
741
+ ackTo(client, 'set_session_name');
742
+ }
743
+ catch (err) {
744
+ engineErrorTo(client)(err);
745
+ }
746
+ break;
747
+ }
748
+ case 'get_commands': {
749
+ if (notController(client, 'list commands'))
750
+ break;
751
+ // The merged command list rides in ack.detail as JSON (the foundation's
752
+ // AckFrame.detail field) — the viewer (T6) JSON.parses it when for ===
753
+ // 'get_commands'. Keeps every command op a uniform ack reply.
754
+ try {
755
+ ackTo(client, 'get_commands', true, JSON.stringify(buildCommandList()));
756
+ }
757
+ catch (err) {
758
+ engineErrorTo(client)(err);
759
+ }
760
+ break;
761
+ }
762
+ case 'navigate_tree': {
763
+ if (notController(client, 'navigate the session tree'))
764
+ break;
765
+ void session
766
+ .navigateTree(frame.targetId, frame.options)
767
+ .then((r) => ackTo(client, 'navigate_tree', !r.cancelled))
768
+ .catch(engineErrorTo(client));
769
+ break;
770
+ }
771
+ case 'reload': {
772
+ if (notController(client, 'reload'))
773
+ break;
774
+ void session.reload().then(() => ackTo(client, 'reload')).catch(engineErrorTo(client));
775
+ break;
776
+ }
777
+ case 'export': {
778
+ if (notController(client, 'export the session'))
779
+ break;
780
+ if (frame.format === 'jsonl') {
781
+ try {
782
+ session.exportToJsonl(frame.path);
783
+ ackTo(client, 'export');
784
+ }
785
+ catch (err) {
786
+ engineErrorTo(client)(err);
787
+ }
788
+ }
789
+ else {
790
+ void session
791
+ .exportToHtml(frame.path)
792
+ .then(() => ackTo(client, 'export'))
793
+ .catch(engineErrorTo(client));
794
+ }
795
+ break;
796
+ }
797
+ case 'new_session': {
798
+ if (notController(client, 'start a new session'))
799
+ break;
800
+ runReplacement(client, 'new_session', (rt) => rt.newSession());
801
+ break;
802
+ }
803
+ case 'switch_session': {
804
+ if (notController(client, 'switch sessions'))
805
+ break;
806
+ runReplacement(client, 'switch_session', (rt) => rt.switchSession(frame.path));
807
+ break;
808
+ }
809
+ case 'fork': {
810
+ if (notController(client, 'fork the session'))
811
+ break;
812
+ runReplacement(client, 'fork', (rt) => rt.fork(frame.entryId));
813
+ break;
814
+ }
815
+ case 'bye':
816
+ client.socket.end();
817
+ break;
818
+ case 'shutdown':
819
+ disposeAndExit('shutdown-frame');
820
+ break;
821
+ }
822
+ };
823
+ // -------------------------------------------------------------------------
824
+ // 4 (plan): the socket listener — unlink any stale socket first.
825
+ // -------------------------------------------------------------------------
826
+ try {
827
+ if (existsSync(sockPath))
828
+ unlinkSync(sockPath);
829
+ }
830
+ catch {
831
+ /* a fresh listen below will surface a real problem */
832
+ }
833
+ server = createServer((socket) => {
834
+ const client = {
835
+ id: '',
836
+ role: 'observer',
837
+ socket,
838
+ decoder: new FrameDecoder(BROKER_READ_CAPS),
839
+ helloed: false,
840
+ pendingBytes: 0,
841
+ queuedFrames: 0,
842
+ };
843
+ clients.add(client);
844
+ socket.on('data', (chunk) => {
845
+ let frames;
846
+ try {
847
+ frames = client.decoder.push(chunk);
848
+ }
849
+ catch (err) {
850
+ // G7: a client frame over the bounded decoder caps (FrameOverflowError) is
851
+ // cap-and-dropped — best-effort error frame, then destroy the peer. The
852
+ // broker survives. The try/catch is around push() itself (not just the
853
+ // per-frame loop) because push() throws BEFORE returning any frames.
854
+ if (err instanceof FrameOverflowError) {
855
+ sendFrame(client, { type: 'error', code: 'frame_overflow', message: err.message });
856
+ dropClient(client, `frame overflow: ${err.message}`);
857
+ }
858
+ else {
859
+ dropClient(client, `decoder error: ${String(err)}`);
860
+ }
861
+ return;
862
+ }
863
+ for (const raw of frames) {
864
+ try {
865
+ handleFrame(client, raw);
866
+ }
867
+ catch {
868
+ /* one bad frame never crashes the broker */
869
+ }
870
+ }
871
+ });
872
+ const drop = () => {
873
+ clients.delete(client);
874
+ persistAttachState(); // a viewer disconnected
875
+ // M2 (T4): controller detach frees control but does NOT cancel in-flight
876
+ // dialogs — they stay pending under the broker-side default timeout so a
877
+ // brief detach/reattach (or a handoff to another observer who takes control)
878
+ // never loses an answerable dialog. Only the timeout or a new controller's
879
+ // answer resolves one.
880
+ releaseControlIfHeldBy(client);
881
+ };
882
+ socket.on('close', drop);
883
+ socket.on('error', () => {
884
+ /* close follows; drop there */
885
+ });
886
+ });
887
+ server.on('error', (err) => {
888
+ process.stderr.write(`[broker] socket server error: ${String(err)}\n`);
889
+ });
890
+ server.listen(sockPath);
891
+ // Initialize presence to the truthful zero state at boot — this also
892
+ // overwrites a stale attach.json a crashed prior incarnation left behind.
893
+ persistAttachState();
894
+ // OS-signal teardown (daemon kill / T6 fallback) → same clean exit path.
895
+ process.on('SIGTERM', () => disposeAndExit('SIGTERM'));
896
+ process.on('SIGINT', () => disposeAndExit('SIGINT'));
897
+ // -------------------------------------------------------------------------
898
+ // 5 (plan): a fresh start delivers the kickoff prompt. A resume needs none —
899
+ // the bound canvas-inbox-watcher drains the pending inbox entry and feeds it.
900
+ // -------------------------------------------------------------------------
901
+ if (!resuming && cfg.firstPrompt !== undefined && cfg.firstPrompt !== '') {
902
+ void session.prompt(cfg.firstPrompt).catch((err) => {
903
+ process.stderr.write(`[broker] first prompt failed: ${String(err)}\n`);
904
+ });
905
+ }
906
+ }
907
+ // ---------------------------------------------------------------------------
908
+ // buildBrokerSession (plan T4 steps 2–4) — turn the launch recipe into a live
909
+ // engine session via the pi SDK SERVICES path. Exported so the C3/C4 real-SDK
910
+ // regression tests can drive the EXACT production wiring (not the mock).
911
+ // ---------------------------------------------------------------------------
912
+ export async function buildBrokerSession(engine, cfg) {
913
+ // Fork-on-spawn is the runtime.fork() path (createAgentSessionRuntime), not
914
+ // wired headless in Phase 3. Fail loud rather than silently mis-resume.
915
+ if (cfg.forkFrom !== undefined && cfg.forkFrom !== '') {
916
+ throw new Error(`[broker] --fork is not supported by the headless broker in Phase 3 ` +
917
+ `(forkFrom=${cfg.forkFrom}); the SDK fork path is createAgentSessionRuntime.fork()`);
918
+ }
919
+ // 2. Build cwd-bound runtime services via the SERVICES path (C3) — NOT plain
920
+ // createAgentSession. createAgentSessionServices builds + reloads the
921
+ // resource loader (the `-e` canvas extensions + --append-system-prompt) and
922
+ // REGISTERS extension-provided model providers into the ModelRegistry (it is
923
+ // also where extension flag values would be applied — the broker recipe
924
+ // carries none today). Plain createAgentSession does NEITHER, so a node whose
925
+ // model comes from a custom-provider extension would get NO model. Mirrors pi
926
+ // main.js (createAgentSessionServices → …FromServices).
927
+ //
928
+ // C4 note: the pinned SDK (0.78.1) has NO project-trust concept at all —
929
+ // project context files (AGENTS.md/CLAUDE.md) load UNCONDITIONALLY (gated
930
+ // only by `noContextFiles`, left default-false), so the headless "trust
931
+ // resolves false → context silently dropped" gap cannot occur here. WHEN the
932
+ // SDK pin bumps to 0.79.0+ (which adds project-trust), pass an explicit
933
+ // `settingsManager: SettingsManager.create(cfg.cwd, getAgentDir(), { projectTrusted: true })`
934
+ // so headless trust defaults TRUSTED — and do NOT re-introduce the CLI's
935
+ // resolveProjectTrust, which returns false with no TTY.
936
+ //
937
+ // T3 session-rebind: services + model + session creation is factored into
938
+ // `buildForManager` so it can serve BOTH the initial boot AND the
939
+ // AgentSessionRuntime factory it is reused as below (new_session/
940
+ // switch_session/fork rebuild the session by re-invoking it for a new
941
+ // SessionManager). Mirrors pi main.js's `createRuntime`.
942
+ const agentDir = getAgentDir();
943
+ const buildForManager = async (o) => {
944
+ const services = await engine.createAgentSessionServices({
945
+ cwd: o.cwd,
946
+ agentDir: o.agentDir,
947
+ resourceLoaderOptions: {
948
+ additionalExtensionPaths: cfg.extensionPaths,
949
+ appendSystemPrompt: cfg.appendSystemPromptPath !== undefined ? [cfg.appendSystemPromptPath] : undefined,
950
+ },
951
+ });
952
+ // 3. Resolve the model spec (`anthropic/sonnet` → a Model) against the
953
+ // SERVICES registry — which has any extension-provided providers (C3).
954
+ // Undefined ⇒ the SDK picks the settings default.
955
+ let model;
956
+ if (cfg.model !== undefined && cfg.model !== '') {
957
+ const slash = cfg.model.indexOf('/');
958
+ if (slash > 0) {
959
+ model = services.modelRegistry.find(cfg.model.slice(0, slash), cfg.model.slice(slash + 1));
960
+ if (model === undefined) {
961
+ process.stderr.write(`[broker] WARNING: model '${cfg.model}' not found in registry — ` +
962
+ `falling back to the SDK default model.\n`);
963
+ }
964
+ }
965
+ else {
966
+ process.stderr.write(`[broker] WARNING: model '${cfg.model}' has no 'provider/id' form — ` +
967
+ `using the SDK default model.\n`);
968
+ }
969
+ }
970
+ const created = await engine.createAgentSessionFromServices({
971
+ services,
972
+ sessionManager: o.sessionManager,
973
+ model,
974
+ tools: cfg.tools,
975
+ sessionStartEvent: o.sessionStartEvent,
976
+ });
977
+ return { ...created, services };
978
+ };
979
+ // 4. Open (resume) or create (fresh) the session — the broker is the sole writer.
980
+ const resumePath = cfg.resumeSessionPath;
981
+ if ((resumePath === undefined || resumePath === '') &&
982
+ cfg.resumeSessionId !== undefined &&
983
+ cfg.resumeSessionId !== '') {
984
+ // SessionManager.open() takes a FILE path — it only path-normalizes; it does
985
+ // NOT resolve a bare uuid to its .jsonl the way pi's CLI does (via
986
+ // SessionManager.list). A bare-id-only resume would open a nonexistent
987
+ // <cwd>/<uuid> and silently start an EMPTY session. Fail loud: revive always
988
+ // passes the .jsonl path, so a bare id means an old node that never captured
989
+ // pi_session_file — mis-resuming it silently is worse than a crash.
990
+ throw new Error(`[broker] resume requires a session .jsonl PATH; got only a bare id ` +
991
+ `'${cfg.resumeSessionId}' (pi_session_file was never captured for this node)`);
992
+ }
993
+ const resuming = resumePath !== undefined && resumePath !== '';
994
+ const sessionManager = resuming
995
+ ? engine.SessionManager.open(resumePath)
996
+ : engine.SessionManager.create(cfg.cwd);
997
+ // When the engine exposes the replacement API (real SDK), wrap the builder in an
998
+ // AgentSessionRuntime so new_session/switch_session/fork work; the runtime
999
+ // re-invokes `buildForManager` for each new SessionManager. The fake-engine
1000
+ // fixture omits it — fall back to a single direct build (those three ops then
1001
+ // reply error{engine_error}).
1002
+ if (engine.createAgentSessionRuntime !== undefined) {
1003
+ const factory = async (o) => {
1004
+ const r = await buildForManager({
1005
+ cwd: o.cwd,
1006
+ agentDir: o.agentDir,
1007
+ sessionManager: o.sessionManager,
1008
+ sessionStartEvent: o.sessionStartEvent,
1009
+ });
1010
+ return { ...r, diagnostics: r.services.diagnostics ?? [] };
1011
+ };
1012
+ const runtime = await engine.createAgentSessionRuntime(factory, {
1013
+ cwd: cfg.cwd,
1014
+ agentDir,
1015
+ sessionManager,
1016
+ });
1017
+ return { session: runtime.session, services: runtime.services, resuming, runtime };
1018
+ }
1019
+ const r = await buildForManager({ cwd: cfg.cwd, agentDir, sessionManager });
1020
+ return { session: r.session, services: r.services, resuming, runtime: undefined };
1021
+ }
1022
+ export function makeBrokerUiContext(deps) {
1023
+ // The dialog router. C2 (scout mq5thyli): the design §5.4 premise — that an
1024
+ // unattended dialog auto-resolves on its OWN timeout — is FALSE. `timeout` is
1025
+ // OPTIONAL on dialog opts, editor() takes none, and almost no real extension
1026
+ // passes one (permission-gate / confirm-destructive / plan-mode / subagent all
1027
+ // omit it). A timeout-reliant unattended node therefore deadlocks the agent
1028
+ // turn FOREVER. So with ZERO viewers attached we fall back to the SDK's noOp UI
1029
+ // behavior — resolve to the default (deny / cancel / undefined) IMMEDIATELY,
1030
+ // never arming a timer, never waiting. (Phase 4 adds the WITH-viewer forwarding
1031
+ // path, wrapped in a broker-side timeout+abort so a controller that attaches
1032
+ // but never answers cannot hang the turn either.)
1033
+ const dialogPromise = (defaultValue, request, parse, opts) => {
1034
+ if (opts?.signal?.aborted)
1035
+ return Promise.resolve(defaultValue);
1036
+ const controller = deps.controller();
1037
+ // C2 (Wave-0, KEEP): no controller at raise time → noOp, resolved at once. No
1038
+ // timer, no wait, no deadlock. This is the genuine zero-controller path.
1039
+ if (controller === null)
1040
+ return Promise.resolve(defaultValue);
1041
+ // A controller is attached: forward the dialog, register it (so a re-routed /
1042
+ // re-attaching controller can answer it — T4), and ALWAYS arm a broker-side
1043
+ // timeout (T4/C2 anti-deadlock): a controller that never answers — or detaches
1044
+ // and is never replaced — can never hang the turn. Honor a shorter per-dialog
1045
+ // timeout if the extension passed one; otherwise the broker default. On fire
1046
+ // it resolves to the SAFE default (deny/cancel/undefined). NOTE: controller
1047
+ // detach does NOT cancel this (M2) — only an answer, the timeout, or an abort.
1048
+ return new Promise((resolve) => {
1049
+ let timer;
1050
+ const cleanup = () => {
1051
+ if (timer !== undefined)
1052
+ clearTimeout(timer);
1053
+ opts?.signal?.removeEventListener('abort', onAbort);
1054
+ deps.pending.delete(request.id);
1055
+ };
1056
+ const onAbort = () => {
1057
+ cleanup();
1058
+ resolve(defaultValue);
1059
+ };
1060
+ opts?.signal?.addEventListener('abort', onAbort, { once: true });
1061
+ // N1: honor the extension's EXPLICIT per-dialog timeout if it passed one
1062
+ // (longer or shorter than the broker default is fine — both are bounded);
1063
+ // otherwise fall back to the broker default. The broker never RELIES on the
1064
+ // extension having passed one (C2).
1065
+ const ms = opts?.timeout !== undefined ? opts.timeout : DEFAULT_DIALOG_TIMEOUT_MS;
1066
+ timer = setTimeout(() => {
1067
+ cleanup();
1068
+ resolve(defaultValue);
1069
+ }, ms);
1070
+ if (typeof timer.unref === 'function')
1071
+ timer.unref();
1072
+ deps.pending.set(request.id, {
1073
+ request,
1074
+ resolve: (r) => {
1075
+ cleanup();
1076
+ resolve(parse(r));
1077
+ },
1078
+ });
1079
+ deps.forward(controller, request);
1080
+ });
1081
+ };
1082
+ const noop = () => { };
1083
+ const ctx = {
1084
+ // pi's ExtensionUIContext exposes `theme`; package extensions (e.g. the OAuth
1085
+ // billing adapter's status rendering) read it from ANY lifecycle hook. The
1086
+ // SDK's noOpUIContext returns the process-global theme proxy here; we mirror
1087
+ // that by reading the same global symbol (populated by initTheme() at broker
1088
+ // boot — see runBroker). Without this property `ctx.ui.theme` is undefined and
1089
+ // the hook throws, which the runner silently swallows while dropping the hook's
1090
+ // returned payload (the OAuth 400 root cause).
1091
+ get theme() {
1092
+ const t = globalThis[Symbol.for('@earendil-works/pi-coding-agent:theme')];
1093
+ if (t === undefined || t === null) {
1094
+ throw new Error('Theme not initialized. Call initTheme() first.');
1095
+ }
1096
+ return t;
1097
+ },
1098
+ select: (title, options, opts) => dialogPromise(undefined, { type: 'extension_ui_request', id: randomUUID(), method: 'select', title, options, timeout: opts?.timeout }, (r) => ('cancelled' in r && r.cancelled ? undefined : 'value' in r ? r.value : undefined), opts),
1099
+ confirm: (title, message, opts) => dialogPromise(false, { type: 'extension_ui_request', id: randomUUID(), method: 'confirm', title, message, timeout: opts?.timeout }, (r) => ('cancelled' in r && r.cancelled ? false : 'confirmed' in r ? r.confirmed : false), opts),
1100
+ input: (title, placeholder, opts) => dialogPromise(undefined, { type: 'extension_ui_request', id: randomUUID(), method: 'input', title, placeholder, timeout: opts?.timeout }, (r) => ('cancelled' in r && r.cancelled ? undefined : 'value' in r ? r.value : undefined), opts),
1101
+ editor: (title, prefill) => dialogPromise(undefined, { type: 'extension_ui_request', id: randomUUID(), method: 'editor', title, prefill }, (r) => ('cancelled' in r && r.cancelled ? undefined : 'value' in r ? r.value : undefined)),
1102
+ // Inert print-mode surface (never reached by a Model hook in Phase 3).
1103
+ notify: noop,
1104
+ onTerminalInput: () => noop,
1105
+ setStatus: noop,
1106
+ setWorkingMessage: noop,
1107
+ setWorkingVisible: noop,
1108
+ setWorkingIndicator: noop,
1109
+ setHiddenThinkingLabel: noop,
1110
+ setWidget: noop,
1111
+ setFooter: noop,
1112
+ setHeader: noop,
1113
+ setTitle: noop,
1114
+ custom: () => Promise.resolve(undefined),
1115
+ pasteToEditor: noop,
1116
+ setEditorText: noop,
1117
+ getEditorText: () => '',
1118
+ addAutocompleteProvider: noop,
1119
+ setEditorComponent: noop,
1120
+ getEditorComponent: () => undefined,
1121
+ getAllThemes: () => [],
1122
+ getTheme: () => undefined,
1123
+ setTheme: noop,
1124
+ getToolsExpanded: () => false,
1125
+ setToolsExpanded: noop,
1126
+ };
1127
+ return ctx;
1128
+ }