@crouton-kit/crouter 0.3.18 → 0.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/orchestrator.md +1 -1
  12. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  13. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  14. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  15. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  16. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  17. package/dist/builtin-personas/runtime-base.md +6 -3
  18. package/dist/builtin-personas/spec/orchestrator.md +1 -1
  19. package/dist/builtin-personas/waiting.md +8 -0
  20. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  21. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  22. package/dist/builtin-views/_lib/states.mjs +161 -0
  23. package/dist/builtin-views/canvas/client.mjs +303 -0
  24. package/dist/builtin-views/canvas/view.mjs +576 -0
  25. package/dist/builtin-views/git-pr/client.mjs +440 -0
  26. package/dist/builtin-views/git-pr/view.mjs +675 -0
  27. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  28. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  29. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  30. package/dist/builtin-views/inbox/view.mjs +889 -0
  31. package/dist/builtin-views/linkedin/client.mjs +610 -0
  32. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  34. package/dist/clients/attach/attach-cmd.js +354 -0
  35. package/dist/clients/attach/chat-view.d.ts +77 -0
  36. package/dist/clients/attach/chat-view.js +450 -0
  37. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  38. package/dist/clients/attach/clipboard-image.js +113 -0
  39. package/dist/clients/attach/config-load.d.ts +31 -0
  40. package/dist/clients/attach/config-load.js +113 -0
  41. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  42. package/dist/clients/attach/extension-dialogs.js +101 -0
  43. package/dist/clients/attach/input-controller.d.ts +54 -0
  44. package/dist/clients/attach/input-controller.js +204 -0
  45. package/dist/clients/attach/slash-commands.d.ts +36 -0
  46. package/dist/clients/attach/slash-commands.js +200 -0
  47. package/dist/clients/attach/view-socket.d.ts +48 -0
  48. package/dist/clients/attach/view-socket.js +126 -0
  49. package/dist/commands/attention.js +3 -3
  50. package/dist/commands/canvas-prune.js +1 -1
  51. package/dist/commands/daemon.js +3 -2
  52. package/dist/commands/human/prompts.js +1 -1
  53. package/dist/commands/human/queue.js +43 -8
  54. package/dist/commands/human/shared.d.ts +2 -0
  55. package/dist/commands/memory/find.d.ts +1 -0
  56. package/dist/commands/memory/find.js +180 -0
  57. package/dist/commands/memory/lint.d.ts +1 -0
  58. package/dist/commands/memory/lint.js +140 -0
  59. package/dist/commands/memory/list.d.ts +1 -0
  60. package/dist/commands/memory/list.js +79 -0
  61. package/dist/commands/memory/read.js +103 -0
  62. package/dist/commands/memory/shared.d.ts +30 -0
  63. package/dist/commands/memory/shared.js +122 -0
  64. package/dist/commands/memory/write.d.ts +1 -0
  65. package/dist/commands/memory/write.js +85 -0
  66. package/dist/commands/memory.d.ts +2 -0
  67. package/dist/commands/memory.js +27 -0
  68. package/dist/commands/node.d.ts +3 -2
  69. package/dist/commands/node.js +607 -61
  70. package/dist/commands/pkg/market-manage.js +1 -1
  71. package/dist/commands/push.js +6 -6
  72. package/dist/commands/revive.js +1 -1
  73. package/dist/commands/skill/author.js +1 -1
  74. package/dist/commands/skill/shared.d.ts +1 -8
  75. package/dist/commands/skill/shared.js +2 -55
  76. package/dist/commands/skill.js +9 -14
  77. package/dist/commands/sys/doctor.js +1 -1
  78. package/dist/commands/sys/update.js +1 -1
  79. package/dist/commands/view-cycle.d.ts +2 -0
  80. package/dist/commands/view-cycle.js +125 -0
  81. package/dist/commands/view-list.d.ts +2 -0
  82. package/dist/commands/view-list.js +66 -0
  83. package/dist/commands/view-new.d.ts +2 -0
  84. package/dist/commands/view-new.js +70 -0
  85. package/dist/commands/view-pick.d.ts +2 -0
  86. package/dist/commands/view-pick.js +119 -0
  87. package/dist/commands/view-run.d.ts +2 -0
  88. package/dist/commands/view-run.js +191 -0
  89. package/dist/commands/view.d.ts +2 -0
  90. package/dist/commands/view.js +29 -0
  91. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  92. package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
  93. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  94. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  95. package/dist/core/__tests__/cascade-close.test.js +12 -2
  96. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  97. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  98. package/dist/core/__tests__/context-intro.test.js +76 -62
  99. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  100. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  101. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  102. package/dist/core/__tests__/detach-focus.test.js +206 -0
  103. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  104. package/dist/core/__tests__/draw-style.test.js +258 -0
  105. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  106. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  107. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  108. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  109. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  110. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  111. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  112. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  113. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  114. package/dist/core/__tests__/helpers/harness.js +111 -1
  115. package/dist/core/__tests__/home-session.test.js +41 -8
  116. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  117. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  118. package/dist/core/__tests__/kickoff.test.js +37 -3
  119. package/dist/core/__tests__/live-mutation.test.js +50 -33
  120. package/dist/core/__tests__/memory.test.js +23 -115
  121. package/dist/core/__tests__/placement-focus.test.js +5 -0
  122. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  123. package/dist/core/__tests__/relaunch.test.js +4 -3
  124. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  125. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  126. package/dist/core/__tests__/spawn-root.test.js +10 -0
  127. package/dist/core/__tests__/spike-harness.test.js +1 -0
  128. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  129. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  130. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  131. package/dist/core/__tests__/wake-origin.test.js +110 -0
  132. package/dist/core/bootstrap.js +1 -1
  133. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  134. package/dist/core/canvas/browse/app.js +24 -2
  135. package/dist/core/canvas/browse/model.d.ts +38 -2
  136. package/dist/core/canvas/browse/model.js +134 -10
  137. package/dist/core/canvas/browse/render.d.ts +6 -12
  138. package/dist/core/canvas/browse/render.js +72 -104
  139. package/dist/core/canvas/canvas.js +15 -22
  140. package/dist/core/canvas/db.js +46 -0
  141. package/dist/core/canvas/index.d.ts +1 -0
  142. package/dist/core/canvas/index.js +1 -0
  143. package/dist/core/canvas/pid.d.ts +4 -0
  144. package/dist/core/canvas/pid.js +23 -0
  145. package/dist/core/canvas/render.d.ts +11 -2
  146. package/dist/core/canvas/render.js +69 -0
  147. package/dist/core/canvas/types.d.ts +85 -3
  148. package/dist/core/canvas/wakeups.d.ts +76 -0
  149. package/dist/core/canvas/wakeups.js +185 -0
  150. package/dist/core/config.js +4 -1
  151. package/dist/core/frontmatter.js +37 -124
  152. package/dist/core/memory-resolver.d.ts +49 -0
  153. package/dist/core/memory-resolver.js +141 -0
  154. package/dist/core/personas/loader.d.ts +7 -0
  155. package/dist/core/personas/loader.js +36 -5
  156. package/dist/core/personas/resolve.js +13 -3
  157. package/dist/core/predicate.d.ts +63 -0
  158. package/dist/core/predicate.js +189 -0
  159. package/dist/core/resolver.js +26 -5
  160. package/dist/core/runtime/bearings.d.ts +53 -12
  161. package/dist/core/runtime/bearings.js +132 -59
  162. package/dist/core/runtime/broker-cli.d.ts +1 -0
  163. package/dist/core/runtime/broker-cli.js +46 -0
  164. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  165. package/dist/core/runtime/broker-protocol.js +153 -0
  166. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  167. package/dist/core/runtime/broker-sdk.js +72 -0
  168. package/dist/core/runtime/broker.d.ts +55 -0
  169. package/dist/core/runtime/broker.js +1128 -0
  170. package/dist/core/runtime/close.js +35 -6
  171. package/dist/core/runtime/host.d.ts +53 -0
  172. package/dist/core/runtime/host.js +186 -0
  173. package/dist/core/runtime/kickoff.d.ts +2 -1
  174. package/dist/core/runtime/kickoff.js +91 -5
  175. package/dist/core/runtime/launch.d.ts +45 -2
  176. package/dist/core/runtime/launch.js +65 -2
  177. package/dist/core/runtime/lifecycle.js +23 -6
  178. package/dist/core/runtime/memory.d.ts +2 -42
  179. package/dist/core/runtime/memory.js +11 -162
  180. package/dist/core/runtime/nodes.d.ts +33 -0
  181. package/dist/core/runtime/nodes.js +59 -1
  182. package/dist/core/runtime/persona.js +21 -11
  183. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  184. package/dist/core/runtime/pi-vendored.js +49 -0
  185. package/dist/core/runtime/placement.d.ts +32 -14
  186. package/dist/core/runtime/placement.js +206 -52
  187. package/dist/core/runtime/promote.d.ts +0 -6
  188. package/dist/core/runtime/promote.js +1 -12
  189. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  190. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  191. package/dist/core/runtime/reset.js +8 -6
  192. package/dist/core/runtime/revive.d.ts +2 -0
  193. package/dist/core/runtime/revive.js +34 -34
  194. package/dist/core/runtime/spawn.d.ts +19 -0
  195. package/dist/core/runtime/spawn.js +75 -22
  196. package/dist/core/runtime/stop-guard.d.ts +1 -1
  197. package/dist/core/runtime/stop-guard.js +6 -1
  198. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  199. package/dist/core/runtime/tmux-chrome.js +1 -1
  200. package/dist/core/runtime/tmux.d.ts +28 -0
  201. package/dist/core/runtime/tmux.js +80 -6
  202. package/dist/core/scope.d.ts +11 -0
  203. package/dist/core/scope.js +39 -0
  204. package/dist/core/spawn.d.ts +5 -0
  205. package/dist/core/spawn.js +20 -2
  206. package/dist/core/substrate/gate.d.ts +13 -0
  207. package/dist/core/substrate/gate.js +21 -0
  208. package/dist/core/substrate/index.d.ts +7 -0
  209. package/dist/core/substrate/index.js +18 -0
  210. package/dist/core/substrate/on-read.d.ts +14 -0
  211. package/dist/core/substrate/on-read.js +292 -0
  212. package/dist/core/substrate/render.d.ts +25 -0
  213. package/dist/core/substrate/render.js +256 -0
  214. package/dist/core/substrate/schema.d.ts +76 -0
  215. package/dist/core/substrate/schema.js +124 -0
  216. package/dist/core/substrate/session-cache.d.ts +30 -0
  217. package/dist/core/substrate/session-cache.js +77 -0
  218. package/dist/core/substrate/subject.d.ts +41 -0
  219. package/dist/core/substrate/subject.js +54 -0
  220. package/dist/core/tui/contract.d.ts +83 -0
  221. package/dist/core/tui/contract.js +8 -0
  222. package/dist/core/tui/draw.d.ts +96 -0
  223. package/dist/core/tui/draw.js +339 -0
  224. package/dist/core/tui/host.d.ts +29 -0
  225. package/dist/core/tui/host.js +379 -0
  226. package/dist/core/tui/loader.d.ts +16 -0
  227. package/dist/core/tui/loader.js +94 -0
  228. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  229. package/dist/core/wake.d.ts +86 -0
  230. package/dist/core/wake.js +308 -0
  231. package/dist/daemon/crtrd.d.ts +29 -4
  232. package/dist/daemon/crtrd.js +662 -46
  233. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  234. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  235. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  236. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  237. package/dist/pi-extensions/canvas-commands.js +10 -0
  238. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  239. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  240. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  241. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  242. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  243. package/dist/pi-extensions/canvas-nav.js +87 -23
  244. package/dist/pi-extensions/canvas-stophook.js +17 -8
  245. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  246. package/dist/pi-extensions/canvas-view.js +75 -0
  247. package/dist/prompts/skill.js +19 -26
  248. package/dist/prompts/view.d.ts +7 -0
  249. package/dist/prompts/view.js +101 -0
  250. package/dist/types.d.ts +4 -0
  251. package/dist/types.js +1 -0
  252. package/package.json +9 -4
  253. package/dist/commands/skill/find.d.ts +0 -4
  254. package/dist/commands/skill/find.js +0 -257
  255. package/dist/commands/skill/read.js +0 -91
  256. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  257. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -1,8 +1,8 @@
