@crouton-kit/crouter 0.3.13 → 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 (233) 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/__tests__/human.test.js +73 -2
  35. package/dist/commands/attention.js +76 -7
  36. package/dist/commands/canvas-prune.d.ts +2 -0
  37. package/dist/commands/canvas-prune.js +66 -0
  38. package/dist/commands/canvas.js +5 -8
  39. package/dist/commands/chord.d.ts +2 -0
  40. package/dist/commands/chord.js +143 -0
  41. package/dist/commands/daemon.js +8 -5
  42. package/dist/commands/dashboard.js +2 -0
  43. package/dist/commands/human/prompts.js +28 -27
  44. package/dist/commands/human/queue.d.ts +1 -0
  45. package/dist/commands/human/queue.js +105 -2
  46. package/dist/commands/human/shared.d.ts +28 -18
  47. package/dist/commands/human/shared.js +53 -60
  48. package/dist/commands/human.js +6 -14
  49. package/dist/commands/node.d.ts +11 -0
  50. package/dist/commands/node.js +381 -87
  51. package/dist/commands/pkg/market-inspect.js +6 -4
  52. package/dist/commands/pkg/market-manage.js +10 -6
  53. package/dist/commands/pkg/market.js +2 -4
  54. package/dist/commands/pkg/plugin-inspect.js +6 -4
  55. package/dist/commands/pkg/plugin-manage.js +12 -7
  56. package/dist/commands/pkg/plugin.js +2 -4
  57. package/dist/commands/pkg.js +0 -4
  58. package/dist/commands/push.js +178 -15
  59. package/dist/commands/revive.js +5 -3
  60. package/dist/commands/skill/author.js +6 -4
  61. package/dist/commands/skill/find.js +8 -5
  62. package/dist/commands/skill/read.js +2 -0
  63. package/dist/commands/skill/state.js +6 -4
  64. package/dist/commands/skill.js +0 -6
  65. package/dist/commands/sys/config.js +21 -7
  66. package/dist/commands/sys/doctor.js +2 -0
  67. package/dist/commands/sys/update.js +4 -0
  68. package/dist/commands/sys.js +0 -6
  69. package/dist/commands/tmux-spread.d.ts +2 -0
  70. package/dist/commands/tmux-spread.js +130 -0
  71. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  72. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  73. package/dist/core/__tests__/child-followup.test.js +83 -0
  74. package/dist/core/__tests__/close.test.d.ts +1 -0
  75. package/dist/core/__tests__/close.test.js +148 -0
  76. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  77. package/dist/core/__tests__/context-intro.test.js +196 -0
  78. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  80. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  81. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  82. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  83. package/dist/core/__tests__/focuses.test.js +259 -0
  84. package/dist/core/__tests__/fork.test.d.ts +1 -0
  85. package/dist/core/__tests__/fork.test.js +91 -0
  86. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  87. package/dist/core/__tests__/home-session.test.js +153 -0
  88. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  89. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  90. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  91. package/dist/core/__tests__/keystone.test.js +185 -0
  92. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  93. package/dist/core/__tests__/kickoff.test.js +89 -0
  94. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  95. package/dist/core/__tests__/lifecycle.test.js +178 -0
  96. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  97. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  98. package/dist/core/__tests__/memory.test.d.ts +1 -0
  99. package/dist/core/__tests__/memory.test.js +152 -0
  100. package/dist/core/__tests__/migration.test.d.ts +1 -0
  101. package/dist/core/__tests__/migration.test.js +238 -0
  102. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  103. package/dist/core/__tests__/pane-column.test.js +153 -0
  104. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  105. package/dist/core/__tests__/passive-subscription.test.js +164 -0
  106. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  107. package/dist/core/__tests__/persona-compose.test.js +53 -0
  108. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  109. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  110. package/dist/core/__tests__/persona.test.d.ts +1 -0
  111. package/dist/core/__tests__/persona.test.js +107 -0
  112. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  113. package/dist/core/__tests__/placement-focus.test.js +244 -0
  114. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  115. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  116. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  117. package/dist/core/__tests__/placement-revive.test.js +238 -0
  118. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  119. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  120. package/dist/core/__tests__/prune.test.d.ts +1 -0
  121. package/dist/core/__tests__/prune.test.js +116 -0
  122. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  123. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  124. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  125. package/dist/core/__tests__/relaunch.test.js +328 -0
  126. package/dist/core/__tests__/reset.test.js +26 -7
  127. package/dist/core/__tests__/revive.test.d.ts +1 -0
  128. package/dist/core/__tests__/revive.test.js +217 -0
  129. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  130. package/dist/core/__tests__/spawn-root.test.js +73 -0
  131. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  132. package/dist/core/__tests__/steer-note.test.js +39 -0
  133. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  134. package/dist/core/__tests__/stop-guard.test.js +82 -0
  135. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  136. package/dist/core/__tests__/subcommand-tier.test.js +99 -0
  137. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  138. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  139. package/dist/core/__tests__/unknown-path.test.js +8 -2
  140. package/dist/core/canvas/attention.d.ts +10 -0
  141. package/dist/core/canvas/attention.js +40 -0
  142. package/dist/core/canvas/canvas.d.ts +66 -7
  143. package/dist/core/canvas/canvas.js +209 -21
  144. package/dist/core/canvas/db.d.ts +8 -0
  145. package/dist/core/canvas/db.js +206 -4
  146. package/dist/core/canvas/focuses.d.ts +22 -0
  147. package/dist/core/canvas/focuses.js +80 -0
  148. package/dist/core/canvas/index.d.ts +3 -0
  149. package/dist/core/canvas/index.js +3 -0
  150. package/dist/core/canvas/labels.d.ts +27 -0
  151. package/dist/core/canvas/labels.js +36 -0
  152. package/dist/core/canvas/paths.d.ts +4 -0
  153. package/dist/core/canvas/paths.js +6 -0
  154. package/dist/core/canvas/render.js +25 -10
  155. package/dist/core/canvas/telemetry.d.ts +14 -0
  156. package/dist/core/canvas/telemetry.js +35 -0
  157. package/dist/core/canvas/types.d.ts +115 -12
  158. package/dist/core/command.d.ts +25 -1
  159. package/dist/core/command.js +48 -7
  160. package/dist/core/config.js +36 -2
  161. package/dist/core/feed/feed.js +14 -12
  162. package/dist/core/feed/inbox.d.ts +3 -1
  163. package/dist/core/feed/inbox.js +45 -5
  164. package/dist/core/feed/passive.d.ts +17 -0
  165. package/dist/core/feed/passive.js +92 -0
  166. package/dist/core/help.d.ts +59 -13
  167. package/dist/core/help.js +73 -28
  168. package/dist/core/personas/index.d.ts +1 -1
  169. package/dist/core/personas/index.js +1 -1
  170. package/dist/core/personas/loader.d.ts +40 -1
  171. package/dist/core/personas/loader.js +63 -1
  172. package/dist/core/personas/resolve.d.ts +13 -6
  173. package/dist/core/personas/resolve.js +46 -34
  174. package/dist/core/runtime/bearings.d.ts +20 -0
  175. package/dist/core/runtime/bearings.js +92 -0
  176. package/dist/core/runtime/close.d.ts +14 -0
  177. package/dist/core/runtime/close.js +151 -0
  178. package/dist/core/runtime/demote.d.ts +14 -0
  179. package/dist/core/runtime/demote.js +120 -0
  180. package/dist/core/runtime/front-door.js +1 -1
  181. package/dist/core/runtime/kickoff.d.ts +32 -6
  182. package/dist/core/runtime/kickoff.js +111 -37
  183. package/dist/core/runtime/launch.d.ts +29 -6
  184. package/dist/core/runtime/launch.js +85 -13
  185. package/dist/core/runtime/lifecycle.d.ts +13 -0
  186. package/dist/core/runtime/lifecycle.js +86 -0
  187. package/dist/core/runtime/memory.d.ts +43 -0
  188. package/dist/core/runtime/memory.js +165 -0
  189. package/dist/core/runtime/naming.d.ts +22 -0
  190. package/dist/core/runtime/naming.js +166 -0
  191. package/dist/core/runtime/nodes.d.ts +32 -1
  192. package/dist/core/runtime/nodes.js +60 -10
  193. package/dist/core/runtime/persona.d.ts +25 -0
  194. package/dist/core/runtime/persona.js +139 -0
  195. package/dist/core/runtime/placement.d.ts +287 -0
  196. package/dist/core/runtime/placement.js +663 -0
  197. package/dist/core/runtime/presence.d.ts +7 -32
  198. package/dist/core/runtime/presence.js +90 -110
  199. package/dist/core/runtime/promote.d.ts +18 -7
  200. package/dist/core/runtime/promote.js +70 -65
  201. package/dist/core/runtime/reset.d.ts +47 -4
  202. package/dist/core/runtime/reset.js +223 -52
  203. package/dist/core/runtime/revive.d.ts +26 -2
  204. package/dist/core/runtime/revive.js +166 -39
  205. package/dist/core/runtime/roadmap.d.ts +5 -4
  206. package/dist/core/runtime/roadmap.js +9 -16
  207. package/dist/core/runtime/spawn.d.ts +20 -5
  208. package/dist/core/runtime/spawn.js +169 -44
  209. package/dist/core/runtime/stop-guard.d.ts +1 -1
  210. package/dist/core/runtime/stop-guard.js +18 -8
  211. package/dist/core/runtime/tmux.d.ts +106 -21
  212. package/dist/core/runtime/tmux.js +249 -45
  213. package/dist/core/spawn.js +15 -0
  214. package/dist/daemon/crtrd.d.ts +12 -1
  215. package/dist/daemon/crtrd.js +152 -34
  216. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  217. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  218. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  219. package/dist/pi-extensions/canvas-commands.js +103 -0
  220. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  221. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  222. package/dist/pi-extensions/canvas-goal-capture.d.ts +21 -0
  223. package/dist/pi-extensions/canvas-goal-capture.js +67 -0
  224. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  225. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  226. package/dist/pi-extensions/canvas-nav.js +586 -262
  227. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  228. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  229. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  230. package/dist/pi-extensions/canvas-stophook.js +344 -228
  231. package/dist/types.d.ts +28 -0
  232. package/dist/types.js +16 -0
  233. 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,27 +99,41 @@ export function openNodeWindow(opts) {
96
99
  ...envFlags(opts.env),
97
100
  opts.command,
98
101
  ]);
