@crouton-kit/crouter 0.3.17 → 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 (283) 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/{base.md → PERSONA.md} +4 -0
  12. package/dist/builtin-personas/design/orchestrator.md +1 -1
  13. package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
  14. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  15. package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
  16. package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
  17. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  18. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  19. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  20. package/dist/builtin-personas/plan/{base.md → PERSONA.md} +4 -0
  21. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  22. package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
  23. package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
  24. package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
  25. package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
  26. package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
  27. package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
  28. package/dist/builtin-personas/runtime-base.md +6 -3
  29. package/dist/builtin-personas/spec/{base.md → PERSONA.md} +4 -0
  30. package/dist/builtin-personas/spec/orchestrator.md +1 -1
  31. package/dist/builtin-personas/waiting.md +8 -0
  32. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
  33. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
  34. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  35. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  36. package/dist/builtin-views/_lib/states.mjs +161 -0
  37. package/dist/builtin-views/canvas/client.mjs +303 -0
  38. package/dist/builtin-views/canvas/view.mjs +576 -0
  39. package/dist/builtin-views/git-pr/client.mjs +440 -0
  40. package/dist/builtin-views/git-pr/view.mjs +675 -0
  41. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  42. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  43. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  44. package/dist/builtin-views/inbox/view.mjs +889 -0
  45. package/dist/builtin-views/linkedin/client.mjs +610 -0
  46. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  47. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  48. package/dist/clients/attach/attach-cmd.js +354 -0
  49. package/dist/clients/attach/chat-view.d.ts +77 -0
  50. package/dist/clients/attach/chat-view.js +450 -0
  51. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  52. package/dist/clients/attach/clipboard-image.js +113 -0
  53. package/dist/clients/attach/config-load.d.ts +31 -0
  54. package/dist/clients/attach/config-load.js +113 -0
  55. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  56. package/dist/clients/attach/extension-dialogs.js +101 -0
  57. package/dist/clients/attach/input-controller.d.ts +54 -0
  58. package/dist/clients/attach/input-controller.js +204 -0
  59. package/dist/clients/attach/slash-commands.d.ts +36 -0
  60. package/dist/clients/attach/slash-commands.js +200 -0
  61. package/dist/clients/attach/view-socket.d.ts +48 -0
  62. package/dist/clients/attach/view-socket.js +126 -0
  63. package/dist/commands/attention.js +3 -3
  64. package/dist/commands/canvas-prune.js +1 -1
  65. package/dist/commands/daemon.js +4 -3
  66. package/dist/commands/human/prompts.js +4 -10
  67. package/dist/commands/human/queue.js +43 -8
  68. package/dist/commands/human/shared.d.ts +28 -1
  69. package/dist/commands/human/shared.js +48 -10
  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 +660 -65
  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-lifecycle.test.d.ts +1 -0
  107. package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
  108. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  109. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  110. package/dist/core/__tests__/cascade-close.test.js +12 -2
  111. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  112. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  113. package/dist/core/__tests__/context-intro.test.js +76 -62
  114. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  115. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  116. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  117. package/dist/core/__tests__/detach-focus.test.js +206 -0
  118. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  119. package/dist/core/__tests__/draw-style.test.js +258 -0
  120. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  121. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  122. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  123. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  124. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  125. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  126. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  127. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  128. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  129. package/dist/core/__tests__/helpers/harness.js +111 -1
  130. package/dist/core/__tests__/home-session.test.js +41 -8
  131. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  132. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  133. package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
  134. package/dist/core/__tests__/human-surface-target.test.js +98 -0
  135. package/dist/core/__tests__/kickoff.test.js +37 -3
  136. package/dist/core/__tests__/live-mutation.test.js +50 -33
  137. package/dist/core/__tests__/memory.test.js +23 -115
  138. package/dist/core/__tests__/persona-subkind.test.js +18 -15
  139. package/dist/core/__tests__/placement-focus.test.js +5 -0
  140. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  141. package/dist/core/__tests__/relaunch.test.js +4 -3
  142. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  143. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  144. package/dist/core/__tests__/spawn-root.test.js +10 -0
  145. package/dist/core/__tests__/spike-harness.test.js +1 -0
  146. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  147. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  148. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  149. package/dist/core/__tests__/wake-origin.test.js +110 -0
  150. package/dist/core/bootstrap.js +1 -1
  151. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  152. package/dist/core/canvas/browse/app.js +24 -2
  153. package/dist/core/canvas/browse/model.d.ts +38 -2
  154. package/dist/core/canvas/browse/model.js +134 -10
  155. package/dist/core/canvas/browse/render.d.ts +6 -12
  156. package/dist/core/canvas/browse/render.js +72 -104
  157. package/dist/core/canvas/canvas.js +15 -22
  158. package/dist/core/canvas/db.js +46 -0
  159. package/dist/core/canvas/index.d.ts +1 -0
  160. package/dist/core/canvas/index.js +1 -0
  161. package/dist/core/canvas/paths.d.ts +4 -1
  162. package/dist/core/canvas/paths.js +10 -4
  163. package/dist/core/canvas/pid.d.ts +4 -0
  164. package/dist/core/canvas/pid.js +23 -0
  165. package/dist/core/canvas/render.d.ts +11 -2
  166. package/dist/core/canvas/render.js +69 -0
  167. package/dist/core/canvas/types.d.ts +85 -3
  168. package/dist/core/canvas/types.js +2 -2
  169. package/dist/core/canvas/wakeups.d.ts +76 -0
  170. package/dist/core/canvas/wakeups.js +185 -0
  171. package/dist/core/config.js +4 -1
  172. package/dist/core/frontmatter.js +37 -124
  173. package/dist/core/help.d.ts +6 -0
  174. package/dist/core/help.js +7 -0
  175. package/dist/core/memory-resolver.d.ts +49 -0
  176. package/dist/core/memory-resolver.js +141 -0
  177. package/dist/core/personas/index.d.ts +4 -3
  178. package/dist/core/personas/index.js +3 -2
  179. package/dist/core/personas/loader.d.ts +41 -16
  180. package/dist/core/personas/loader.js +133 -29
  181. package/dist/core/personas/resolve.d.ts +4 -4
  182. package/dist/core/personas/resolve.js +28 -16
  183. package/dist/core/predicate.d.ts +63 -0
  184. package/dist/core/predicate.js +189 -0
  185. package/dist/core/resolver.js +26 -5
  186. package/dist/core/runtime/bearings.d.ts +53 -12
  187. package/dist/core/runtime/bearings.js +132 -59
  188. package/dist/core/runtime/broker-cli.d.ts +1 -0
  189. package/dist/core/runtime/broker-cli.js +46 -0
  190. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  191. package/dist/core/runtime/broker-protocol.js +153 -0
  192. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  193. package/dist/core/runtime/broker-sdk.js +72 -0
  194. package/dist/core/runtime/broker.d.ts +55 -0
  195. package/dist/core/runtime/broker.js +1128 -0
  196. package/dist/core/runtime/close.js +35 -6
  197. package/dist/core/runtime/host.d.ts +53 -0
  198. package/dist/core/runtime/host.js +186 -0
  199. package/dist/core/runtime/kickoff.d.ts +2 -1
  200. package/dist/core/runtime/kickoff.js +91 -5
  201. package/dist/core/runtime/launch.d.ts +45 -2
  202. package/dist/core/runtime/launch.js +65 -2
  203. package/dist/core/runtime/lifecycle.js +23 -6
  204. package/dist/core/runtime/memory.d.ts +2 -42
  205. package/dist/core/runtime/memory.js +11 -162
  206. package/dist/core/runtime/nodes.d.ts +33 -0
  207. package/dist/core/runtime/nodes.js +59 -1
  208. package/dist/core/runtime/persona.js +21 -11
  209. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  210. package/dist/core/runtime/pi-vendored.js +49 -0
  211. package/dist/core/runtime/placement.d.ts +42 -14
  212. package/dist/core/runtime/placement.js +228 -38
  213. package/dist/core/runtime/promote.d.ts +0 -6
  214. package/dist/core/runtime/promote.js +1 -12
  215. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  216. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  217. package/dist/core/runtime/reset.js +8 -6
  218. package/dist/core/runtime/revive.d.ts +2 -0
  219. package/dist/core/runtime/revive.js +34 -34
  220. package/dist/core/runtime/spawn.d.ts +19 -0
  221. package/dist/core/runtime/spawn.js +75 -22
  222. package/dist/core/runtime/stop-guard.d.ts +1 -1
  223. package/dist/core/runtime/stop-guard.js +6 -1
  224. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  225. package/dist/core/runtime/tmux-chrome.js +1 -1
  226. package/dist/core/runtime/tmux.d.ts +28 -0
  227. package/dist/core/runtime/tmux.js +80 -6
  228. package/dist/core/scope.d.ts +11 -0
  229. package/dist/core/scope.js +39 -0
  230. package/dist/core/spawn.d.ts +25 -1
  231. package/dist/core/spawn.js +72 -7
  232. package/dist/core/substrate/gate.d.ts +13 -0
  233. package/dist/core/substrate/gate.js +21 -0
  234. package/dist/core/substrate/index.d.ts +7 -0
  235. package/dist/core/substrate/index.js +18 -0
  236. package/dist/core/substrate/on-read.d.ts +14 -0
  237. package/dist/core/substrate/on-read.js +292 -0
  238. package/dist/core/substrate/render.d.ts +25 -0
  239. package/dist/core/substrate/render.js +256 -0
  240. package/dist/core/substrate/schema.d.ts +76 -0
  241. package/dist/core/substrate/schema.js +124 -0
  242. package/dist/core/substrate/session-cache.d.ts +30 -0
  243. package/dist/core/substrate/session-cache.js +77 -0
  244. package/dist/core/substrate/subject.d.ts +41 -0
  245. package/dist/core/substrate/subject.js +54 -0
  246. package/dist/core/tui/contract.d.ts +83 -0
  247. package/dist/core/tui/contract.js +8 -0
  248. package/dist/core/tui/draw.d.ts +96 -0
  249. package/dist/core/tui/draw.js +339 -0
  250. package/dist/core/tui/host.d.ts +29 -0
  251. package/dist/core/tui/host.js +379 -0
  252. package/dist/core/tui/loader.d.ts +16 -0
  253. package/dist/core/tui/loader.js +94 -0
  254. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  255. package/dist/core/wake.d.ts +86 -0
  256. package/dist/core/wake.js +308 -0
  257. package/dist/daemon/crtrd.d.ts +29 -4
  258. package/dist/daemon/crtrd.js +662 -46
  259. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  260. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  261. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  262. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  263. package/dist/pi-extensions/canvas-commands.js +10 -0
  264. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  265. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  266. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  267. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  268. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  269. package/dist/pi-extensions/canvas-nav.js +145 -34
  270. package/dist/pi-extensions/canvas-stophook.js +17 -8
  271. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  272. package/dist/pi-extensions/canvas-view.js +75 -0
  273. package/dist/prompts/skill.js +19 -26
  274. package/dist/prompts/view.d.ts +7 -0
  275. package/dist/prompts/view.js +101 -0
  276. package/dist/types.d.ts +4 -0
  277. package/dist/types.js +1 -0
  278. package/package.json +9 -4
  279. package/dist/commands/skill/find.d.ts +0 -4
  280. package/dist/commands/skill/find.js +0 -257
  281. package/dist/commands/skill/read.js +0 -91
  282. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  283. /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. */