1
1
  import { type NodeRow, type FocusRow } from '../canvas/index.js';
2
- import { homeSessionOf } from './nodes.js';
3
- export { homeSessionOf };
2
+ import { homeSessionOf, childBackstageOf } from './nodes.js';
3
+ export { homeSessionOf, childBackstageOf };
4
4
  export type { FocusRow };
5
- export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, focusWindow, windowAlive, windowOfPane, respawnPane, } from './tmux.js';
5
+ export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, splitWindow, focusWindow, windowAlive, windowOfPane, respawnPane, respawnPaneSync, setPaneOption, getPaneOption, paneCurrentPath, listLivePanes, } from './tmux.js';
6
6
  export type { RespawnPaneOpts } from './tmux.js';
7
7
  export { nodeSession } from './nodes.js';
8
8
  /** The focus a node occupies, or null. UNIQUE(node_id) ⇒ at most one. */
@@ -155,8 +155,10 @@ export declare function reviveTarget(focus: FocusRow | null, focusPaneAlive: boo
155
155
  * nothing else. That is the structural bug-kill.
156
156
  *
157
157
  * `setPresence` (the one atomic LOCATION write) records where the node landed.
158
- * CRTR_ROOT_SESSION is forced to `homeSession` in BOTH branches so the node's
159
- * children always flow to the backstage, never into the focus session. */
158
+ * CRTR_ROOT_SESSION is forced to the node's children-backstage (childBackstageOf)
159
+ * in BOTH branches so the node's children always flow to the backstage, never
160
+ * into the focus session — and, for a front-door ROOT, never into the user
161
+ * session its own pane adopted. */
160
162
  export declare function reviveIntoPlacement(nodeId: string, launch: ReviveLaunch): PlacementResult;
161
163
  /** Relocate a node's still-running agent to the background `crtr` session,
162
164
  * freeing the foreground pane WITHOUT killing the pi. `break-pane` moves the
@@ -166,7 +168,15 @@ export declare function reviveIntoPlacement(nodeId: string, launch: ReviveLaunch
166
168
  * CURRENT location, §2.4) and again after (presence FOLLOWS the move). No-op
167
169
  * (false) when there is no live pane to relocate or tmux refuses the break.
168
170
  * `pane` is the authoritative node pane the caller acts on (the Alt+C menu's
169
- * `#{pane_id}`); falls back to the node's durable handle. */
171
+ * `#{pane_id}`); falls back to the node's durable handle.
172
+ *
173
+ * Invariant P gate (Bug 1): the backstage holds ONLY generating-but-unfocused
174
+ * nodes. A node that is NOT mid-turn (e.g. a resident root parked idle between
175
+ * turns, detached via Alt+C → D) must never be parked there as a live-idle pi —
176
+ * nothing would re-enter agent_end to release it and, with no live subscription,
177
+ * nothing would wake it, so it would hold a backstage pane forever, reaped by
178
+ * nothing. Such a node is RELEASED to dormant instead (mirrors the parked-viewer
179
+ * release on focus-away in retargetFocus). */
170
180
  export declare function detachToBackground(nodeId: string, pane?: string): boolean;
