@crouton-kit/crouter 0.3.14 → 0.3.15

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 (220) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/attention.js +76 -7
  35. package/dist/commands/canvas-prune.d.ts +2 -0
  36. package/dist/commands/canvas-prune.js +66 -0
  37. package/dist/commands/canvas.js +5 -8
  38. package/dist/commands/chord.d.ts +2 -0
  39. package/dist/commands/chord.js +143 -0
  40. package/dist/commands/daemon.js +8 -5
  41. package/dist/commands/dashboard.js +2 -0
  42. package/dist/commands/human/prompts.js +28 -27
  43. package/dist/commands/human/queue.js +30 -14
  44. package/dist/commands/human/shared.d.ts +26 -21
  45. package/dist/commands/human/shared.js +44 -66
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -98
  49. package/dist/commands/pkg/market-inspect.js +6 -4
  50. package/dist/commands/pkg/market-manage.js +10 -6
  51. package/dist/commands/pkg/market.js +2 -4
  52. package/dist/commands/pkg/plugin-inspect.js +6 -4
  53. package/dist/commands/pkg/plugin-manage.js +12 -7
  54. package/dist/commands/pkg/plugin.js +2 -4
  55. package/dist/commands/pkg.js +0 -4
  56. package/dist/commands/push.js +178 -15
  57. package/dist/commands/revive.js +5 -3
  58. package/dist/commands/skill/author.js +6 -4
  59. package/dist/commands/skill/find.js +8 -5
  60. package/dist/commands/skill/read.js +2 -0
  61. package/dist/commands/skill/state.js +6 -4
  62. package/dist/commands/skill.js +0 -6
  63. package/dist/commands/sys/config.js +21 -7
  64. package/dist/commands/sys/doctor.js +2 -0
  65. package/dist/commands/sys/update.js +4 -0
  66. package/dist/commands/sys.js +0 -6
  67. package/dist/commands/tmux-spread.d.ts +2 -0
  68. package/dist/commands/tmux-spread.js +130 -0
  69. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  70. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  71. package/dist/core/__tests__/child-followup.test.js +83 -0
  72. package/dist/core/__tests__/close.test.d.ts +1 -0
  73. package/dist/core/__tests__/close.test.js +148 -0
  74. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  75. package/dist/core/__tests__/context-intro.test.js +196 -0
  76. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  77. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  78. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  80. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  81. package/dist/core/__tests__/focuses.test.js +259 -0
  82. package/dist/core/__tests__/fork.test.d.ts +1 -0
  83. package/dist/core/__tests__/fork.test.js +91 -0
  84. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  85. package/dist/core/__tests__/home-session.test.js +153 -0
  86. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  87. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  88. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  89. package/dist/core/__tests__/keystone.test.js +185 -0
  90. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  91. package/dist/core/__tests__/kickoff.test.js +89 -0
  92. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  93. package/dist/core/__tests__/lifecycle.test.js +178 -0
  94. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  95. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  96. package/dist/core/__tests__/memory.test.d.ts +1 -0
  97. package/dist/core/__tests__/memory.test.js +152 -0
  98. package/dist/core/__tests__/migration.test.d.ts +1 -0
  99. package/dist/core/__tests__/migration.test.js +238 -0
  100. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  101. package/dist/core/__tests__/pane-column.test.js +153 -0
  102. package/dist/core/__tests__/passive-subscription.test.js +24 -1
  103. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  104. package/dist/core/__tests__/persona-compose.test.js +53 -0
  105. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  106. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  107. package/dist/core/__tests__/persona.test.d.ts +1 -0
  108. package/dist/core/__tests__/persona.test.js +107 -0
  109. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  110. package/dist/core/__tests__/placement-focus.test.js +244 -0
  111. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  112. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  113. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  114. package/dist/core/__tests__/placement-revive.test.js +238 -0
  115. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  116. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  117. package/dist/core/__tests__/prune.test.d.ts +1 -0
  118. package/dist/core/__tests__/prune.test.js +116 -0
  119. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  120. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  121. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  122. package/dist/core/__tests__/relaunch.test.js +328 -0
  123. package/dist/core/__tests__/reset.test.js +26 -7
  124. package/dist/core/__tests__/revive.test.d.ts +1 -0
  125. package/dist/core/__tests__/revive.test.js +217 -0
  126. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  127. package/dist/core/__tests__/spawn-root.test.js +73 -0
  128. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  129. package/dist/core/__tests__/steer-note.test.js +39 -0
  130. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  131. package/dist/core/__tests__/stop-guard.test.js +82 -0
  132. package/dist/core/__tests__/subcommand-tier.test.js +35 -33
  133. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  134. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  135. package/dist/core/__tests__/unknown-path.test.js +8 -2
  136. package/dist/core/canvas/attention.d.ts +10 -0
  137. package/dist/core/canvas/attention.js +40 -0
  138. package/dist/core/canvas/canvas.d.ts +66 -7
  139. package/dist/core/canvas/canvas.js +209 -21
  140. package/dist/core/canvas/db.d.ts +8 -0
  141. package/dist/core/canvas/db.js +206 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +80 -0
  144. package/dist/core/canvas/index.d.ts +3 -0
  145. package/dist/core/canvas/index.js +3 -0
  146. package/dist/core/canvas/labels.d.ts +27 -0
  147. package/dist/core/canvas/labels.js +36 -0
  148. package/dist/core/canvas/render.js +25 -10
  149. package/dist/core/canvas/telemetry.d.ts +14 -0
  150. package/dist/core/canvas/telemetry.js +35 -0
  151. package/dist/core/canvas/types.d.ts +115 -12
  152. package/dist/core/command.d.ts +25 -1
  153. package/dist/core/command.js +23 -15
  154. package/dist/core/config.js +36 -2
  155. package/dist/core/feed/feed.js +3 -3
  156. package/dist/core/feed/inbox.d.ts +3 -1
  157. package/dist/core/feed/inbox.js +45 -5
  158. package/dist/core/feed/passive.js +24 -11
  159. package/dist/core/help.d.ts +26 -13
  160. package/dist/core/help.js +44 -37
  161. package/dist/core/personas/index.d.ts +1 -1
  162. package/dist/core/personas/index.js +1 -1
  163. package/dist/core/personas/loader.d.ts +40 -1
  164. package/dist/core/personas/loader.js +63 -1
  165. package/dist/core/personas/resolve.d.ts +13 -6
  166. package/dist/core/personas/resolve.js +46 -34
  167. package/dist/core/runtime/bearings.d.ts +20 -0
  168. package/dist/core/runtime/bearings.js +92 -0
  169. package/dist/core/runtime/close.d.ts +14 -0
  170. package/dist/core/runtime/close.js +151 -0
  171. package/dist/core/runtime/demote.js +27 -10
  172. package/dist/core/runtime/front-door.js +1 -1
  173. package/dist/core/runtime/kickoff.d.ts +23 -6
  174. package/dist/core/runtime/kickoff.js +92 -36
  175. package/dist/core/runtime/launch.d.ts +24 -12
  176. package/dist/core/runtime/launch.js +75 -19
  177. package/dist/core/runtime/lifecycle.d.ts +13 -0
  178. package/dist/core/runtime/lifecycle.js +86 -0
  179. package/dist/core/runtime/memory.d.ts +43 -0
  180. package/dist/core/runtime/memory.js +165 -0
  181. package/dist/core/runtime/naming.d.ts +22 -0
  182. package/dist/core/runtime/naming.js +166 -0
  183. package/dist/core/runtime/nodes.d.ts +32 -1
  184. package/dist/core/runtime/nodes.js +60 -10
  185. package/dist/core/runtime/persona.d.ts +25 -0
  186. package/dist/core/runtime/persona.js +139 -0
  187. package/dist/core/runtime/placement.d.ts +287 -0
  188. package/dist/core/runtime/placement.js +663 -0
  189. package/dist/core/runtime/presence.d.ts +7 -15
  190. package/dist/core/runtime/presence.js +90 -66
  191. package/dist/core/runtime/promote.d.ts +14 -7
  192. package/dist/core/runtime/promote.js +57 -67
  193. package/dist/core/runtime/reset.d.ts +47 -4
  194. package/dist/core/runtime/reset.js +223 -52
  195. package/dist/core/runtime/revive.d.ts +26 -2
  196. package/dist/core/runtime/revive.js +166 -39
  197. package/dist/core/runtime/spawn.d.ts +20 -5
  198. package/dist/core/runtime/spawn.js +163 -43
  199. package/dist/core/runtime/stop-guard.d.ts +1 -1
  200. package/dist/core/runtime/stop-guard.js +18 -8
  201. package/dist/core/runtime/tmux.d.ts +100 -14
  202. package/dist/core/runtime/tmux.js +201 -28
  203. package/dist/core/spawn.js +15 -0
  204. package/dist/daemon/crtrd.d.ts +12 -1
  205. package/dist/daemon/crtrd.js +152 -34
  206. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  207. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  208. package/dist/pi-extensions/canvas-commands.js +16 -13
  209. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  210. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  211. package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
  212. package/dist/pi-extensions/canvas-goal-capture.js +15 -1
  213. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  214. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  215. package/dist/pi-extensions/canvas-nav.js +586 -262
  216. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  217. package/dist/pi-extensions/canvas-stophook.js +344 -228
  218. package/dist/types.d.ts +28 -0
  219. package/dist/types.js +16 -0
  220. package/package.json +1 -1