@@ -15,6 +15,16 @@ export declare function focusByPane(pane: string): FocusRow | null;
15
15
  export declare function focusedNodes(): Set<string>;
16
16
  /** Every focus row (every live viewport). */
17
17
  export declare function listFocuses(): FocusRow[];
18
+ /** The on-screen viewport a human-in-the-loop prompt raised by `nodeId` should
19
+ * surface into: the HIGHEST FOCUSED node of nodeId's graph — the focused node
20
+ * closest to the graph root, i.e. the session/window the user is actually
21
+ * watching this work in. Walks nodeId's spine to its root, enumerates the whole
22
+ * tree root-first (`view` is BFS ⇒ shallowest first), and returns the focus row
23
+ * of the first node that occupies a viewport. null when nothing in the graph is
24
+ * on screen — the caller then surfaces in the user's attached pane rather than
25
+ * the backstage node session. PURE (db reads only): no tmux probe, so the pane
26
+ * may be stale; the caller liveness-checks before targeting it. */
27
+ export declare function graphSurfaceTarget(nodeId: string): FocusRow | null;
18
28
  /** The cached LOCATION as stored on a node row: the authoritative `pane` handle
19
29
  * plus its derived window/session cache. */
20
30
  export interface CachedLocation {
@@ -145,8 +155,10 @@ export declare function reviveTarget(focus: FocusRow | null, focusPaneAlive: boo
145
155
  * nothing else. That is the structural bug-kill.
146
156
  *
147
157
  * `setPresence` (the one atomic LOCATION write) records where the node landed.
148
- * CRTR_ROOT_SESSION is forced to `homeSession` in BOTH branches so the node's
149
- * 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. */
150
162
  export declare function reviveIntoPlacement(nodeId: string, launch: ReviveLaunch): PlacementResult;
151
163
  /** Relocate a node's still-running agent to the background `crtr` session,
152
164
  * freeing the foreground pane WITHOUT killing the pi. `break-pane` moves the
@@ -156,7 +168,15 @@ export declare function reviveIntoPlacement(nodeId: string, launch: ReviveLaunch
156
168
  * CURRENT location, §2.4) and again after (presence FOLLOWS the move). No-op
157
169
  * (false) when there is no live pane to relocate or tmux refuses the break.
158
170
  * `pane` is the authoritative node pane the caller acts on (the Alt+C menu's
159
- * `#{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). */
160
180
  export declare function detachToBackground(nodeId: string, pane?: string): boolean;
161
181
  /** A reviver: resume a DORMANT node into its backstage placement (a fresh `crtr`
162
182
  * window via reviveIntoPlacement). Injected so placement.ts need not import
@@ -267,20 +287,28 @@ export declare function recycleFocusPane(nodeId: string, pane: string, launch: R
267
287
  /** §1.6 lifecycle successor — hand a truly-done focused node's viewport to its
268
288
  * manager. Repoints the focus row `focusId` to `managerId` (a DB swap of the
269
289
  * occupant). Two takeover realizations, split on the manager's liveness:
270
- * - DORMANT manager (dead pi): the row repoint is all this does; the manager,
271
- * woken by the finished node's `push final` landing in its inbox, is revived
272
- * by the external daemon INTO this node's now-frozen focus pane
273
- * (remain-on-exit), where reviveIntoPlacement's focus-pane branch resumes it
274
- * 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).
275
298
  * - LIVE manager (pi alive in the backstage, the normal multi-child state):
276
299
  * the daemon never revives it (it only respawns dead-pi nodes), so we must
277
300
  * bring it into the viewport SYNCHRONOUSLY here — swap its backstage pane
278
301
  * into the focus slot (MAJOR 1). Otherwise the manager runs off-screen
279
302
  * forever while %m sits orphaned in the viewport and the focus row lies
280
303
  * about LOCATION.
281
- * Returns false — the caller closes the focus (Q1) when there is no manager,
282
- * the manager IS this node, or the manager already occupies another viewport
283
- * (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).
284
312
  *
285
313
  * Why the live swap is NOT the forbidden self-saw: `swap-pane -d` only EXCHANGES
286
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 { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, } from '../canvas/index.js';
28
+ import { spawnSync } from 'node:child_process';
29
+ import { join } from 'node:path';
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).
@@ -71,6 +75,27 @@ export function focusedNodes() {
71
75
  export function listFocuses() {
72
76
  return listFocusRows();
73
77
  }
78
+ // ---------------------------------------------------------------------------
79
+ // Graph → focus routing (for surfacing human-in-the-loop prompts)
80
+ // ---------------------------------------------------------------------------
81
+ /** The on-screen viewport a human-in-the-loop prompt raised by `nodeId` should
82
+ * surface into: the HIGHEST FOCUSED node of nodeId's graph — the focused node
83
+ * closest to the graph root, i.e. the session/window the user is actually
84
+ * watching this work in. Walks nodeId's spine to its root, enumerates the whole
85
+ * tree root-first (`view` is BFS ⇒ shallowest first), and returns the focus row
86
+ * of the first node that occupies a viewport. null when nothing in the graph is
87
+ * on screen — the caller then surfaces in the user's attached pane rather than
88
+ * the backstage node session. PURE (db reads only): no tmux probe, so the pane
89
+ * may be stale; the caller liveness-checks before targeting it. */
90
+ export function graphSurfaceTarget(nodeId) {
91
+ const root = rootOfSpine(nodeId);
92
+ for (const id of [root, ...view(root)]) {
93
+ const f = getFocusByNode(id);
94
+ if (f !== null && f.pane !== null)
95
+ return f;
96
+ }
97
+ return null;
98
+ }
74
99
  /** PURE reconciliation decision (§2.4) — unit-testable without a live tmux.
75
100
  * Given the cached row LOCATION and what tmux currently reports, decide the
76
101
  * presence patch. Mirrors the pure-core/impure-shell split (cf. `livenessVerdict`
@@ -209,8 +234,10 @@ export function reviveTarget(focus, focusPaneAlive, homeSession) {
209
234
  * nothing else. That is the structural bug-kill.
210
235
  *
211
236
  * `setPresence` (the one atomic LOCATION write) records where the node landed.
212
- * CRTR_ROOT_SESSION is forced to `homeSession` in BOTH branches so the node's
213
- * 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. */
214
241
  export function reviveIntoPlacement(nodeId, launch) {
215
242
  // §2.4 — follow any manual pane move before acting.
216
243
  reconcile(nodeId);
@@ -218,9 +245,12 @@ export function reviveIntoPlacement(nodeId, launch) {
218
245
  const focusPaneAlive = focus !== null && focus.pane !== null && paneExists(focus.pane);
219
246
  const homeSession = homeSessionOf(nodeId);
220
247
  const decision = reviveTarget(focus, focusPaneAlive, homeSession);
221
- // The node's children always spawn into the backstage (homeSession), never the
222
- // focus session — force it regardless of which branch the node itself takes.
223
- 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) };
224
254
  if (decision.kind === 'focus-pane') {
225
255
  // F3: resume the pi INTO the live focus pane, in its CURRENT session (Q4 —
226
256
  // reconcileFocus follows a user move of the focus pane). No new window.
@@ -263,7 +293,15 @@ export function reviveIntoPlacement(nodeId, launch) {
263
293
  * CURRENT location, §2.4) and again after (presence FOLLOWS the move). No-op
264
294
  * (false) when there is no live pane to relocate or tmux refuses the break.
265
295
  * `pane` is the authoritative node pane the caller acts on (the Alt+C menu's
266
- * `#{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). */
267
305
  export function detachToBackground(nodeId, pane) {
268
306
  reconcile(nodeId);
269
307
  const row = getRow(nodeId);
@@ -276,6 +314,28 @@ export function detachToBackground(nodeId, pane) {
276
314
  // follows the right pane.
277
315
  if (row.pane !== target)
278
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.
279
339
  const session = nodeSession();
280
340
  ensureSession(session, row.cwd);
281
341
  const ok = breakPaneToSession(target, session);
@@ -304,18 +364,6 @@ function holderId(focusId) {
304
364
  function newFocusId() {
305
365
  return `f-${newNodeId()}`;
306
366
  }
307
- /** signal-0 liveness probe for a pi pid (mirrors the daemon's isPidAlive). */
308
- function pidAlive(pid) {
309
- if (pid == null)
310
- return false;
311
- try {
312
- process.kill(pid, 0);
313
- return true;
314
- }
315
- catch (e) {
316
- return e.code === 'EPERM';
317
- }
318
- }
319
367
  /** Is a focus's OUTGOING occupant still GENERATING (a live pi actually MID-TURN)?
320
368
  * A still-generating node is moved to backstage by a retarget (F2 — it keeps
321
369
  * running off-screen); a holder / done / dormant / merely-parked node has its
@@ -327,7 +375,7 @@ function pidAlive(pid) {
327
375
  * pid between turns; pid-alive would misclassify it as "doing work" and leave
328
376
  * it stuck backstaged-active forever. `isBusy` is true only inside a turn, so a
329
377
  * parked viewer reads as not-generating and is released to dormant on
330
- * 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
331
379
  * mid-turn) harmless. */
332
380
  function isGenerating(nodeId) {
333
381
  const row = getRow(nodeId);
@@ -335,7 +383,7 @@ function isGenerating(nodeId) {
335
383
  return false;
336
384
  if (row.status !== 'active' && row.status !== 'idle')
337
385
  return false;
338
- return isBusy(nodeId) && pidAlive(row.pi_pid);
386
+ return isBusy(nodeId) && isPidAlive(row.pi_pid);
339
387
  }
340
388
  export function outgoingDisposition(o) {
341
389
  if (!o.exists)
@@ -535,6 +583,18 @@ export function focus(nodeId, opts) {
535
583
  const meta = getNode(nodeId);
536
584
  if (meta === null)
537
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);
538
598
  const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
539
599
  if (callerPane === undefined || callerPane === '') {
540
600
  // Not in tmux — no viewport to swap into. Reconcile and report status.
@@ -564,6 +624,105 @@ export function focus(nodeId, opts) {
564
624
  }
565
625
  return retargetFocus(f.focus_id, nodeId, opts.revive);
566
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
+ }
567
726
  /** Register the caller's CURRENT pane as a focus so a `node focus`/`cycle` from a
568
727
  * pane that isn't yet a viewport retargets IN PLACE. Occupied by whatever node
569
728
  * sits in the pane now (`callerNode`, else resolved by pane→row), or a HOLDER
@@ -625,20 +784,28 @@ export function recycleFocusPane(nodeId, pane, launch) {
625
784
  /** §1.6 lifecycle successor — hand a truly-done focused node's viewport to its
626
785
  * manager. Repoints the focus row `focusId` to `managerId` (a DB swap of the
627
786
  * occupant). Two takeover realizations, split on the manager's liveness:
628
- * - DORMANT manager (dead pi): the row repoint is all this does; the manager,
629
- * woken by the finished node's `push final` landing in its inbox, is revived
630
- * by the external daemon INTO this node's now-frozen focus pane
631
- * (remain-on-exit), where reviveIntoPlacement's focus-pane branch resumes it
632
- * 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).
633
795
  * - LIVE manager (pi alive in the backstage, the normal multi-child state):
634
796
  * the daemon never revives it (it only respawns dead-pi nodes), so we must
635
797
  * bring it into the viewport SYNCHRONOUSLY here — swap its backstage pane
636
798
  * into the focus slot (MAJOR 1). Otherwise the manager runs off-screen
637
799
  * forever while %m sits orphaned in the viewport and the focus row lies
638
800
  * about LOCATION.
639
- * Returns false — the caller closes the focus (Q1) when there is no manager,
640
- * the manager IS this node, or the manager already occupies another viewport
641
- * (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).
642
809
  *
643
810
  * Why the live swap is NOT the forbidden self-saw: `swap-pane -d` only EXCHANGES
644
811
  * two panes' slot positions; it never respawns or kills the finishing node's own
@@ -654,19 +821,42 @@ export function handFocusToManager(focusId, managerId) {
654
821
  return false;
655
822
  if (getFocusByNode(managerId) !== null)
656
823
  return false; // manager already focused elsewhere
657
- setFocusOccupant(focusId, managerId);
658
- // MAJOR 1 — LIVE backstage manager → swap it into the focus slot now. DORMANT
659
- // managers (no live pane / dead pi) fall through unchanged: the daemon revives
660
- // them into the frozen %m async.
661
824
  const mgr = getRow(managerId);
662
- 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);
663
833
  const focusLoc = paneLocation(f.pane); // F2's window/session — the slot mgr swaps INTO (%m is currently there)
664
834
  if (swapPaneInPlace(mgr.pane, f.pane) && focusLoc !== null) {
665
835
  setFocusPane(f.focus_id, mgr.pane, focusLoc.session); // re-anchor the focus row to mgr's pane (now in F2)
666
836
  setPresence(managerId, { pane: mgr.pane, tmux_session: focusLoc.session, window: focusLoc.window });
667
837
  }
838
+ return true; // took focus (live swap) — caller doesn't disarm
668
839
  }
669
- 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;
670
860
  }
671
861
  /** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
672
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
  }