171
181
  /** A reviver: resume a DORMANT node into its backstage placement (a fresh `crtr`
172
182
  * window via reviveIntoPlacement). Injected so placement.ts need not import
@@ -277,20 +287,28 @@ export declare function recycleFocusPane(nodeId: string, pane: string, launch: R
277
287
  /** §1.6 lifecycle successor — hand a truly-done focused node's viewport to its
278
288
  * manager. Repoints the focus row `focusId` to `managerId` (a DB swap of the
279
289
  * occupant). Two takeover realizations, split on the manager's liveness:
280
- * - DORMANT manager (dead pi): the row repoint is all this does; the manager,
281
- * woken by the finished node's `push final` landing in its inbox, is revived
282
- * by the external daemon INTO this node's now-frozen focus pane
283
- * (remain-on-exit), where reviveIntoPlacement's focus-pane branch resumes it
284
- * in place — no new window, no taint. (UNCHANGED — the canonical takeover.)
290
+ * - DORMANT manager (dead pi) ONLY when it is idle + idle-release: the row
291
+ * repoint is all this does; the manager, woken by the finished node's `push
292
+ * final` landing in its inbox, is revived by the external daemon INTO this
293
+ * node's now-frozen focus pane (remain-on-exit), where reviveIntoPlacement's
294
+ * focus-pane branch resumes it in place — no new window, no taint. A dormant
295
+ * manager that is NOT idle-release (done/dead/canceled, or idle with another
296
+ * intent) is one the daemon will NEVER revive, so this returns false WITHOUT
297
+ * repointing — the caller then disarms the freeze (see below).
285
298
  * - LIVE manager (pi alive in the backstage, the normal multi-child state):
286
299
  * the daemon never revives it (it only respawns dead-pi nodes), so we must
287
300
  * bring it into the viewport SYNCHRONOUSLY here — swap its backstage pane
288
301
  * into the focus slot (MAJOR 1). Otherwise the manager runs off-screen
289
302
  * forever while %m sits orphaned in the viewport and the focus row lies
290
303
  * about LOCATION.
291
- * Returns false — the caller closes the focus (Q1) when there is no manager,
292
- * the manager IS this node, or the manager already occupies another viewport
293
- * (UNIQUE node_id: do NOT move it, §1.6 edge).
304
+ * Returns false — the caller closes the focus (Q1: closeFocusToShell disarms
305
+ * remain-on-exit so the pane REAPS on exit) whenever NO successor will claim
306
+ * the frozen pane: no manager, the manager IS this node, the manager already
307
+ * occupies another viewport (UNIQUE node_id: do NOT move it, §1.6 edge), the
308
+ * manager row is missing, or it is a dormant manager the daemon won't revive
309
+ * (not idle-release) / a live-but-paneless inline root. It does NOT repoint the
310
+ * occupant on any false path — repointing while returning false would strand the
311
+ * pane frozen forever with no reaper (the dead-focus-pane bug).
294
312
  *
295
313
  * Why the live swap is NOT the forbidden self-saw: `swap-pane -d` only EXCHANGES
296
314
  * two panes' slot positions; it never respawns or kills the finishing node's own
@@ -25,18 +25,22 @@
25
25
  // The robustness contract: a manual `move-pane`/`join-pane`/`break-pane` must
26
26
  // NEVER read as a node death. Liveness is pane-existence, not window-existence,
27
27
  // and reconcile makes crtr follow a move instead of fighting it.
28
+ import { spawnSync } from 'node:child_process';
29
+ import { join } from 'node:path';
28
30
  import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, view, } from '../canvas/index.js';
29
31
  import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
30
- import { homeSessionOf, nodeSession, newNodeId } from './nodes.js';
32
+ import { homeSessionOf, childBackstageOf, nodeSession, newNodeId, rootOfSpine } from './nodes.js';
33
+ import { nodeDir } from '../canvas/paths.js';
31
34
  import { isBusy } from './busy.js';
35
+ import { isPidAlive } from '../canvas/pid.js';
32
36
  import { transition } from './lifecycle.js';
33
37
  // Re-export the durable REVIVE-HOME read so placement is the one front door for
34
38
  // "where does this node live."
35
- export { homeSessionOf };
39
+ export { homeSessionOf, childBackstageOf };
36
40
  // Placement is the sanctioned model-over-driver (§2.1): non-placement runtime /
37
41
  // command modules that legitimately need a raw driver verb get it from here, so
38
42
  // the §5.1 lint can hold "only placement.ts / tmux-chrome.ts import tmux.ts".
39
- export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, focusWindow, windowAlive, windowOfPane, respawnPane, } from './tmux.js';
43
+ export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, splitWindow, focusWindow, windowAlive, windowOfPane, respawnPane, respawnPaneSync, setPaneOption, getPaneOption, paneCurrentPath, listLivePanes, } from './tmux.js';
40
44
  export { nodeSession } from './nodes.js';
41
45
  // ---------------------------------------------------------------------------
42
46
  // Focus reads (Step 4) — COMPOSE over the canvas focuses table (§2.3/§4).
@@ -74,21 +78,6 @@ export function listFocuses() {
74
78
  // ---------------------------------------------------------------------------
75
79
  // Graph → focus routing (for surfacing human-in-the-loop prompts)
76
80
  // ---------------------------------------------------------------------------
77
- /** The root of a node's spine: walk the `parent` column up to `parent == null`.
78
- * Cycle-guarded (parents must not cycle, but never loop forever). */
79
- function rootOfSpine(nodeId) {
80
- let cur = nodeId;
81
- const seen = new Set();
82
- for (;;) {
83
- if (seen.has(cur))
84
- return cur;
85
- seen.add(cur);
86
- const row = getRow(cur);
87
- if (row === null || row.parent == null)
88
- return cur;
89
- cur = row.parent;
90
- }
91
- }
92
81
  /** The on-screen viewport a human-in-the-loop prompt raised by `nodeId` should
93
82
  * surface into: the HIGHEST FOCUSED node of nodeId's graph — the focused node
94
83
  * closest to the graph root, i.e. the session/window the user is actually
@@ -245,8 +234,10 @@ export function reviveTarget(focus, focusPaneAlive, homeSession) {
245
234
  * nothing else. That is the structural bug-kill.
246
235
  *
247
236
  * `setPresence` (the one atomic LOCATION write) records where the node landed.
248
- * CRTR_ROOT_SESSION is forced to `homeSession` in BOTH branches so the node's
249
- * children always flow to the backstage, never into the focus session. */
237
+ * CRTR_ROOT_SESSION is forced to the node's children-backstage (childBackstageOf)
238
+ * in BOTH branches so the node's children always flow to the backstage, never
239
+ * into the focus session — and, for a front-door ROOT, never into the user
240
+ * session its own pane adopted. */
250
241
  export function reviveIntoPlacement(nodeId, launch) {
251
242
  // §2.4 — follow any manual pane move before acting.
252
243
  reconcile(nodeId);
@@ -254,9 +245,12 @@ export function reviveIntoPlacement(nodeId, launch) {
254
245
  const focusPaneAlive = focus !== null && focus.pane !== null && paneExists(focus.pane);
255
246
  const homeSession = homeSessionOf(nodeId);
256
247
  const decision = reviveTarget(focus, focusPaneAlive, homeSession);
257
- // The node's children always spawn into the backstage (homeSession), never the
258
- // focus session — force it regardless of which branch the node itself takes.
259
- const env = { ...launch.env, CRTR_ROOT_SESSION: homeSession };
248
+ // The node's children always spawn into the backstage, never the focus session
249
+ // — force it regardless of which branch the node itself takes. childBackstageOf
250
+ // is home_session for a managed child but `nodeSession()` for a root (whose
251
+ // home_session may be a user session it adopted), so a refreshed front-door
252
+ // root never re-points its subtree into the user's session.
253
+ const env = { ...launch.env, CRTR_ROOT_SESSION: childBackstageOf(nodeId), CRTR_SUBTREE: rootOfSpine(nodeId) };
260
254
  if (decision.kind === 'focus-pane') {
261
255
  // F3: resume the pi INTO the live focus pane, in its CURRENT session (Q4 —
262
256
  // reconcileFocus follows a user move of the focus pane). No new window.
@@ -299,7 +293,15 @@ export function reviveIntoPlacement(nodeId, launch) {
299
293
  * CURRENT location, §2.4) and again after (presence FOLLOWS the move). No-op
300
294
  * (false) when there is no live pane to relocate or tmux refuses the break.
301
295
  * `pane` is the authoritative node pane the caller acts on (the Alt+C menu's
302
- * `#{pane_id}`); falls back to the node's durable handle. */
296
+ * `#{pane_id}`); falls back to the node's durable handle.
297
+ *
298
+ * Invariant P gate (Bug 1): the backstage holds ONLY generating-but-unfocused
299
+ * nodes. A node that is NOT mid-turn (e.g. a resident root parked idle between
300
+ * turns, detached via Alt+C → D) must never be parked there as a live-idle pi —
301
+ * nothing would re-enter agent_end to release it and, with no live subscription,
302
+ * nothing would wake it, so it would hold a backstage pane forever, reaped by
303
+ * nothing. Such a node is RELEASED to dormant instead (mirrors the parked-viewer
304
+ * release on focus-away in retargetFocus). */
303
305
  export function detachToBackground(nodeId, pane) {
304
306
  reconcile(nodeId);
305
307
  const row = getRow(nodeId);
@@ -312,6 +314,28 @@ export function detachToBackground(nodeId, pane) {
312
314
  // follows the right pane.
313
315
  if (row.pane !== target)
314
316
  setPresence(nodeId, { pane: target });
317
+ // Invariant P (Bug 1): a node that is NOT mid-turn must never be parked in the
318
+ // backstage as a live-idle pi (it would hold a backstage pane forever, reaped
319
+ // by nothing). Detaching such a node RELEASES it to dormant instead: close its
320
+ // focus, then — crash-safety, as in retargetFocus's release path — flip it to
321
+ // dormant (idle + intent='idle-release', revivable via inbox / re-focus) and
322
+ // null its LOCATION BEFORE reaping the pane, so a daemon tick can never catch a
323
+ // window-gone live node mid-reap and race to revive it. Unlike retargetFocus's
324
+ // outgoingDisposition (which keeps a RESIDENT node warm on focus-away), detach
325
+ // is a deliberate let-go: a non-generating node is released regardless of
326
+ // lifecycle. The status guard skips the transition for a done/dead/canceled
327
+ // node ('release' is legal only from active|idle) — it still reaps + nulls.
328
+ if (!isGenerating(nodeId)) {
329
+ const f = focusOf(nodeId);
330
+ if (f !== null)
331
+ closeFocusRow(f.focus_id);
332
+ if (row.status === 'active' || row.status === 'idle')
333
+ transition(nodeId, 'release');
334
+ setPresence(nodeId, { pane: null, tmux_session: null, window: null });
335
+ closePane(target);
336
+ return true;
337
+ }
338
+ // Generating → park in the backstage, keep running off-screen.
315
339
  const session = nodeSession();
316
340
  ensureSession(session, row.cwd);
317
341
  const ok = breakPaneToSession(target, session);
@@ -340,18 +364,6 @@ function holderId(focusId) {
340
364
  function newFocusId() {
341
365
  return `f-${newNodeId()}`;
342
366
  }
343
- /** signal-0 liveness probe for a pi pid (mirrors the daemon's isPidAlive). */
344
- function pidAlive(pid) {
345
- if (pid == null)
346
- return false;
347
- try {
348
- process.kill(pid, 0);
349
- return true;
350
- }
351
- catch (e) {
352
- return e.code === 'EPERM';
353
- }
354
- }
355
367
  /** Is a focus's OUTGOING occupant still GENERATING (a live pi actually MID-TURN)?
356
368
  * A still-generating node is moved to backstage by a retarget (F2 — it keeps
357
369
  * running off-screen); a holder / done / dormant / merely-parked node has its
@@ -363,7 +375,7 @@ function pidAlive(pid) {
363
375
  * pid between turns; pid-alive would misclassify it as "doing work" and leave
364
376
  * it stuck backstaged-active forever. `isBusy` is true only inside a turn, so a
365
377
  * parked viewer reads as not-generating and is released to dormant on
366
- * focus-away. The AND with `pidAlive` makes a stale marker (a pi that crashed
378
+ * focus-away. The AND with `isPidAlive` makes a stale marker (a pi that crashed
367
379
  * mid-turn) harmless. */
368
380
  function isGenerating(nodeId) {
369
381
  const row = getRow(nodeId);
@@ -371,7 +383,7 @@ function isGenerating(nodeId) {
371
383
  return false;
372
384
  if (row.status !== 'active' && row.status !== 'idle')
373
385
  return false;
374
- return isBusy(nodeId) && pidAlive(row.pi_pid);
386
+ return isBusy(nodeId) && isPidAlive(row.pi_pid);
375
387
  }
376
388
  export function outgoingDisposition(o) {
377
389
  if (!o.exists)
@@ -571,6 +583,18 @@ export function focus(nodeId, opts) {
571
583
  const meta = getNode(nodeId);
572
584
  if (meta === null)
573
585
  return { focused: false, session: null, inPlace: false, revived: false };
586
+ // ── Phase-4 broker-focus opt-in (headless-broker model) ────────────────────
587
+ // A broker node has NO engine pane: headlessBrokerHost.launch spawns a
588
+ // DETACHED broker that hosts the single engine + binds view.sock, opening no
589
+ // tmux window. So focus cannot swap an engine pane into the caller's viewport
590
+ // — there is none. It instead (a) ensures the broker/engine is ALIVE via the
591
+ // sanctioned reviver, then (b) opens a tmux pane running the VIEWER
592
+ // `crtr attach to <id>`, which connects to view.sock. The viewer NEVER
593
+ // launches the engine (the one-writer invariant) — placement does, here. This
594
+ // is an ISOLATED opt-in BESIDE the engine-in-pane path: it fires ONLY for
595
+ // host_kind==='broker', so the tmux-host placement below stays byte-identical.
596
+ if (meta.host_kind === 'broker')
597
+ return focusBroker(nodeId, meta, opts);
574
598
  const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
575
599
  if (callerPane === undefined || callerPane === '') {
576
600
  // Not in tmux — no viewport to swap into. Reconcile and report status.
@@ -600,6 +624,105 @@ export function focus(nodeId, opts) {
600
624
  }
601
625
  return retargetFocus(f.focus_id, nodeId, opts.revive);
602
626
  }
627
+ const BROKER_FOCUS_SOCKET_WAIT_MS = 30_000;
628
+ const BROKER_FOCUS_SOCKET_RETRY_MS = 100;
629
+ /** Synchronously wait until a broker's view.sock accepts a connection. `focus()`
630
+ * is sync today (the command layer calls it directly), so the readiness probe
631
+ * lives in a short child Node process that can use async net events while this
632
+ * process blocks in `spawnSync`. Success proves more than file existence: it is
633
+ * robust to a stale leftover socket that the launching broker has not unlinked
634
+ * yet, because only an accepting listener exits 0. */
635
+ function waitForBrokerViewSocket(nodeId) {
636
+ const sockPath = join(nodeDir(nodeId), 'view.sock');
637
+ const probe = `
638
+ const net = require('node:net');
639
+ const sockPath = process.argv[1];
640
+ const deadline = Date.now() + Number(process.argv[2]);
641
+ const delay = Number(process.argv[3]);
642
+ function attempt() {
643
+ let socket;
644
+ let settled = false;
645
+ const finish = (ok) => {
646
+ if (settled) return;
647
+ settled = true;
648
+ if (socket !== undefined) socket.destroy();
649
+ if (ok) process.exit(0);
650
+ if (Date.now() >= deadline) process.exit(1);
651
+ setTimeout(attempt, delay);
652
+ };
653
+ try {
654
+ socket = net.createConnection(sockPath);
655
+ } catch {
656
+ finish(false);
657
+ return;
658
+ }
659
+ socket.once('connect', () => finish(true));
660
+ socket.once('error', () => finish(false));
661
+ socket.setTimeout(delay, () => finish(false));
662
+ }
663
+ attempt();
664
+ `;
665
+ const r = spawnSync(process.execPath, ['--input-type=commonjs', '-e', probe, sockPath, String(BROKER_FOCUS_SOCKET_WAIT_MS), String(BROKER_FOCUS_SOCKET_RETRY_MS)], {
666
+ stdio: 'ignore',
667
+ timeout: BROKER_FOCUS_SOCKET_WAIT_MS + 1_000,
668
+ // Keep the probe deterministic: NODE_OPTIONS like --input-type=module or
669
+ // --inspect-brk can break/hang a tiny `node -e` readiness check.
670
+ env: { ...process.env, NODE_OPTIONS: '' },
671
+ });
672
+ return r.status === 0;
673
+ }
674
+ /** Phase-4 broker-focus (headless-broker model) — open an attached VIEWER pane
675
+ * for a node whose engine runs in a DETACHED broker (host_kind==='broker'), the
676
+ * small opt-in beside the engine-in-pane path. Two steps:
677
+ * (a) ENSURE THE BROKER (engine) IS ALIVE via the sanctioned launcher — the
678
+ * injected reviver (reviveNode → headlessBrokerHost.launch). A broker's
679
+ * liveness IS its recorded pid (headlessBrokerHost.isAlive ===
680
+ * isPidAlive(pi_pid)); reviveNode's double-revive guard makes this
681
+ * idempotent: already-alive → no-op, dead → launch ONE detached broker —
682
+ * never a second engine. The VIEWER never does this; placement does.
683
+ * (b) OPEN THE VIEWER PANE — after a bounded view.sock readiness wait (closes
684
+ * the cold-start race against attach's single connect), split a pane
685
+ * beside the caller running exactly the VIEWER command `crtr attach to
686
+ * <id>`, which connects to view.sock and renders/drives the live engine.
687
+ *
688
+ * No FOCUS ROW is registered: a broker viewer pane is NOT an engine pane (the
689
+ * engine lives in the detached broker; pi_pid is the broker's), so it stays out
690
+ * of the focuses table / reconcile / retargetFocus, which track engine-in-pane
691
+ * swap semantics. (Repeated focus thus stacks viewers — accepted for the Phase-4
692
+ * opt-in; the Phase-5 default-swap owns any viewer dedup.) Reaches no engine
693
+ * launcher directly (placement must not import revive.ts/host.ts — a cycle):
694
+ * step (a) goes through the injected `revive`, step (b) through the existing
695
+ * splitWindow driver verb. `--new-pane` is moot — a broker focus is ALWAYS a
696
+ * fresh viewer pane — so the flag is ignored. */
697
+ function focusBroker(nodeId, meta, opts) {
698
+ const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
699
+ if (callerPane === undefined || callerPane === '') {
700
+ // crtr is tmux-only: no viewport to open and no non-tmux fallback.
701
+ return { focused: false, session: null, inPlace: false, revived: false };
702
+ }
703
+ // (a) Ensure the broker is alive (idempotent). Capture prior liveness so the
704
+ // FocusResult reports whether THIS focus had to launch it.
705
+ const wasAlive = isPidAlive(getNode(nodeId)?.pi_pid ?? null);
706
+ opts.revive(nodeId);
707
+ const revived = !wasAlive;
708
+ // Close the cold-start race: a freshly launched broker records pid during
709
+ // extension bind, then opens view.sock later; attach connects once and exits
710
+ // "no broker" if we split before listen(). Probe for an ACCEPTING socket (not
711
+ // mere path existence) before opening the viewer pane.
712
+ if (!waitForBrokerViewSocket(nodeId)) {
713
+ return { focused: false, session: null, inPlace: false, revived };
714
+ }
715
+ // Propagate CRTR_HOME so the viewer resolves the SAME canvas home (and thus
716
+ // the right view.sock) under a non-default override; otherwise an empty env
717
+ // (the split inherits the tmux server env, like every other crtr chrome pane).
718
+ const crtrHome = process.env['CRTR_HOME'];
719
+ const env = crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
720
+ // Node ids are shell-safe identifiers (base36-ts + hex); no quoting needed.
721
+ const pane = splitWindow(callerPane, { cwd: meta.cwd, env, command: `crtr attach to ${nodeId}` });
722
+ if (pane === null)
723
+ return { focused: false, session: null, inPlace: false, revived };
724
+ return { focused: true, session: paneLocation(pane)?.session ?? null, inPlace: false, revived };
725
+ }
603
726
  /** Register the caller's CURRENT pane as a focus so a `node focus`/`cycle` from a
604
727
  * pane that isn't yet a viewport retargets IN PLACE. Occupied by whatever node
605
728
  * sits in the pane now (`callerNode`, else resolved by pane→row), or a HOLDER
@@ -661,20 +784,28 @@ export function recycleFocusPane(nodeId, pane, launch) {
661
784
  /** §1.6 lifecycle successor — hand a truly-done focused node's viewport to its
662
785
  * manager. Repoints the focus row `focusId` to `managerId` (a DB swap of the
663
786
  * occupant). Two takeover realizations, split on the manager's liveness:
664
- * - DORMANT manager (dead pi): the row repoint is all this does; the manager,
665
- * woken by the finished node's `push final` landing in its inbox, is revived
666
- * by the external daemon INTO this node's now-frozen focus pane
667
- * (remain-on-exit), where reviveIntoPlacement's focus-pane branch resumes it
668
- * in place — no new window, no taint. (UNCHANGED — the canonical takeover.)
787
+ * - DORMANT manager (dead pi) ONLY when it is idle + idle-release: the row
788
+ * repoint is all this does; the manager, woken by the finished node's `push
789
+ * final` landing in its inbox, is revived by the external daemon INTO this
790
+ * node's now-frozen focus pane (remain-on-exit), where reviveIntoPlacement's
791
+ * focus-pane branch resumes it in place — no new window, no taint. A dormant
792
+ * manager that is NOT idle-release (done/dead/canceled, or idle with another
793
+ * intent) is one the daemon will NEVER revive, so this returns false WITHOUT
794
+ * repointing — the caller then disarms the freeze (see below).
669
795
  * - LIVE manager (pi alive in the backstage, the normal multi-child state):
670
796
  * the daemon never revives it (it only respawns dead-pi nodes), so we must
671
797
  * bring it into the viewport SYNCHRONOUSLY here — swap its backstage pane
672
798
  * into the focus slot (MAJOR 1). Otherwise the manager runs off-screen
673
799
  * forever while %m sits orphaned in the viewport and the focus row lies
674
800
  * about LOCATION.
675
- * Returns false — the caller closes the focus (Q1) when there is no manager,
676
- * the manager IS this node, or the manager already occupies another viewport
677
- * (UNIQUE node_id: do NOT move it, §1.6 edge).
801
+ * Returns false — the caller closes the focus (Q1: closeFocusToShell disarms
802
+ * remain-on-exit so the pane REAPS on exit) whenever NO successor will claim
803
+ * the frozen pane: no manager, the manager IS this node, the manager already
804
+ * occupies another viewport (UNIQUE node_id: do NOT move it, §1.6 edge), the
805
+ * manager row is missing, or it is a dormant manager the daemon won't revive
806
+ * (not idle-release) / a live-but-paneless inline root. It does NOT repoint the
807
+ * occupant on any false path — repointing while returning false would strand the
808
+ * pane frozen forever with no reaper (the dead-focus-pane bug).
678
809
  *
679
810
  * Why the live swap is NOT the forbidden self-saw: `swap-pane -d` only EXCHANGES
680
811
  * two panes' slot positions; it never respawns or kills the finishing node's own
@@ -690,19 +821,42 @@ export function handFocusToManager(focusId, managerId) {
690
821
  return false;
691
822
  if (getFocusByNode(managerId) !== null)
692
823
  return false; // manager already focused elsewhere
693
- setFocusOccupant(focusId, managerId);
694
- // MAJOR 1 — LIVE backstage manager → swap it into the focus slot now. DORMANT
695
- // managers (no live pane / dead pi) fall through unchanged: the daemon revives
696
- // them into the frozen %m async.
697
824
  const mgr = getRow(managerId);
698
- if (mgr !== null && mgr.pane != null && isNodePaneAlive(mgr) && pidAlive(mgr.pi_pid) && f.pane != null) {
825
+ if (mgr === null)
826
+ return false; // no row to hand to
827
+ // MAJOR 1 — LIVE backstage manager → swap it into the focus slot NOW. The
828
+ // daemon never revives a live node (it only respawns dead-pi nodes), so this
829
+ // synchronous swap is the ONLY way a live manager claims the frozen %m. Commit
830
+ // the occupant repoint only here, on a path that genuinely takes the pane.
831
+ if (mgr.pane != null && isNodePaneAlive(mgr) && isPidAlive(mgr.pi_pid) && f.pane != null) {
832
+ setFocusOccupant(focusId, managerId);
699
833
  const focusLoc = paneLocation(f.pane); // F2's window/session — the slot mgr swaps INTO (%m is currently there)
700
834
  if (swapPaneInPlace(mgr.pane, f.pane) && focusLoc !== null) {
701
835
  setFocusPane(f.focus_id, mgr.pane, focusLoc.session); // re-anchor the focus row to mgr's pane (now in F2)
702
836
  setPresence(managerId, { pane: mgr.pane, tmux_session: focusLoc.session, window: focusLoc.window });
703
837
  }
838
+ return true; // took focus (live swap) — caller doesn't disarm
704
839
  }
705
- return true; // still "took focus" caller doesn't close
840
+ // DORMANT manager it can only enter the frozen %m via crtrd.superviseTick's
841
+ // SECOND PASS, which revives a node ONLY when status==='idle' && intent===
842
+ // 'idle-release' (AND pi dead + unseen inbox). We mirror just status+intent: the
843
+ // other two are guaranteed by lifecycle invariants — every transition(_,
844
+ // 'release') site kills pi immediately, so idle-release ⟹ pi-dead; and the
845
+ // `push --final` that drove us into this done-branch just seeded the manager's
846
+ // inbox. (If a future idle-release path ever leaves pi LIVE, revisit this.)
847
+ // Mirror that predicate EXACTLY: a done/dead/
848
+ // canceled, an idle-but-not-idle-release, or a live-but-paneless (inline root)
849
+ // manager will NEVER be brought into this pane. Repointing for one of those
850
+ // makes the caller SKIP closeFocusToShell ⇒ remain-on-exit stays ON ⇒ the pane
851
+ // freezes forever with no reaper (the dead-focus-pane bug). So ONLY repoint +
852
+ // return true when the daemon will genuinely revive it; otherwise return false
853
+ // WITHOUT repointing so the caller disarms the freeze and the pane reaps on exit
854
+ // (§1.6 flow (b), close-out-completely).
855
+ const daemonWillRevive = mgr.status === 'idle' && mgr.intent === 'idle-release';
856
+ if (!daemonWillRevive)
857
+ return false;
858
+ setFocusOccupant(focusId, managerId); // repoint only; the daemon revives mgr INTO the frozen %m
859
+ return true;
706
860
  }
707
861
  /** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
708
862
  * flow (b)): close its focus row and DISARM the pane's
@@ -6,12 +6,6 @@ export interface PromoteResult {
6
6
  roadmapPath: string;
7
7
  /** Absolute path to the node's goal doc (context/initial-prompt.md). */
8
8
  goalPath: string;
9
- /** Absolute path to the node-local memory index (context/memory/MEMORY.md). */
10
- memoryPath: string;
11
- /** Absolute path to the user-global memory index (<crtrHome>/memory/MEMORY.md). */
12
- userMemoryPath: string;
13
- /** Absolute path to the project memory index (<crtrHome>/projects/<key>/memory/MEMORY.md). */
14
- projectMemoryPath: string;
15
9
  }
16
10
  /** Promote a node to an orchestrator (mode→orchestrator), optionally
17
11
  * specializing its kind (e.g. a `general` worker becoming a
@@ -4,7 +4,7 @@
4
4
  // 1. Promotion → mode flips to orchestrator (mid-turn). This call flips the
5
5
  // node's mode and (optionally) its KIND, REWRITES its launch spec to that
6
6
  // kind's orchestrator persona (so the next revive comes back as that
7
- // orchestrator), and seeds a roadmap scaffold + the three memory stores.
7
+ // orchestrator), and seeds a roadmap scaffold.
8
8
  // The transition guidance the node needs is injected CENTRALLY by the
9
9
  // persona injector (runtime/persona.ts) at the turn boundary — promote()
10
10
  // itself no longer returns or hand-emits guidance.
@@ -22,7 +22,6 @@ import { getNode, updateNode } from '../canvas/index.js';
22
22
  import { transition } from './lifecycle.js';
23
23
  import { buildLaunchSpec } from './launch.js';
24
24
  import { hasRoadmap, seedRoadmap, roadmapPath } from './roadmap.js';
25
- import { seedMemory, memoryPath, seedUserMemory, userMemoryPath, seedProjectMemory, projectMemoryPath, } from './memory.js';
26
25
  import { readGoal, goalPath } from './kickoff.js';
27
26
  /** Promote a node to an orchestrator (mode→orchestrator), optionally
28
27
  * specializing its kind (e.g. a `general` worker becoming a
@@ -58,13 +57,6 @@ export function promote(nodeId, opts = {}) {
58
57
  seedRoadmap(nodeId, goal !== null && goal.trim() !== '' ? { goal: goal.trim() } : {});
59
58
  roadmapWritten = true;
60
59
  }
61
- // Seed all three scoped memory stores alongside the roadmap — user-global,
62
- // project (keyed off this node's cwd), and node-local. Each is a durable,
63
- // refresh-surviving artifact; each guarded so a re-seed never clobbers an
64
- // evolved memory.
65
- seedUserMemory();
66
- seedProjectMemory(node.cwd);
67
- seedMemory(nodeId);
68
60
  // Flip mode→orchestrator + kind + launch spec. Lifecycle is independent:
69
61
  // only set resident when the caller asked for it (the common self-promotion
70
62
  // stays terminal/orchestrator — it still reports up + reaps).
@@ -79,9 +71,6 @@ export function promote(nodeId, opts = {}) {
79
71
  roadmapWritten,
80
72
  roadmapPath: roadmapPath(nodeId),
81
73
  goalPath: goalPath(nodeId),
82
- memoryPath: memoryPath(nodeId),
83
- userMemoryPath: userMemoryPath(),
84
- projectMemoryPath: projectMemoryPath(node.cwd),
85
74
  };
86
75
  }
87
76
  /** Request a refresh-yield: discard in-memory context and revive fresh against
@@ -1,7 +1,7 @@
1
- export interface DemoteResult {
1
+ export interface RecycleResult {
2
2
  /** True when the pane was recycled (a fresh root respawned in it). */
3
- demoted: boolean;
4
- /** True when a `final` report was pushed for the demoted node. */
3
+ recycled: boolean;
4
+ /** True when a `final` report was pushed for the recycled node. */
5
5
  finalized: boolean;
6
6
  /** The fresh root node booted into the pane, or null on failure. */
7
7
  newRoot: string | null;
@@ -10,5 +10,5 @@ export interface DemoteResult {
10
10
  }
11
11
  /** Finish `nodeId` and recycle its pane into a fresh root. `callerPane` is the
12
12
  * tmux pane the agent occupies (the Alt+C menu passes it as `#{pane_id}`).
13
- * Best-effort; `demoted:false` when there is no pane to act on. */
14
- export declare function demoteNode(nodeId: string, callerPane?: string): Promise<DemoteResult>;
13
+ * Best-effort; `recycled:false` when there is no pane to act on. */
14
+ export declare function recycleNode(nodeId: string, callerPane?: string): Promise<RecycleResult>;
@@ -1,6 +1,6 @@
1
- // demote.ts — the "graduate this agent" action behind `crtr node demote`.
1
+ // recycle.ts — the "finalize + reboot this pane" action behind `crtr node recycle`.
2
2
  //
3
- // Demote finishes the agent occupying a tmux pane and recycles that pane for
3
+ // Recycle FINALIZES the agent occupying a tmux pane and recycles that pane for
4
4
  // fresh work, in three steps:
5
5
  //
6
6
  // 1. Finalize — push the agent's last surfaced message as a `final` report so
@@ -8,6 +8,10 @@
8
8
  // 2. Close — kill the agent's pi (respawn-pane -k tears it down in place).
9
9
  // 3. Recycle — boot a fresh resident root in that same pane (a new `crtr`).
10
10
  //
11
+ // NOT to be confused with `node demote` (flip-to-terminal IN PLACE, which keeps
12
+ // the agent focused and running): recycle ENDS this agent and boots a brand-new
13
+ // resident root here.
14
+ //
11
15
  // The agent's real conversation lives inside pi (not on disk), so the final
12
16
  // body is its newest report (which, on a natural stop, IS its last assistant
13
17
  // message) — falling back to a short note when it never reported.
@@ -16,10 +20,11 @@ import { join } from 'node:path';
16
20
  import { getNode, setPresence, updateNode, setFocusOccupant, fullName } from '../canvas/index.js';
17
21
  import { reportsDir } from '../canvas/paths.js';
18
22
  import { pushFinal } from '../feed/feed.js';
19
- import { spawnNode, nodeSession } from './nodes.js';
23
+ import { spawnNode, nodeSession, rootOfSpine } from './nodes.js';
20
24
  import { buildLaunchSpec, buildPiArgv } from './launch.js';
21
25
  import { FRONT_DOOR_ENV } from './front-door.js';
22
26
  import { focusOf, recycleFocusPane, piCommand, paneLocation } from './placement.js';
27
+ import { hostFor } from './host.js';
23
28
  import { ensureDaemon } from '../../daemon/manage.js';
24
29
  /** The agent's most recent surfaced message: the newest reports/*.md body with
25
30
  * its YAML frontmatter stripped. Empty string when the node never reported. */
@@ -49,18 +54,18 @@ function lastReportBody(nodeId) {
49
54
  }
50
55
  /** Finish `nodeId` and recycle its pane into a fresh root. `callerPane` is the
51
56
  * tmux pane the agent occupies (the Alt+C menu passes it as `#{pane_id}`).
52
- * Best-effort; `demoted:false` when there is no pane to act on. */
53
- export async function demoteNode(nodeId, callerPane) {
57
+ * Best-effort; `recycled:false` when there is no pane to act on. */
58
+ export async function recycleNode(nodeId, callerPane) {
54
59
  const meta = getNode(nodeId);
55
60
  if (meta === null)
56
- return { demoted: false, finalized: false, newRoot: null, delivered: [] };
61
+ return { recycled: false, finalized: false, newRoot: null, delivered: [] };
57
62
  const pane = callerPane ?? process.env['TMUX_PANE'];
58
63
  if (pane === undefined || pane === '') {
59
- return { demoted: false, finalized: false, newRoot: null, delivered: [] };
64
+ return { recycled: false, finalized: false, newRoot: null, delivered: [] };
60
65
  }
61
66
  // 1. Finalize — fan the agent's last message out as a `final`, mark it done.
62
67
  const body = lastReportBody(nodeId) ||
63
- `Closed via demote — no final summary was authored by ${meta.name}.`;
68
+ `Closed via recycle — no final summary was authored by ${meta.name}.`;
64
69
  let delivered = [];
65
70
  let finalized = false;
66
71
  try {
@@ -69,6 +74,17 @@ export async function demoteNode(nodeId, callerPane) {
69
74
  finalized = true;
70
75
  }
71
76
  catch { /* recycle the pane even if the report failed */ }
77
+ // A broker node has NO tmux pane, so recycleFocusPane below (respawn-pane -k)
78
+ // never kills its engine — route its teardown through the Host seam so the
79
+ // broker PROCESS exits and releases the sole .jsonl writer (mirrors the T12
80
+ // close.ts/reset.ts fix; review reuse MINOR-3). Status is already flipped done
81
+ // by pushFinal above (crash-safe order: the daemon won't revive a done node).
82
+ if (meta.host_kind === 'broker') {
83
+ try {
84
+ hostFor(meta).teardown(nodeId);
85
+ }
86
+ catch { /* best-effort */ }
87
+ }
72
88
  // Capture M's focus viewport (if any) BEFORE nulling — the fresh root inherits
73
89
  // it (the SAME focus row + pane). The demoted node no longer holds a pane: it is
74
90
  // being reclaimed.
@@ -93,7 +109,7 @@ export async function demoteNode(nodeId, callerPane) {
93
109
  parent: null,
94
110
  launch,
95
111
  });
96
- // REVIVE-HOME: a demote-recycled root's durable revive target is the session
112
+ // REVIVE-HOME: a recycled root's durable revive target is the session
97
113
  // of the pane it was recycled into (the one place home_session is rewritten
98
114
  // after birth). Falls back to the backstage when the pane can't be located.
99
115
  updateNode(root.node_id, { home_session: loc?.session ?? nodeSession() });
@@ -107,9 +123,9 @@ export async function demoteNode(nodeId, callerPane) {
107
123
  }
108
124
  const fresh = getNode(root.node_id);
109
125
  const inv = buildPiArgv(fresh);
110
- const env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), [FRONT_DOOR_ENV]: '1' };
126
+ const env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), CRTR_SUBTREE: rootOfSpine(root.node_id), [FRONT_DOOR_ENV]: '1' };
111
127
  const ok = recycleFocusPane(root.node_id, pane, {
112
128
  command: piCommand(inv.argv), env, cwd: meta.cwd, name: fullName(fresh), resuming: false,
113
129
  });
114
- return { demoted: ok, finalized, newRoot: root.node_id, delivered };
130
+ return { recycled: ok, finalized, newRoot: root.node_id, delivered };
115
131
  }