@@ -8,6 +8,7 @@
8
8
  // root) or switch-client + select-window (across roots). done/dead nodes close
9
9
  // their window; reviving opens a fresh one.
10
10
  import { spawn, spawnSync } from 'node:child_process';
11
+ import { readConfig } from '../config.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Shell quoting + tmux invocation
13
14
  // ---------------------------------------------------------------------------
@@ -69,7 +70,8 @@ function envFlags(env) {
69
70
  }
70
71
  /** Open a background window for a node and run `command` in it. `-d` keeps it
71
72
  * detached so it doesn't steal focus or become the current window. Returns the
72
- * new window id.
73
+ * new window id AND the pane id it created (the durable `%pane_id`, LOCATION's
74
+ * anchor) — callers that only need the window destructure `.window`.
73
75
  *
74
76
  * Target is `${session}:` (trailing colon = the session, no window index) plus
75
77
  * `-a` (insert after the current window) so tmux allocates the next free index.
@@ -78,7 +80,8 @@ function envFlags(env) {
78
80
  * "create window failed: index N in use" whenever the active window is not the
79
81
  * last one (common when base-index is 0 but the live window sits at index 1).
80
82
  * `-a` also keeps node windows off index 0, which is reserved for the optional
81
- * dashboard. */
83
+ * dashboard. The explicit `-t ${session}:` target is the §2.2 HARD DRIVER
84
+ * INVARIANT — never let new-window fall back to tmux's global current session. */
82
85
  export function openNodeWindow(opts) {
83
86
  const r = tmux([
84
87
  'new-window',
@@ -86,7 +89,7 @@ export function openNodeWindow(opts) {
86
89
  '-a',
87
90
  '-P',
88
91
  '-F',
89
- '#{window_id}',
92
+ '#{window_id}\t#{pane_id}',
90
93
  '-t',
91
94
  `${opts.session}:`,
92
95
  '-n',
@@ -96,7 +99,40 @@ export function openNodeWindow(opts) {
96
99
  ...envFlags(opts.env),
97
100
  opts.command,
98
101
  ]);
99
- return r.ok ? r.stdout : null;
102
+ if (!r.ok)
103
+ return null;
104
+ const [window, pane] = r.stdout.split('\t');
105
+ if (window === undefined || window === '' || pane === undefined || pane === '') {
106
+ return null;
107
+ }
108
+ return { window, pane };
109
+ }
110
+ /** Split `targetPane`'s window, opening a NEW pane beside it running `command`,
111
+ * and return the new pane id (the durable `%id`). The ONLY new-pane-beside verb
112
+ * (Q3: a focus opened side-by-side). `-d` keeps the caller's pane active; `-h`
113
+ * makes the split side-by-side (left/right), the default for a focus viewport.
114
+ *
115
+ * §2.2 HARD DRIVER INVARIANT: `targetPane` is REQUIRED — a bare `split-window`
116
+ * would split tmux's global current pane, which can leak a pane into an
117
+ * unrelated user session (the exact bug this design kills). The explicit
118
+ * `-t <targetPane>` makes the destination structurally un-leakable. Returns
119
+ * null if tmux fails. */
120
+ export function splitWindow(targetPane, opts) {
121
+ const r = tmux([
122
+ 'split-window',
123
+ '-d',
124
+ ...(opts.vertical === true ? [] : ['-h']),
125
+ '-P',
126
+ '-F',
127
+ '#{pane_id}',
128
+ '-t',
129
+ targetPane,
130
+ '-c',
131
+ opts.cwd,
132
+ ...envFlags(opts.env),
133
+ opts.command,
134
+ ]);
135
+ return r.ok && r.stdout !== '' ? r.stdout : null;
100
136
  }
101
137
  /** Bring a node's window forefront. Switches client across roots when needed. */
102
138
  export function focusWindow(session, window) {
@@ -112,6 +148,14 @@ export function focusWindow(session, window) {
112
148
  export function closeWindow(window) {
113
149
  return tmux(['kill-window', '-t', window]).ok;
114
150
  }
151
+ /** Close a single PANE. Its window closes automatically once this was the last
152
+ * pane, but sibling panes survive — so co-located nodes (several agents sharing
153
+ * one window via swap-pane focus) are torn down one at a time instead of all
154
+ * at once by a window kill. Pane ids are the stable vehicle handle; windows
155
+ * shift under swap-pane focus, so pane-granular teardown is the correct unit. */
156
+ export function closePane(pane) {
157
+ return tmux(['kill-pane', '-t', pane]).ok;
158
+ }
115
159
  /** The active pane id of a window. Node windows are single-pane, so this is the
116
160
  * node's pane. Returns null if the window is gone or tmux fails. */
117
161
  export function paneOfWindow(session, window) {
@@ -126,8 +170,11 @@ export function windowOfPane(pane) {
126
170
  const r = tmux(['display-message', '-p', '-t', pane, '#{window_id}']);
127
171
  return r.ok && r.stdout !== '' ? r.stdout : null;
128
172
  }
129
- /** The session + window a pane currently lives in. Used by demote to place the
130
- * recycled root's meta on the pane it respawns into. Null if tmux fails. */
173
+ /** The session + window a pane currently lives in (`display-message -p -t %id`).
174
+ * The §2.4 reconciliation read-back: resolve a node's/focus's CURRENT
175
+ * window/session from its durable pane id before any act, so crtr follows a
176
+ * manual `move-pane`/`join-pane`/`break-pane` instead of fighting it. Null if
177
+ * the pane is gone or tmux fails. */
131
178
  export function paneLocation(pane) {
132
179
  const r = tmux(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
133
180
  if (!r.ok)
@@ -137,6 +184,31 @@ export function paneLocation(pane) {
137
184
  return null;
138
185
  return { session, window };
139
186
  }
187
+ /** Does this pane id still exist? A `display-message` probe on the `%id` — the
188
+ * v3 PRIMARY liveness probe (§1.2/§2.2), replacing window-existence so a user
189
+ * moving a pane to another window/session never reads as "gone". True iff tmux
190
+ * knows the pane.
191
+ *
192
+ * NOTE: `display-message -p -t <gone-pane>` EXITS 0 with EMPTY output (it does
193
+ * not error on an unresolvable pane target) — so an `.ok` check alone would
194
+ * report a dead pane as alive, defeating the whole point of pane-existence
195
+ * liveness. We therefore require the echoed `#{pane_id}` to equal the requested
196
+ * pane: a live pane echoes its own id, a gone/bogus one yields empty. */
197
+ export function paneExists(pane) {
198
+ const r = tmux(['display-message', '-p', '-t', pane, '#{pane_id}']);
199
+ return r.ok && r.stdout === pane;
200
+ }
201
+ /** Relocate a pane into another session as its own window WITHOUT killing the
202
+ * process in it — `break-pane -d` moves the pane out of its current window (the
203
+ * pi keeps generating) into a fresh window in `session`; `-d` leaves the caller's
204
+ * client where it is rather than following the pane to the background, and `-a`
205
+ * allocates the next free window index (same dodge as openNodeWindow). The
206
+ * "detach to background" driver behind `node lifecycle --detach`. Best-effort;
207
+ * false if tmux refuses (e.g. the pane is gone). The caller reconciles presence
208
+ * so the canvas follows the move. */
209
+ export function breakPaneToSession(pane, session) {
210
+ return tmux(['break-pane', '-d', '-a', '-s', pane, '-t', `${session}:`]).ok;
211
+ }
140
212
  /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
141
213
  * caller's window active, so the target's pane appears where the caller is
142
214
  * rather than navigating the client off to the target's window. The caller's
@@ -147,26 +219,34 @@ export function swapPaneInPlace(targetPane, callerPane) {
147
219
  return true;
148
220
  return tmux(['swap-pane', '-d', '-s', targetPane, '-t', callerPane]).ok;
149
221
  }
150
- /** Re-exec a command in an EXISTING pane, in place. `-k` kills the pane's
151
- * current process (e.g. a yielding pi) and starts `command` in the same pane
152
- * the window/pane survives, so an interactive session is never dropped to a
153
- * shell and no window churns. Used by refresh-yield.
154
- *
155
- * Spawned DETACHED (own process group, unref'd) so the request reaches the
156
- * tmux server even though killing the pane tears down the caller's own pi.
157
- * Returns true once the request was dispatched. */
158
- export function respawnPane(opts) {
222
+ /** The `respawn-pane -k` argv for `opts`. `-k` kills the pane's current process
223
+ * (e.g. a yielding pi) and re-execs `command` in the SAME pane, preserving its
224
+ * `%id` (§1.5 F3: a frozen focus pane resumes in place, no new window). The
225
+ * explicit `-t opts.pane` is the §2.2 HARD DRIVER INVARIANT — respawn must name
226
+ * its target pane, never tmux's global current pane. */
227
+ function respawnPaneArgs(opts) {
228
+ return [
229
+ 'respawn-pane',
230
+ '-k',
231
+ '-c',
232
+ opts.cwd,
233
+ ...envFlags(opts.env),
234
+ '-t',
235
+ opts.pane,
236
+ opts.command,
237
+ ];
238
+ }
239
+ /** Re-exec a command in an EXISTING pane, in place — DETACHED. Spawned in its own
240
+ * process group (unref'd) so the request reaches the tmux server even though
241
+ * `-k` tears down the caller's own pi mid-flight. Used when a node respawns ITS
242
+ * OWN pane (refresh-yield): the dispatch can't be awaited because it kills the
243
+ * awaiter. Returns true once the request was dispatched. */
244
+ export function respawnPaneDetached(opts) {
159
245
  try {
160
- const child = spawn('tmux', [
161
- 'respawn-pane',
162
- '-k',
163
- '-c',
164
- opts.cwd,
165
- ...envFlags(opts.env),
166
- '-t',
167
- opts.pane,
168
- opts.command,
169
- ], { detached: true, stdio: 'ignore' });
246
+ const child = spawn('tmux', respawnPaneArgs(opts), {
247
+ detached: true,
248
+ stdio: 'ignore',
249
+ });
170
250
  child.unref();
171
251
  return true;
172
252
  }
@@ -174,6 +254,19 @@ export function respawnPane(opts) {
174
254
  return false;
175
255
  }
176
256
  }
257
+ /** Re-exec a command in an EXISTING pane, in place — SYNCHRONOUS. Runs the
258
+ * `respawn-pane` to completion and reports the real exit status. Used when the
259
+ * caller is NOT the pane being respawned (e.g. the daemon resuming a frozen
260
+ * focus pane), so it can confirm the respawn landed. Returns true on success. */
261
+ export function respawnPaneSync(opts) {
262
+ return tmux(respawnPaneArgs(opts)).ok;
263
+ }
264
+ /** @deprecated Use respawnPaneDetached. Retained so existing refresh-yield
265
+ * callers stay green while the placement layer migrates onto the explicit
266
+ * sync/detached split. */
267
+ export function respawnPane(opts) {
268
+ return respawnPaneDetached(opts);
269
+ }
177
270
  // ---------------------------------------------------------------------------
178
271
  // pi command assembly
179
272
  // ---------------------------------------------------------------------------
@@ -216,12 +309,55 @@ export function switchClient(session) {
216
309
  return tmux(['switch-client', '-t', session]).ok;
217
310
  }
218
311
  // ---------------------------------------------------------------------------
312
+ // Multi-pane layout (used by `canvas tmux-spread`)
313
+ // ---------------------------------------------------------------------------
314
+ /** Move a source pane into a destination window (`tmux join-pane`). The source
315
+ * pane's running process (e.g. a child's live pi) is preserved; its now-empty
316
+ * source window auto-closes. Best-effort; false if tmux fails. */
317
+ export function joinPane(srcPane, dstWindow) {
318
+ return tmux(['join-pane', '-s', srcPane, '-t', dstWindow]).ok;
319
+ }
320
+ /** Apply a named tmux layout to a window (`tmux select-layout`). Use
321
+ * `main-vertical` for one wide pane on the left + the rest stacked right.
322
+ * Best-effort; never throws. */
323
+ export function selectLayout(window, layout) {
324
+ return tmux(['select-layout', '-t', window, layout]).ok;
325
+ }
326
+ /** Set a tmux window option (`tmux set-window-option`). Used to size the main
327
+ * pane (`main-pane-width`) before a main-vertical layout. Best-effort. */
328
+ export function setWindowOption(window, name, value) {
329
+ return tmux(['set-window-option', '-t', window, name, value]).ok;
330
+ }
331
+ /** Toggle `remain-on-exit` on a window (§1.5 F3). `on` keeps a focus pane on
332
+ * screen after its pi exits — the viewport survives (F1), the final transcript
333
+ * is preserved, and `respawn-pane -k` can resurrect the node into the SAME pane
334
+ * id. NOTE (§1.5/§2.5, spike-confirmed): a dead/frozen pane is reaped only by
335
+ * `kill-pane`/`respawn-pane`, NEVER by toggling this off — the toggle does not
336
+ * reap an already-dead pane. Best-effort; never throws. */
337
+ export function setRemainOnExit(window, on) {
338
+ return tmux(['set-window-option', '-t', window, 'remain-on-exit', on ? 'on' : 'off']).ok;
339
+ }
340
+ /** Type a literal (e.g. a `/graph` slash command) into a pane and press Enter
341
+ * (`tmux send-keys -t <pane> '<text>' Enter`). Requires the pane's editor be
342
+ * empty, same limitation as the menu's `/promote` item. Best-effort. */
343
+ export function sendKeysEnter(pane, text) {
344
+ return tmux(['send-keys', '-t', pane, text, 'Enter']).ok;
345
+ }
346
+ // ---------------------------------------------------------------------------
219
347
  // Prefix menu — Alt+C opens a which-key-style tmux display-menu of crouter
220
348
  // actions. Installed on the running server at root boot; idempotent (a re-bind
221
349
  // overwrites the previous one). Items shell out to `crtr`, passing the active
222
350
  // pane so an action targets the agent currently in front of you.
223
351
  // ---------------------------------------------------------------------------
224
- /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
352
+ /** Reserved mnemonic keys owned by the built-in menu items below a custom
353
+ * `prefixBind` may not claim these (the built-in item wins). */
354
+ const RESERVED_MENU_KEYS = new Set(['o', 'd', 'D', 'x', 'b']);
355
+ /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails.
356
+ * The built-in items (promote/demote/detach/close/browse) are static; the canvas-nav
357
+ * chords (graph/manager/expand/report-N + any custom prefixBind) are appended
358
+ * from `canvasNav.prefixBinds`, each routed through `crtr canvas chord` (or, for
359
+ * the `__graph__` sentinel, a `send-keys '/graph'`) so the menu stays static
360
+ * while behaviour is config-driven. */
225
361
  export function installMenuBinding() {
226
362
  const sess = nodeSession();
227
363
  const title = ' crtr ';
@@ -230,9 +366,46 @@ export function installMenuBinding() {
230
366
  // the slash command delivers the orchestration guidance into the node's
231
367
  // context, which a bare `run-shell` (output discarded) could not.
232
368
  { name: 'promote to orchestrator', key: 'o', cmd: `send-keys -t '#{pane_id}' '/promote' Enter` },
233
- { name: 'finish agent + recycle pane', key: 'd', cmd: `run-shell "crtr node demote --pane '#{pane_id}'"` },
234
- { name: 'browse background agents', key: 'g', cmd: `switch-client -t ${sess}` },
369
+ // `d` demotes the agent to TERMINAL in place: no finalize, no kill it keeps
370
+ // running where it is, and because it is now terminal it is forced to push a
371
+ // final up the spine when it finishes. `D` ALSO detaches it to the background
372
+ // `crtr` session (frees the pane; the pi keeps generating). Neither ends it.
373
+ { name: 'demote to terminal', key: 'd', cmd: `run-shell "crtr node lifecycle terminal --pane '#{pane_id}' >/dev/null 2>&1"` },
374
+ { name: 'detach to background', key: 'D', cmd: `run-shell "crtr node lifecycle terminal --pane '#{pane_id}' --detach >/dev/null 2>&1"` },
375
+ // Close cascades down the subscribes_to spine (kills the subtree's windows,
376
+ // marks them canceled); revivable. Output discarded — the keypress just acts.
377
+ { name: 'close agent + subtree', key: 'x', cmd: `run-shell "crtr node close --pane '#{pane_id}' >/dev/null 2>&1"` },
378
+ // Re-keyed g→b so `g` is free for the canvas-nav GRAPH toggle (below).
379
+ { name: 'browse background agents', key: 'b', cmd: `switch-client -t ${sess}` },
235
380
  ];
381
+ // Canvas-nav chords from config (default: g→graph, m→manager, e→expand). The
382
+ // `__graph__` sentinel toggles the in-pi GRAPH modal via send-keys; every
383
+ // other bind shells the chord dispatcher, which resolves the pane's node and
384
+ // interpolates the bind at popup time. Keys colliding with the built-ins are
385
+ // skipped (the built-in wins).
386
+ let prefixBinds = {};
387
+ try {
388
+ prefixBinds = readConfig('user').canvasNav.prefixBinds;
389
+ }
390
+ catch { /* defaults below */ }
391
+ for (const [key, bind] of Object.entries(prefixBinds)) {
392
+ if (key.length !== 1 || RESERVED_MENU_KEYS.has(key))
393
+ continue;
394
+ const name = bind.desc !== undefined && bind.desc !== '' ? bind.desc : `chord ${key}`;
395
+ const cmd = bind.run === '__graph__'
396
+ ? `send-keys -t '#{pane_id}' '/graph' Enter`
397
+ : `run-shell "crtr canvas chord --pane '#{pane_id}' --key ${key} >/dev/null 2>&1"`;
398
+ items.push({ name, key, cmd });
399
+ }
400
+ // Focus report N: nine generated chord items (1..9), each resolved by the
401
+ // dispatcher to subscriptionsOf(self)[N-1] at popup time.
402
+ for (let n = 1; n <= 9; n++) {
403
+ items.push({
404
+ name: `focus report ${n}`,
405
+ key: `${n}`,
406
+ cmd: `run-shell "crtr canvas chord --pane '#{pane_id}' --key ${n} >/dev/null 2>&1"`,
407
+ });
408
+ }
236
409
  // tmux's -x sets the menu's LEFT edge. To sit the box INSIDE the pane's
237
410
  // top-right corner, shift x left by the box width (longest line + tmux chrome:
238
411
  // borders + padding + the right-aligned mnemonic-key column) via format math.
@@ -78,6 +78,21 @@ export function spawnAndDetach(opts) {
78
78
  return { status: 'spawn-failed', message: msg };
79
79
  }
80
80
  const paneId = split.stdout.trim();
81
+ // Force `remain-on-exit off` at PANE scope on the new pane. remain-on-exit is
82
+ // a pane option (tmux 3.x) inherited from the window-scoped value, and the
83
+ // canvas runtime arms `remain-on-exit on` on a node's vehicle/focus WINDOW
84
+ // (F3 freeze, see runtime/tmux.ts setRemainOnExit). A split-window pane opened
85
+ // into that window inherits the `on`, so the humanloop TUI pane would linger
86
+ // as a dead pane ("pane is dead (status 0, …)") when `crtr human _run` exits 0
87
+ // instead of closing. Overriding at pane scope destroys this pane on clean
88
+ // exit WITHOUT touching the window's value (focus freeze still works) or the
89
+ // user's global config. Best-effort: harmless no-op on tmux where the option
90
+ // is window-only.
91
+ if (paneId !== '') {
92
+ spawnSync('tmux', ['set-option', '-p', '-t', paneId, 'remain-on-exit', 'off'], {
93
+ stdio: 'ignore',
94
+ });
95
+ }
81
96
  // Schedule self-kill of the originating pane.
82
97
  scheduleKillCurrentPane(opts.killAfterSeconds);
83
98
  return {
@@ -1,9 +1,20 @@
1
+ export type LivenessVerdict = 'leave' | 'pending' | 'revive';
2
+ /** Decide what to do with a node whose tmux pane is alive, from its pi
3
+ * liveness and how long it's been dead. Pure — the time-and-tmux side effects
4
+ * live in handleLiveWindow; this is the unit-testable core.
5
+ * piPidAlive: true=alive, false=dead, null=no pid recorded (legacy node, or a
6
+ * relaunch in flight) — leave those to the pane-gone pass.
7
+ * deadFor: ms since first observed dead, or null on the first observation. */
8
+ export declare function livenessVerdict(piPidAlive: boolean | null, deadFor: number | null): LivenessVerdict;
1
9
  /** Read the pid stored in the pidfile, or null if absent / malformed. */
2
10
  export declare function readPidfile(): number | null;
3
- /** True if a process with `pid` is currently alive (signal-0 probe). */
11
+ /** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
12
+ * 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
13
+ * ours — still alive. */
4
14
  export declare function isPidAlive(pid: number): boolean;
5
15
  /** True when a crtrd process is already running (pidfile exists + pid alive). */
6
16
  export declare function isDaemonRunning(): boolean;
17
+ export declare function superviseTick(now?: number): Promise<void>;
7
18
  export interface DaemonOpts {
8
19
  /** Milliseconds between supervision polls. Default 2000. */
9
20
  intervalMs?: number;
@@ -3,15 +3,18 @@
3
3
  // Sole responsibility: supervise tmux window exit and revive nodes. No
4
4
  // orchestration logic lives here. The daemon is a process-lifecycle watcher.
5
5
  //
6
- // Model
6
+ // Model (v3: liveness is PANE-existence, not window-existence — a manual
7
+ // move-pane/join-pane/break-pane must never read as a node death)
7
8
  // • Poll every intervalMs (default 2000ms).
8
- // • For each active|idle node: check whether its tmux window is still alive.
9
- // Window alive healthy, skip.
10
- // • Window gone + intent==='refresh' fresh respawn (node asked to yield).
11
- // Window gone + intent==='idle-release' node freed its own pane while
9
+ // • For each active|idle node: check whether its tmux PANE is still alive
10
+ // (isNodePaneAlive; window-existence is only a legacy/no-pane fallback).
11
+ // • Pane alive reconcile its LOCATION (follow any manual move; lazy-backfill
12
+ // a legacy row's pane), then judge pi liveness healthy, skip otherwise.
13
+ // • Pane gone + intent==='refresh' → fresh respawn (node asked to yield).
14
+ // • Pane gone + intent==='idle-release' → node freed its own pane while
12
15
  // dormant; clear the stale window ref and revive (resume) when its inbox
13
16
  // gains an unseen entry.
14
- // • Window gone + any other intent → crash: mark 'dead'.
17
+ // • Pane gone + any other intent → crash: mark 'dead'.
15
18
  // • Nodes with no tmux placement (inline roots) are skipped.
16
19
  //
17
20
  // Single-instance guarantee
@@ -21,11 +24,91 @@
21
24
  import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, } from 'node:fs';
22
25
  import { join } from 'node:path';
23
26
  import { crtrHome } from '../core/canvas/paths.js';
24
- import { listNodes, setStatus, getNode, updateNode, } from '../core/canvas/index.js';
25
- import { windowAlive } from '../core/runtime/tmux.js';
27
+ import { listNodes, getRow, setPresence, getNode, } from '../core/canvas/index.js';
28
+ import { transition } from '../core/runtime/lifecycle.js';
29
+ import { isNodePaneAlive, reconcile } from '../core/runtime/placement.js';
26
30
  import { reviveNode } from '../core/runtime/revive.js';
31
+ import { pushUrgent } from '../core/feed/feed.js';
27
32
  import { readInboxSince, readCursor } from '../core/feed/inbox.js';
33
+ /** Surface a vehicle that never booted.
34
+ *
35
+ * `spawnChild` returns status="active" the instant the tmux window opens — it
36
+ * does NOT wait for pi to come up, because boot is inherently slow (and slower
37
+ * under load) and racing it would either block the spawner or false-fail a
38
+ * slow-but-healthy launch. The cost of that optimism: a pi that dies before its
39
+ * first session_start (so `pi_session_id` was never recorded) is invisible —
40
+ * the parent believes its child is running. When the daemon later finds the
41
+ * pane gone with no session ever bound, it errors LOUDLY up the spine: an
42
+ * urgent push so the parent learns the child failed to launch instead of just
43
+ * seeing a silent `dead`. */
44
+ async function surfaceBootFailure(meta) {
45
+ const body = `⚠ Spawn failed — \`${meta.name}\` (${meta.kind}) never started.\n\n` +
46
+ `Its pi vehicle exited before the session came up (no pi_session_id was ever ` +
47
+ `recorded), so the node produced no output. This is almost always a transient ` +
48
+ `launch failure — e.g. resource pressure when several nodes boot at once — not ` +
49
+ `a fault in the task itself.\n\n` +
50
+ `If the work still needs doing, re-spawn it; if spawns keep dying, spawn fewer at a time.`;
51
+ await pushUrgent(meta.node_id, body, { from: meta.node_id });
52
+ }
28
53
  const DEFAULT_INTERVAL_MS = 2000;
54
+ // How long a node's pi may be observed dead-while-its-window-lives before the
55
+ // daemon revives it. MUST exceed worst-case pi boot time: a normal in-place
56
+ // refresh (reviveInPlace) transiently shows a dead OLD pid for the gap between
57
+ // the old pi dying and the fresh pi booting + re-recording its pid, and we must
58
+ // not double-spawn into that gap.
59
+ const REVIVE_GRACE_MS = 20_000;
60
+ // Per-node first-observed-dead timestamps, for the grace window above. In-memory
61
+ // only — a daemon restart resets it (worst case: one extra grace interval).
62
+ const unhealthySince = new Map();
63
+ /** Decide what to do with a node whose tmux pane is alive, from its pi
64
+ * liveness and how long it's been dead. Pure — the time-and-tmux side effects
65
+ * live in handleLiveWindow; this is the unit-testable core.
66
+ * piPidAlive: true=alive, false=dead, null=no pid recorded (legacy node, or a
67
+ * relaunch in flight) — leave those to the pane-gone pass.
68
+ * deadFor: ms since first observed dead, or null on the first observation. */
69
+ export function livenessVerdict(piPidAlive, deadFor) {
70
+ if (piPidAlive !== false)
71
+ return 'leave';
72
+ if (deadFor === null || deadFor < REVIVE_GRACE_MS)
73
+ return 'pending';
74
+ return 'revive';
75
+ }
76
+ /** A node whose tmux PANE is alive: pane-existence does NOT prove pi is
77
+ * alive (an inline root runs pi under a persistent login shell that survives
78
+ * pi's death), so gauge liveness on the recorded pid and revive a dead pi once
79
+ * it's been dead past the grace window. */
80
+ function handleLiveWindow(row, now) {
81
+ const id = row.node_id;
82
+ // A deliberately-frozen focused-dormant node (intent=idle-release) keeps its
83
+ // pane alive via remain-on-exit (F3, §3c). Do NOT grace-revive it here — it is
84
+ // waiting for a worker's inbox push, which the second pass delivers. Grace-
85
+ // reviving would pre-empt that and churn the frozen focus pane.
86
+ if (row.intent === 'idle-release') {
87
+ unhealthySince.delete(id);
88
+ return;
89
+ }
90
+ const pid = row.pi_pid;
91
+ const piPidAlive = pid == null ? null : isPidAlive(pid);
92
+ if (piPidAlive !== false) {
93
+ unhealthySince.delete(id); // alive, or no pid to judge — nothing pending
94
+ return;
95
+ }
96
+ const since = unhealthySince.get(id);
97
+ const verdict = livenessVerdict(piPidAlive, since === undefined ? null : now - since);
98
+ if (verdict === 'pending') {
99
+ if (since === undefined)
100
+ unhealthySince.set(id, now);
101
+ return;
102
+ }
103
+ // 'revive' — pi has been dead past the grace window while its window lived on.
104
+ unhealthySince.delete(id);
105
+ // A refresh-yield wants fresh context (re-read the roadmap); any other death
106
+ // resumes the saved conversation. reviveNode opens a fresh window and clears
107
+ // pi_pid, so the next tick won't re-fire on this stale pid.
108
+ const resume = row.intent !== 'refresh';
109
+ process.stderr.write(`[crtrd] revive ${id} (pi dead, pane alive, intent=${String(row.intent)})\n`);
110
+ reviveNode(id, { resume });
111
+ }
29
112
  // ---------------------------------------------------------------------------
30
113
  // Pidfile
31
114
  // ---------------------------------------------------------------------------
@@ -54,14 +137,16 @@ export function readPidfile() {
54
137
  const n = Number(raw);
55
138
  return Number.isFinite(n) && n > 0 ? n : null;
56
139
  }
57
- /** True if a process with `pid` is currently alive (signal-0 probe). */
140
+ /** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
141
+ * 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
142
+ * ours — still alive. */
58
143
  export function isPidAlive(pid) {
59
144
  try {
60
145
  process.kill(pid, 0);
61
146
  return true;
62
147
  }
63
- catch {
64
- return false;
148
+ catch (e) {
149
+ return e.code === 'EPERM';
65
150
  }
66
151
  }
67
152
  /** True when a crtrd process is already running (pidfile exists + pid alive). */
@@ -72,7 +157,7 @@ export function isDaemonRunning() {
72
157
  // ---------------------------------------------------------------------------
73
158
  // Supervisor tick
74
159
  // ---------------------------------------------------------------------------
75
- async function superviseTick() {
160
+ export async function superviseTick(now = Date.now()) {
76
161
  let rows;
77
162
  try {
78
163
  rows = listNodes({ status: ['active', 'idle'] });
@@ -83,33 +168,62 @@ async function superviseTick() {
83
168
  }
84
169
  for (const row of rows) {
85
170
  try {
86
- // listNodes returns the lightweight NodeRow; we need the full NodeMeta
87
- // for tmux_session, window, intent, and pi_session_id.
88
- const meta = getNode(row.node_id);
89
- if (meta === null)
90
- continue; // vanished between list and get
91
- // Nodes without tmux placement are inline roots — not daemon-managed.
92
- if (meta.tmux_session == null || meta.window == null)
171
+ // Runtime (tmux_session, window, intent, pi_pid) is now authoritative IN
172
+ // the row no per-node getNode re-read. Only the boot-failure split below
173
+ // still needs identity (pi_session_id), read on demand there.
174
+ // Nodes with no tmux placement at all are inline roots — not daemon-
175
+ // managed. Pane-anchored: a node still counts as placed if it has a pane
176
+ // even when its derived window/session cache is null.
177
+ if (row.tmux_session == null && row.window == null && row.pane == null)
93
178
  continue;
94
- if (windowAlive(meta.tmux_session, meta.window))
95
- continue; // healthy
96
- // Window is gone. Branch on why.
97
- if (meta.intent === 'refresh') {
179
+ if (isNodePaneAlive(row)) {
180
+ // The pane is up — but that alone doesn't mean pi is. Reconcile first
181
+ // (follow any manual pane move, and lazy-backfill a legacy row's pane
182
+ // from its live window), then judge pi liveness off the fresh row. The
183
+ // alive-gate means reconcile here only ever FOLLOWS/backfills — never
184
+ // nulls the LOCATION out from under the gone-branches below.
185
+ reconcile(row.node_id);
186
+ handleLiveWindow(getRow(row.node_id) ?? row, now);
187
+ continue;
188
+ }
189
+ // The pane is gone. Branch on why.
190
+ unhealthySince.delete(row.node_id); // pane-gone path owns it now
191
+ if (row.intent === 'refresh') {
98
192
  // The node set intent=refresh before stopping — a clean yield. Respawn
99
193
  // fresh so it re-reads its roadmap/context dir.
100
194
  process.stderr.write(`[crtrd] revive ${row.node_id} (refresh-yield)\n`);
101
195
  reviveNode(row.node_id, { resume: false });
102
196
  }
103
- else if (meta.intent === 'idle-release') {
197
+ else if (row.intent === 'idle-release') {
104
198
  // The node freed its own window on purpose while dormant. Drop the stale
105
199
  // window ref and keep it 'idle'; the inbox-poll pass below revives it
106
200
  // (resume) the moment a subscribed worker delivers.
107
- updateNode(row.node_id, { window: null });
201
+ setPresence(row.node_id, { tmux_session: row.tmux_session, window: null });
108
202
  }
109
203
  else {
110
- // Window vanished without the node completing or refreshing a crash.
111
- process.stderr.write(`[crtrd] dead ${row.node_id} (window gone, intent=${String(meta.intent)})\n`);
112
- setStatus(row.node_id, 'dead');
204
+ // The pane vanished without the node completing or refreshing. Split the
205
+ // two ways that happens: a vehicle that NEVER BOOTED (pi exited before
206
+ // its first session_start, so pi_session_id is still null) versus a
207
+ // genuine mid-run CRASH (it had booted, so pi_session_id is set). Both
208
+ // are dead, but a never-booted node is a spawn failure the parent was
209
+ // never told about — surface it up the spine instead of dying quietly.
210
+ transition(row.node_id, 'crash');
211
+ // Boot-failed vs crashed turns on pi_session_id, an IDENTITY field — the
212
+ // one place this pass still reads meta. surfaceBootFailure also wants the
213
+ // full meta (name/kind) for its message.
214
+ const meta = getNode(row.node_id);
215
+ if (meta !== null && meta.pi_session_id == null) {
216
+ process.stderr.write(`[crtrd] boot-failed ${row.node_id} (pane gone before pi ever started)\n`);
217
+ try {
218
+ await surfaceBootFailure(meta);
219
+ }
220
+ catch (err) {
221
+ process.stderr.write(`[crtrd] surfaceBootFailure ${row.node_id} error: ${err.message}\n`);
222
+ }
223
+ }
224
+ else {
225
+ process.stderr.write(`[crtrd] dead ${row.node_id} (pane gone, intent=${String(row.intent)})\n`);
226
+ }
113
227
  }
114
228
  }
115
229
  catch (err) {
@@ -123,15 +237,19 @@ async function superviseTick() {
123
237
  // exit; any entry past it is undelivered work — resume the node to handle it.
124
238
  for (const row of rows) {
125
239
  try {
126
- const meta = getNode(row.node_id);
127
- if (meta === null)
240
+ // Re-read the ROW for fresh runtime (the first pass may have mutated it);
241
+ // no meta needed — status/intent/window/tmux_session all live in the row.
242
+ const r = getRow(row.node_id);
243
+ if (r === null)
128
244
  continue;
129
- if (meta.status !== 'idle' || meta.intent !== 'idle-release')
245
+ if (r.status !== 'idle' || r.intent !== 'idle-release')
130
246
  continue;
131
- // If a window is somehow alive, the in-process watcher owns delivery.
132
- if (meta.window != null && windowAlive(meta.tmux_session ?? '', meta.window)) {
247
+ // The in-process inbox-watcher only owns delivery while pi is actually LIVE.
248
+ // A frozen focused-dormant pane (remain-on-exit, F3) is pane-ALIVE but
249
+ // pi-DEAD — no watcher — so the daemon must wake it. Gate the skip on pi
250
+ // liveness, NOT pane presence (which would skip a frozen pane forever, §3c).
251
+ if (r.pi_pid != null && isPidAlive(r.pi_pid))
133
252
  continue;
134
- }
135
253
  const entries = readInboxSince(row.node_id, readCursor(row.node_id));
136
254
  if (entries.length > 0) {
137
255
  process.stderr.write(`[crtrd] revive ${row.node_id} (idle-release, inbox)\n`);