99
- return r.ok ? r.stdout : null;
100
- }
101
- /** Open a background window running a plain login shell (no pi) and return its
102
- * window + pane ids. Used by demote: the agent's pi is swapped OUT into this
103
- * window's slot and the shell is swapped INTO the caller's pane. `-a` keeps it
104
- * off index 0 (reserved for a dashboard), `-d` keeps it from stealing focus. */
105
- export function openShellWindow(opts) {
106
- const r = tmux([
107
- 'new-window', '-d', '-a', '-P',
108
- '-F', '#{window_id}\t#{pane_id}',
109
- '-t', `${opts.session}:`,
110
- '-n', opts.name,
111
- '-c', opts.cwd,
112
- ]);
113
102
  if (!r.ok)
114
103
  return null;
115
104
  const [window, pane] = r.stdout.split('\t');
116
- if (window === undefined || pane === undefined)
105
+ if (window === undefined || window === '' || pane === undefined || pane === '') {
117
106
  return null;
107
+ }
118
108
  return { window, pane };
119
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;
136
+ }
120
137
  /** Bring a node's window forefront. Switches client across roots when needed. */
121
138
  export function focusWindow(session, window) {
122
139
  const here = currentTmux();
@@ -131,6 +148,14 @@ export function focusWindow(session, window) {
131
148
  export function closeWindow(window) {
132
149
  return tmux(['kill-window', '-t', window]).ok;
133
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
+ }
134
159
  /** The active pane id of a window. Node windows are single-pane, so this is the
135
160
  * node's pane. Returns null if the window is gone or tmux fails. */
136
161
  export function paneOfWindow(session, window) {
@@ -145,6 +170,45 @@ export function windowOfPane(pane) {
145
170
  const r = tmux(['display-message', '-p', '-t', pane, '#{window_id}']);
146
171
  return r.ok && r.stdout !== '' ? r.stdout : null;
147
172
  }
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. */
178
+ export function paneLocation(pane) {
179
+ const r = tmux(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
180
+ if (!r.ok)
181
+ return null;
182
+ const [session, window] = r.stdout.split('\t');
183
+ if (session === undefined || session === '' || window === undefined || window === '')
184
+ return null;
185
+ return { session, window };
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
+ }
148
212
  /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
149
213
  * caller's window active, so the target's pane appears where the caller is
150
214
  * rather than navigating the client off to the target's window. The caller's
@@ -155,26 +219,34 @@ export function swapPaneInPlace(targetPane, callerPane) {
155
219
  return true;
156
220
  return tmux(['swap-pane', '-d', '-s', targetPane, '-t', callerPane]).ok;
157
221
  }
158
- /** Re-exec a command in an EXISTING pane, in place. `-k` kills the pane's
159
- * current process (e.g. a yielding pi) and starts `command` in the same pane
160
- * the window/pane survives, so an interactive session is never dropped to a
161
- * shell and no window churns. Used by refresh-yield.
162
- *
163
- * Spawned DETACHED (own process group, unref'd) so the request reaches the
164
- * tmux server even though killing the pane tears down the caller's own pi.
165
- * Returns true once the request was dispatched. */
166
- 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) {
167
245
  try {
168
- const child = spawn('tmux', [
169
- 'respawn-pane',
170
- '-k',
171
- '-c',
172
- opts.cwd,
173
- ...envFlags(opts.env),
174
- '-t',
175
- opts.pane,
176
- opts.command,
177
- ], { detached: true, stdio: 'ignore' });
246
+ const child = spawn('tmux', respawnPaneArgs(opts), {
247
+ detached: true,
248
+ stdio: 'ignore',
249
+ });
178
250
  child.unref();
179
251
  return true;
180
252
  }
@@ -182,6 +254,19 @@ export function respawnPane(opts) {
182
254
  return false;
183
255
  }
184
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
+ }
185
270
  // ---------------------------------------------------------------------------
186
271
  // pi command assembly
187
272
  // ---------------------------------------------------------------------------
@@ -224,21 +309,140 @@ export function switchClient(session) {
224
309
  return tmux(['switch-client', '-t', session]).ok;
225
310
  }
226
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
+ // ---------------------------------------------------------------------------
227
347
  // Prefix menu — Alt+C opens a which-key-style tmux display-menu of crouter
228
348
  // actions. Installed on the running server at root boot; idempotent (a re-bind
229
349
  // overwrites the previous one). Items shell out to `crtr`, passing the active
230
350
  // pane so an action targets the agent currently in front of you.
231
351
  // ---------------------------------------------------------------------------
232
- /** 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. */
233
361
  export function installMenuBinding() {
234
362
  const sess = nodeSession();
235
- return tmux([
363
+ const title = ' crtr ';
364
+ const items = [
365
+ // Promote types `/promote` into the agent's pane rather than shelling out:
366
+ // the slash command delivers the orchestration guidance into the node's
367
+ // context, which a bare `run-shell` (output discarded) could not.
368
+ { name: 'promote to orchestrator', key: 'o', cmd: `send-keys -t '#{pane_id}' '/promote' Enter` },
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}` },
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
+ }
409
+ // tmux's -x sets the menu's LEFT edge. To sit the box INSIDE the pane's
410
+ // top-right corner, shift x left by the box width (longest line + tmux chrome:
411
+ // borders + padding + the right-aligned mnemonic-key column) via format math.
412
+ const boxW = Math.max(title.length, ...items.map((i) => i.name.length)) + 6;
413
+ // Fine-tune nudges off the pane's top-right corner: a hair further left and
414
+ // one row down so the box doesn't kiss the pane border.
415
+ const nudgeX = 1; // extra columns left
416
+ const nudgeY = 3; // rows down
417
+ const args = [
236
418
  'bind-key', '-n', 'M-c', 'display-menu',
237
- '-T', '#[align=centre] crtr ',
238
- // Anchor to the top-right of the pane it was called from (tmux clamps it
239
- // back on-screen) rather than centring on the whole terminal.
240
- '-x', '#{pane_right}', '-y', '#{pane_top}',
241
- 'detach agent \u2192 background', 'd', `run-shell "crtr node demote --pane '#{pane_id}'"`,
242
- 'browse background agents', 'g', `switch-client -t ${sess}`,
419
+ '-T', `#[align=centre]${title}`,
420
+ '-x', `#{e|-:#{pane_right},${boxW + nudgeX}}`,
421
+ '-y', `#{e|+:#{pane_top},${nudgeY}}`,
422
+ ];
423
+ for (const it of items)
424
+ args.push(it.name, it.key, it.cmd);
425
+ return tmux(args).ok;
426
+ }
427
+ // ---------------------------------------------------------------------------
428
+ // Nav bindings — Alt+] / Alt+[ DFS-walk the canvas one window at a time. Each
429
+ // key shells out to `crtr node cycle`, passing the active pane so the walk is
430
+ // relative to the agent in front of you; cycle then swaps the next/prev node
431
+ // into that pane (like `node focus`). Output is discarded so the keypress never
432
+ // pops a results view. Installed at root boot alongside the Alt+C menu.
433
+ // ---------------------------------------------------------------------------
434
+ /** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
435
+ * false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
436
+ * terminal/tmux disambiguate it from a raw CSI introducer (`extended-keys on`).
437
+ */
438
+ export function installNavBindings() {
439
+ const next = tmux([
440
+ 'bind-key', '-n', 'M-]', 'run-shell',
441
+ `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`,
442
+ ]).ok;
443
+ const prev = tmux([
444
+ 'bind-key', '-n', 'M-[', 'run-shell',
445
+ `crtr node cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`,
243
446
  ]).ok;
447
+ return next && prev;
244
448
  }
@@ -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;