@crouton-kit/crouter 0.3.14 → 0.3.16

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 (224) 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 +45 -67
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -99
  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 +129 -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 +196 -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 +266 -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 +178 -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 +334 -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 +105 -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 +205 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +81 -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 +24 -12
  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 +26 -12
  176. package/dist/core/runtime/launch.js +78 -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 +39 -1
  184. package/dist/core/runtime/nodes.js +69 -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 +299 -0
  188. package/dist/core/runtime/placement.js +688 -0
  189. package/dist/core/runtime/promote.d.ts +14 -7
  190. package/dist/core/runtime/promote.js +57 -67
  191. package/dist/core/runtime/reset.d.ts +47 -4
  192. package/dist/core/runtime/reset.js +223 -52
  193. package/dist/core/runtime/revive.d.ts +26 -2
  194. package/dist/core/runtime/revive.js +166 -39
  195. package/dist/core/runtime/spawn.d.ts +20 -5
  196. package/dist/core/runtime/spawn.js +163 -43
  197. package/dist/core/runtime/stop-guard.d.ts +1 -1
  198. package/dist/core/runtime/stop-guard.js +18 -8
  199. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  200. package/dist/core/runtime/tmux-chrome.js +4 -0
  201. package/dist/core/runtime/tmux.d.ts +113 -20
  202. package/dist/core/runtime/tmux.js +221 -39
  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 +594 -262
  216. package/dist/pi-extensions/canvas-resume.d.ts +22 -0
  217. package/dist/pi-extensions/canvas-resume.js +173 -0
  218. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  219. package/dist/pi-extensions/canvas-stophook.js +340 -228
  220. package/dist/types.d.ts +28 -0
  221. package/dist/types.js +16 -0
  222. package/package.json +2 -2
  223. package/dist/core/runtime/presence.d.ts +0 -38
  224. package/dist/core/runtime/presence.js +0 -154
@@ -1,11 +1,6 @@
1
1
  /** POSIX single-quote escaping for one shell word. */
2
2
  export declare function shellQuote(s: string): string;
3
3
  export declare function inTmux(): boolean;
4
- /** The single, shared tmux session that ALL canvas node windows live in.
5
- * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
6
- * child opens a window here rather than cluttering the user's own working
7
- * session — switch to it to browse the whole live graph, ignore it otherwise. */
8
- export declare function nodeSession(): string;
9
4
  export interface TmuxLocation {
10
5
  session: string;
11
6
  window: string;
@@ -28,7 +23,8 @@ export interface OpenWindowOpts {
28
23
  }
29
24
  /** Open a background window for a node and run `command` in it. `-d` keeps it
30
25
  * detached so it doesn't steal focus or become the current window. Returns the
31
- * new window id.
26
+ * new window id AND the pane id it created (the durable `%pane_id`, LOCATION's
27
+ * anchor) — callers that only need the window destructure `.window`.
32
28
  *
33
29
  * Target is `${session}:` (trailing colon = the session, no window index) plus
34
30
  * `-a` (insert after the current window) so tmux allocates the next free index.
@@ -37,12 +33,41 @@ export interface OpenWindowOpts {
37
33
  * "create window failed: index N in use" whenever the active window is not the
38
34
  * last one (common when base-index is 0 but the live window sits at index 1).
39
35
  * `-a` also keeps node windows off index 0, which is reserved for the optional
40
- * dashboard. */
41
- export declare function openNodeWindow(opts: OpenWindowOpts): string | null;
36
+ * dashboard. The explicit `-t ${session}:` target is the §2.2 HARD DRIVER
37
+ * INVARIANT never let new-window fall back to tmux's global current session. */
38
+ export declare function openNodeWindow(opts: OpenWindowOpts): {
39
+ window: string;
40
+ pane: string;
41
+ } | null;
42
+ export interface SplitWindowOpts {
43
+ cwd: string;
44
+ env: Record<string, string>;
45
+ /** The full command to run in the new pane (already a shell string). */
46
+ command: string;
47
+ /** Stack the new pane below instead of beside (default: beside, `-h`). */
48
+ vertical?: boolean;
49
+ }
50
+ /** Split `targetPane`'s window, opening a NEW pane beside it running `command`,
51
+ * and return the new pane id (the durable `%id`). The ONLY new-pane-beside verb
52
+ * (Q3: a focus opened side-by-side). `-d` keeps the caller's pane active; `-h`
53
+ * makes the split side-by-side (left/right), the default for a focus viewport.
54
+ *
55
+ * §2.2 HARD DRIVER INVARIANT: `targetPane` is REQUIRED — a bare `split-window`
56
+ * would split tmux's global current pane, which can leak a pane into an
57
+ * unrelated user session (the exact bug this design kills). The explicit
58
+ * `-t <targetPane>` makes the destination structurally un-leakable. Returns
59
+ * null if tmux fails. */
60
+ export declare function splitWindow(targetPane: string, opts: SplitWindowOpts): string | null;
42
61
  /** Bring a node's window forefront. Switches client across roots when needed. */
43
62
  export declare function focusWindow(session: string, window: string): boolean;
44
63
  /** Close a node's window (drop it from the UI). */
45
64
  export declare function closeWindow(window: string): boolean;
65
+ /** Close a single PANE. Its window closes automatically once this was the last
66
+ * pane, but sibling panes survive — so co-located nodes (several agents sharing
67
+ * one window via swap-pane focus) are torn down one at a time instead of all
68
+ * at once by a window kill. Pane ids are the stable vehicle handle; windows
69
+ * shift under swap-pane focus, so pane-granular teardown is the correct unit. */
70
+ export declare function closePane(pane: string): boolean;
46
71
  /** The active pane id of a window. Node windows are single-pane, so this is the
47
72
  * node's pane. Returns null if the window is gone or tmux fails. */
48
73
  export declare function paneOfWindow(session: string, window: string): string | null;
@@ -51,12 +76,35 @@ export declare function paneOfWindow(session: string, window: string): string |
51
76
  * are not, so the node→window mapping must be re-derived from the pane. Returns
52
77
  * null if the pane is gone or tmux fails. */
53
78
  export declare function windowOfPane(pane: string): string | null;
54
- /** The session + window a pane currently lives in. Used by demote to place the
55
- * recycled root's meta on the pane it respawns into. Null if tmux fails. */
79
+ /** The session + window a pane currently lives in (`display-message -p -t %id`).
80
+ * The §2.4 reconciliation read-back: resolve a node's/focus's CURRENT
81
+ * window/session from its durable pane id before any act, so crtr follows a
82
+ * manual `move-pane`/`join-pane`/`break-pane` instead of fighting it. Null if
83
+ * the pane is gone or tmux fails. */
56
84
  export declare function paneLocation(pane: string): {
57
85
  session: string;
58
86
  window: string;
59
87
  } | null;
88
+ /** Does this pane id still exist? A `display-message` probe on the `%id` — the
89
+ * v3 PRIMARY liveness probe (§1.2/§2.2), replacing window-existence so a user
90
+ * moving a pane to another window/session never reads as "gone". True iff tmux
91
+ * knows the pane.
92
+ *
93
+ * NOTE: `display-message -p -t <gone-pane>` EXITS 0 with EMPTY output (it does
94
+ * not error on an unresolvable pane target) — so an `.ok` check alone would
95
+ * report a dead pane as alive, defeating the whole point of pane-existence
96
+ * liveness. We therefore require the echoed `#{pane_id}` to equal the requested
97
+ * pane: a live pane echoes its own id, a gone/bogus one yields empty. */
98
+ export declare function paneExists(pane: string): boolean;
99
+ /** Relocate a pane into another session as its own window WITHOUT killing the
100
+ * process in it — `break-pane -d` moves the pane out of its current window (the
101
+ * pi keeps generating) into a fresh window in `session`; `-d` leaves the caller's
102
+ * client where it is rather than following the pane to the background, and `-a`
103
+ * allocates the next free window index (same dodge as openNodeWindow). The
104
+ * "detach to background" driver behind `node lifecycle --detach`. Best-effort;
105
+ * false if tmux refuses (e.g. the pane is gone). The caller reconciles presence
106
+ * so the canvas follows the move. */
107
+ export declare function breakPaneToSession(pane: string, session: string): boolean;
60
108
  /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
61
109
  * caller's window active, so the target's pane appears where the caller is
62
110
  * rather than navigating the client off to the target's window. The caller's
@@ -71,16 +119,34 @@ export interface RespawnPaneOpts {
71
119
  /** The full command to run in the pane (already a shell string). */
72
120
  command: string;
73
121
  }
74
- /** Re-exec a command in an EXISTING pane, in place. `-k` kills the pane's
75
- * current process (e.g. a yielding pi) and starts `command` in the same pane
76
- * the window/pane survives, so an interactive session is never dropped to a
77
- * shell and no window churns. Used by refresh-yield.
78
- *
79
- * Spawned DETACHED (own process group, unref'd) so the request reaches the
80
- * tmux server even though killing the pane tears down the caller's own pi.
81
- * Returns true once the request was dispatched. */
122
+ /** Re-exec a command in an EXISTING pane, in place — DETACHED. Spawned in its own
123
+ * process group (unref'd) so the request reaches the tmux server even though
124
+ * `-k` tears down the caller's own pi mid-flight. Used when a node respawns ITS
125
+ * OWN pane (refresh-yield): the dispatch can't be awaited because it kills the
126
+ * awaiter. Returns true once the request was dispatched. */
127
+ export declare function respawnPaneDetached(opts: RespawnPaneOpts): boolean;
128
+ /** Re-exec a command in an EXISTING pane, in place SYNCHRONOUS. Runs the
129
+ * `respawn-pane` to completion and reports the real exit status. Used when the
130
+ * caller is NOT the pane being respawned (e.g. the daemon resuming a frozen
131
+ * focus pane), so it can confirm the respawn landed. Returns true on success. */
132
+ export declare function respawnPaneSync(opts: RespawnPaneOpts): boolean;
133
+ /** @deprecated Use respawnPaneDetached. Retained so existing refresh-yield
134
+ * callers stay green while the placement layer migrates onto the explicit
135
+ * sync/detached split. */
82
136
  export declare function respawnPane(opts: RespawnPaneOpts): boolean;
83
- /** Turn a pi argv array into a single shell command string. */
137
+ /** Turn a pi argv array into a single shell command string.
138
+ *
139
+ * The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
140
+ * literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
141
+ * unset (every production path) the behavior is byte-identical to exec'ing
142
+ * `pi`. The integration-test harness points it at a deterministic fake-pi
143
+ * vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
144
+ * without any dependence on tmux/shell PATH inheritance — the substitution is
145
+ * baked into the command string at build time, in the process that calls
146
+ * piCommand. An explicit `binary` arg still overrides the env (no caller passes
147
+ * one today). The value may be a multi-word launcher (e.g. `node --import
148
+ * tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
149
+ * binary is spliced verbatim ahead of them. */
84
150
  export declare function piCommand(argv: string[], binary?: string): string;
85
151
  /** List all window ids present in `session`. Returns [] if the session does
86
152
  * not exist or tmux fails for any reason. Each entry is the raw window id
@@ -97,7 +163,34 @@ export declare function selectWindow(session: string, window: string): boolean;
97
163
  * `tmux switch-client -t <session>`. Best-effort; never throws. The caller is
98
164
  * responsible for following up with selectWindow to land on the right window. */
99
165
  export declare function switchClient(session: string): boolean;
100
- /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
166
+ /** Move a source pane into a destination window (`tmux join-pane`). The source
167
+ * pane's running process (e.g. a child's live pi) is preserved; its now-empty
168
+ * source window auto-closes. Best-effort; false if tmux fails. */
169
+ export declare function joinPane(srcPane: string, dstWindow: string): boolean;
170
+ /** Apply a named tmux layout to a window (`tmux select-layout`). Use
171
+ * `main-vertical` for one wide pane on the left + the rest stacked right.
172
+ * Best-effort; never throws. */
173
+ export declare function selectLayout(window: string, layout: string): boolean;
174
+ /** Set a tmux window option (`tmux set-window-option`). Used to size the main
175
+ * pane (`main-pane-width`) before a main-vertical layout. Best-effort. */
176
+ export declare function setWindowOption(window: string, name: string, value: string): boolean;
177
+ /** Toggle `remain-on-exit` on a window (§1.5 F3). `on` keeps a focus pane on
178
+ * screen after its pi exits — the viewport survives (F1), the final transcript
179
+ * is preserved, and `respawn-pane -k` can resurrect the node into the SAME pane
180
+ * id. NOTE (§1.5/§2.5, spike-confirmed): a dead/frozen pane is reaped only by
181
+ * `kill-pane`/`respawn-pane`, NEVER by toggling this off — the toggle does not
182
+ * reap an already-dead pane. Best-effort; never throws. */
183
+ export declare function setRemainOnExit(window: string, on: boolean): boolean;
184
+ /** Type a literal (e.g. a `/graph` slash command) into a pane and press Enter
185
+ * (`tmux send-keys -t <pane> '<text>' Enter`). Requires the pane's editor be
186
+ * empty, same limitation as the menu's `/promote` item. Best-effort. */
187
+ export declare function sendKeysEnter(pane: string, text: string): boolean;
188
+ /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails.
189
+ * The built-in items (promote/demote/detach/close/browse) are static; the canvas-nav
190
+ * chords (graph/manager/expand/report-N + any custom prefixBind) are appended
191
+ * from `canvasNav.prefixBinds`, each routed through `crtr canvas chord` (or, for
192
+ * the `__graph__` sentinel, a `send-keys '/graph'`) so the menu stays static
193
+ * while behaviour is config-driven. */
101
194
  export declare function installMenuBinding(): boolean;
102
195
  /** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
103
196
  * false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
@@ -8,6 +8,8 @@
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';
12
+ import { nodeSession } from './nodes.js';
11
13
  // ---------------------------------------------------------------------------
12
14
  // Shell quoting + tmux invocation
13
15
  // ---------------------------------------------------------------------------
@@ -26,14 +28,6 @@ function tmux(args) {
26
28
  export function inTmux() {
27
29
  return process.env['TMUX'] !== undefined && process.env['TMUX'] !== '';
28
30
  }
29
- /** The single, shared tmux session that ALL canvas node windows live in.
30
- * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
31
- * child opens a window here rather than cluttering the user's own working
32
- * session — switch to it to browse the whole live graph, ignore it otherwise. */
33
- export function nodeSession() {
34
- const v = process.env['CRTR_NODE_SESSION'];
35
- return v !== undefined && v !== '' ? v : 'crtr';
36
- }
37
31
  /** Where the caller currently is, or null if not inside tmux. */
38
32
  export function currentTmux() {
39
33
  if (!inTmux())
@@ -69,7 +63,8 @@ function envFlags(env) {
69
63
  }
70
64
  /** Open a background window for a node and run `command` in it. `-d` keeps it
71
65
  * detached so it doesn't steal focus or become the current window. Returns the
72
- * new window id.
66
+ * new window id AND the pane id it created (the durable `%pane_id`, LOCATION's
67
+ * anchor) — callers that only need the window destructure `.window`.
73
68
  *
74
69
  * Target is `${session}:` (trailing colon = the session, no window index) plus
75
70
  * `-a` (insert after the current window) so tmux allocates the next free index.
@@ -78,7 +73,8 @@ function envFlags(env) {
78
73
  * "create window failed: index N in use" whenever the active window is not the
79
74
  * last one (common when base-index is 0 but the live window sits at index 1).
80
75
  * `-a` also keeps node windows off index 0, which is reserved for the optional
81
- * dashboard. */
76
+ * dashboard. The explicit `-t ${session}:` target is the §2.2 HARD DRIVER
77
+ * INVARIANT — never let new-window fall back to tmux's global current session. */
82
78
  export function openNodeWindow(opts) {
83
79
  const r = tmux([
84
80
  'new-window',
@@ -86,7 +82,7 @@ export function openNodeWindow(opts) {
86
82
  '-a',
87
83
  '-P',
88
84
  '-F',
89
- '#{window_id}',
85
+ '#{window_id}\t#{pane_id}',
90
86
  '-t',
91
87
  `${opts.session}:`,
92
88
  '-n',
@@ -96,7 +92,40 @@ export function openNodeWindow(opts) {
96
92
  ...envFlags(opts.env),
97
93
  opts.command,
98
94
  ]);
99
- return r.ok ? r.stdout : null;
95
+ if (!r.ok)
96
+ return null;
97
+ const [window, pane] = r.stdout.split('\t');
98
+ if (window === undefined || window === '' || pane === undefined || pane === '') {
99
+ return null;
100
+ }
101
+ return { window, pane };
102
+ }
103
+ /** Split `targetPane`'s window, opening a NEW pane beside it running `command`,
104
+ * and return the new pane id (the durable `%id`). The ONLY new-pane-beside verb
105
+ * (Q3: a focus opened side-by-side). `-d` keeps the caller's pane active; `-h`
106
+ * makes the split side-by-side (left/right), the default for a focus viewport.
107
+ *
108
+ * §2.2 HARD DRIVER INVARIANT: `targetPane` is REQUIRED — a bare `split-window`
109
+ * would split tmux's global current pane, which can leak a pane into an
110
+ * unrelated user session (the exact bug this design kills). The explicit
111
+ * `-t <targetPane>` makes the destination structurally un-leakable. Returns
112
+ * null if tmux fails. */
113
+ export function splitWindow(targetPane, opts) {
114
+ const r = tmux([
115
+ 'split-window',
116
+ '-d',
117
+ ...(opts.vertical === true ? [] : ['-h']),
118
+ '-P',
119
+ '-F',
120
+ '#{pane_id}',
121
+ '-t',
122
+ targetPane,
123
+ '-c',
124
+ opts.cwd,
125
+ ...envFlags(opts.env),
126
+ opts.command,
127
+ ]);
128
+ return r.ok && r.stdout !== '' ? r.stdout : null;
100
129
  }
101
130
  /** Bring a node's window forefront. Switches client across roots when needed. */
102
131
  export function focusWindow(session, window) {
@@ -112,6 +141,14 @@ export function focusWindow(session, window) {
112
141
  export function closeWindow(window) {
113
142
  return tmux(['kill-window', '-t', window]).ok;
114
143
  }
144
+ /** Close a single PANE. Its window closes automatically once this was the last
145
+ * pane, but sibling panes survive — so co-located nodes (several agents sharing
146
+ * one window via swap-pane focus) are torn down one at a time instead of all
147
+ * at once by a window kill. Pane ids are the stable vehicle handle; windows
148
+ * shift under swap-pane focus, so pane-granular teardown is the correct unit. */
149
+ export function closePane(pane) {
150
+ return tmux(['kill-pane', '-t', pane]).ok;
151
+ }
115
152
  /** The active pane id of a window. Node windows are single-pane, so this is the
116
153
  * node's pane. Returns null if the window is gone or tmux fails. */
117
154
  export function paneOfWindow(session, window) {
@@ -126,8 +163,11 @@ export function windowOfPane(pane) {
126
163
  const r = tmux(['display-message', '-p', '-t', pane, '#{window_id}']);
127
164
  return r.ok && r.stdout !== '' ? r.stdout : null;
128
165
  }
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. */
166
+ /** The session + window a pane currently lives in (`display-message -p -t %id`).
167
+ * The §2.4 reconciliation read-back: resolve a node's/focus's CURRENT
168
+ * window/session from its durable pane id before any act, so crtr follows a
169
+ * manual `move-pane`/`join-pane`/`break-pane` instead of fighting it. Null if
170
+ * the pane is gone or tmux fails. */
131
171
  export function paneLocation(pane) {
132
172
  const r = tmux(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
133
173
  if (!r.ok)
@@ -137,6 +177,31 @@ export function paneLocation(pane) {
137
177
  return null;
138
178
  return { session, window };
139
179
  }
180
+ /** Does this pane id still exist? A `display-message` probe on the `%id` — the
181
+ * v3 PRIMARY liveness probe (§1.2/§2.2), replacing window-existence so a user
182
+ * moving a pane to another window/session never reads as "gone". True iff tmux
183
+ * knows the pane.
184
+ *
185
+ * NOTE: `display-message -p -t <gone-pane>` EXITS 0 with EMPTY output (it does
186
+ * not error on an unresolvable pane target) — so an `.ok` check alone would
187
+ * report a dead pane as alive, defeating the whole point of pane-existence
188
+ * liveness. We therefore require the echoed `#{pane_id}` to equal the requested
189
+ * pane: a live pane echoes its own id, a gone/bogus one yields empty. */
190
+ export function paneExists(pane) {
191
+ const r = tmux(['display-message', '-p', '-t', pane, '#{pane_id}']);
192
+ return r.ok && r.stdout === pane;
193
+ }
194
+ /** Relocate a pane into another session as its own window WITHOUT killing the
195
+ * process in it — `break-pane -d` moves the pane out of its current window (the
196
+ * pi keeps generating) into a fresh window in `session`; `-d` leaves the caller's
197
+ * client where it is rather than following the pane to the background, and `-a`
198
+ * allocates the next free window index (same dodge as openNodeWindow). The
199
+ * "detach to background" driver behind `node lifecycle --detach`. Best-effort;
200
+ * false if tmux refuses (e.g. the pane is gone). The caller reconciles presence
201
+ * so the canvas follows the move. */
202
+ export function breakPaneToSession(pane, session) {
203
+ return tmux(['break-pane', '-d', '-a', '-s', pane, '-t', `${session}:`]).ok;
204
+ }
140
205
  /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
141
206
  * caller's window active, so the target's pane appears where the caller is
142
207
  * rather than navigating the client off to the target's window. The caller's
@@ -147,26 +212,34 @@ export function swapPaneInPlace(targetPane, callerPane) {
147
212
  return true;
148
213
  return tmux(['swap-pane', '-d', '-s', targetPane, '-t', callerPane]).ok;
149
214
  }
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) {
215
+ /** The `respawn-pane -k` argv for `opts`. `-k` kills the pane's current process
216
+ * (e.g. a yielding pi) and re-execs `command` in the SAME pane, preserving its
217
+ * `%id` (§1.5 F3: a frozen focus pane resumes in place, no new window). The
218
+ * explicit `-t opts.pane` is the §2.2 HARD DRIVER INVARIANT — respawn must name
219
+ * its target pane, never tmux's global current pane. */
220
+ function respawnPaneArgs(opts) {
221
+ return [
222
+ 'respawn-pane',
223
+ '-k',
224
+ '-c',
225
+ opts.cwd,
226
+ ...envFlags(opts.env),
227
+ '-t',
228
+ opts.pane,
229
+ opts.command,
230
+ ];
231
+ }
232
+ /** Re-exec a command in an EXISTING pane, in place — DETACHED. Spawned in its own
233
+ * process group (unref'd) so the request reaches the tmux server even though
234
+ * `-k` tears down the caller's own pi mid-flight. Used when a node respawns ITS
235
+ * OWN pane (refresh-yield): the dispatch can't be awaited because it kills the
236
+ * awaiter. Returns true once the request was dispatched. */
237
+ export function respawnPaneDetached(opts) {
159
238
  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' });
239
+ const child = spawn('tmux', respawnPaneArgs(opts), {
240
+ detached: true,
241
+ stdio: 'ignore',
242
+ });
170
243
  child.unref();
171
244
  return true;
172
245
  }
@@ -174,11 +247,36 @@ export function respawnPane(opts) {
174
247
  return false;
175
248
  }
176
249
  }
250
+ /** Re-exec a command in an EXISTING pane, in place — SYNCHRONOUS. Runs the
251
+ * `respawn-pane` to completion and reports the real exit status. Used when the
252
+ * caller is NOT the pane being respawned (e.g. the daemon resuming a frozen
253
+ * focus pane), so it can confirm the respawn landed. Returns true on success. */
254
+ export function respawnPaneSync(opts) {
255
+ return tmux(respawnPaneArgs(opts)).ok;
256
+ }
257
+ /** @deprecated Use respawnPaneDetached. Retained so existing refresh-yield
258
+ * callers stay green while the placement layer migrates onto the explicit
259
+ * sync/detached split. */
260
+ export function respawnPane(opts) {
261
+ return respawnPaneDetached(opts);
262
+ }
177
263
  // ---------------------------------------------------------------------------
178
264
  // pi command assembly
179
265
  // ---------------------------------------------------------------------------
180
- /** Turn a pi argv array into a single shell command string. */
181
- export function piCommand(argv, binary = 'pi') {
266
+ /** Turn a pi argv array into a single shell command string.
267
+ *
268
+ * The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
269
+ * literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
270
+ * unset (every production path) the behavior is byte-identical to exec'ing
271
+ * `pi`. The integration-test harness points it at a deterministic fake-pi
272
+ * vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
273
+ * without any dependence on tmux/shell PATH inheritance — the substitution is
274
+ * baked into the command string at build time, in the process that calls
275
+ * piCommand. An explicit `binary` arg still overrides the env (no caller passes
276
+ * one today). The value may be a multi-word launcher (e.g. `node --import
277
+ * tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
278
+ * binary is spliced verbatim ahead of them. */
279
+ export function piCommand(argv, binary = process.env['CRTR_PI_BINARY'] ?? 'pi') {
182
280
  return [binary, ...argv.map(shellQuote)].join(' ');
183
281
  }
184
282
  // ---------------------------------------------------------------------------
@@ -202,7 +300,7 @@ export function windowAlive(session, window) {
202
300
  return listWindowIds(session).includes(window);
203
301
  }
204
302
  // ---------------------------------------------------------------------------
205
- // Focus helpers (used by the presence layer)
303
+ // Focus helpers (used by the placement layer)
206
304
  // ---------------------------------------------------------------------------
207
305
  /** Activate a window within its session (same-session navigation). Equivalent
208
306
  * to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
@@ -216,12 +314,55 @@ export function switchClient(session) {
216
314
  return tmux(['switch-client', '-t', session]).ok;
217
315
  }
218
316
  // ---------------------------------------------------------------------------
317
+ // Multi-pane layout (used by `canvas tmux-spread`)
318
+ // ---------------------------------------------------------------------------
319
+ /** Move a source pane into a destination window (`tmux join-pane`). The source
320
+ * pane's running process (e.g. a child's live pi) is preserved; its now-empty
321
+ * source window auto-closes. Best-effort; false if tmux fails. */
322
+ export function joinPane(srcPane, dstWindow) {
323
+ return tmux(['join-pane', '-s', srcPane, '-t', dstWindow]).ok;
324
+ }
325
+ /** Apply a named tmux layout to a window (`tmux select-layout`). Use
326
+ * `main-vertical` for one wide pane on the left + the rest stacked right.
327
+ * Best-effort; never throws. */
328
+ export function selectLayout(window, layout) {
329
+ return tmux(['select-layout', '-t', window, layout]).ok;
330
+ }
331
+ /** Set a tmux window option (`tmux set-window-option`). Used to size the main
332
+ * pane (`main-pane-width`) before a main-vertical layout. Best-effort. */
333
+ export function setWindowOption(window, name, value) {
334
+ return tmux(['set-window-option', '-t', window, name, value]).ok;
335
+ }
336
+ /** Toggle `remain-on-exit` on a window (§1.5 F3). `on` keeps a focus pane on
337
+ * screen after its pi exits — the viewport survives (F1), the final transcript
338
+ * is preserved, and `respawn-pane -k` can resurrect the node into the SAME pane
339
+ * id. NOTE (§1.5/§2.5, spike-confirmed): a dead/frozen pane is reaped only by
340
+ * `kill-pane`/`respawn-pane`, NEVER by toggling this off — the toggle does not
341
+ * reap an already-dead pane. Best-effort; never throws. */
342
+ export function setRemainOnExit(window, on) {
343
+ return tmux(['set-window-option', '-t', window, 'remain-on-exit', on ? 'on' : 'off']).ok;
344
+ }
345
+ /** Type a literal (e.g. a `/graph` slash command) into a pane and press Enter
346
+ * (`tmux send-keys -t <pane> '<text>' Enter`). Requires the pane's editor be
347
+ * empty, same limitation as the menu's `/promote` item. Best-effort. */
348
+ export function sendKeysEnter(pane, text) {
349
+ return tmux(['send-keys', '-t', pane, text, 'Enter']).ok;
350
+ }
351
+ // ---------------------------------------------------------------------------
219
352
  // Prefix menu — Alt+C opens a which-key-style tmux display-menu of crouter
220
353
  // actions. Installed on the running server at root boot; idempotent (a re-bind
221
354
  // overwrites the previous one). Items shell out to `crtr`, passing the active
222
355
  // pane so an action targets the agent currently in front of you.
223
356
  // ---------------------------------------------------------------------------
224
- /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
357
+ /** Reserved mnemonic keys owned by the built-in menu items below a custom
358
+ * `prefixBind` may not claim these (the built-in item wins). */
359
+ const RESERVED_MENU_KEYS = new Set(['o', 'r', 'd', 'D', 'x', 'b']);
360
+ /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails.
361
+ * The built-in items (promote/demote/detach/close/browse) are static; the canvas-nav
362
+ * chords (graph/manager/expand/report-N + any custom prefixBind) are appended
363
+ * from `canvasNav.prefixBinds`, each routed through `crtr canvas chord` (or, for
364
+ * the `__graph__` sentinel, a `send-keys '/graph'`) so the menu stays static
365
+ * while behaviour is config-driven. */
225
366
  export function installMenuBinding() {
226
367
  const sess = nodeSession();
227
368
  const title = ' crtr ';
@@ -230,9 +371,50 @@ export function installMenuBinding() {
230
371
  // the slash command delivers the orchestration guidance into the node's
231
372
  // context, which a bare `run-shell` (output discarded) could not.
232
373
  { 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}` },
374
+ // Resume types `/resume-node` into the agent's pane: the slash command opens
375
+ // a whole-canvas picker (incl. dormant nodes) and revives the choice via
376
+ // `crtr node focus` — the only sync-safe open (routes through reviveNode).
377
+ { name: 'resume node', key: 'r', cmd: `send-keys -t '#{pane_id}' '/resume-node' Enter` },
378
+ // `d` demotes the agent to TERMINAL in place: no finalize, no kill — it keeps
379
+ // running where it is, and because it is now terminal it is forced to push a
380
+ // final up the spine when it finishes. `D` ALSO detaches it to the background
381
+ // `crtr` session (frees the pane; the pi keeps generating). Neither ends it.
382
+ { name: 'demote to terminal', key: 'd', cmd: `run-shell "crtr node lifecycle terminal --pane '#{pane_id}' >/dev/null 2>&1"` },
383
+ { name: 'detach to background', key: 'D', cmd: `run-shell "crtr node lifecycle terminal --pane '#{pane_id}' --detach >/dev/null 2>&1"` },
384
+ // Close cascades down the subscribes_to spine (kills the subtree's windows,
385
+ // marks them canceled); revivable. Output discarded — the keypress just acts.
386
+ { name: 'close agent + subtree', key: 'x', cmd: `run-shell "crtr node close --pane '#{pane_id}' >/dev/null 2>&1"` },
387
+ // Re-keyed g→b so `g` is free for the canvas-nav GRAPH toggle (below).
388
+ { name: 'browse background agents', key: 'b', cmd: `switch-client -t ${sess}` },
235
389
  ];
390
+ // Canvas-nav chords from config (default: g→graph, m→manager, e→expand). The
391
+ // `__graph__` sentinel toggles the in-pi GRAPH modal via send-keys; every
392
+ // other bind shells the chord dispatcher, which resolves the pane's node and
393
+ // interpolates the bind at popup time. Keys colliding with the built-ins are
394
+ // skipped (the built-in wins).
395
+ let prefixBinds = {};
396
+ try {
397
+ prefixBinds = readConfig('user').canvasNav.prefixBinds;
398
+ }
399
+ catch { /* defaults below */ }
400
+ for (const [key, bind] of Object.entries(prefixBinds)) {
401
+ if (key.length !== 1 || RESERVED_MENU_KEYS.has(key))
402
+ continue;
403
+ const name = bind.desc !== undefined && bind.desc !== '' ? bind.desc : `chord ${key}`;
404
+ const cmd = bind.run === '__graph__'
405
+ ? `send-keys -t '#{pane_id}' '/graph' Enter`
406
+ : `run-shell "crtr canvas chord --pane '#{pane_id}' --key ${key} >/dev/null 2>&1"`;
407
+ items.push({ name, key, cmd });
408
+ }
409
+ // Focus report N: nine generated chord items (1..9), each resolved by the
410
+ // dispatcher to subscriptionsOf(self)[N-1] at popup time.
411
+ for (let n = 1; n <= 9; n++) {
412
+ items.push({
413
+ name: `focus report ${n}`,
414
+ key: `${n}`,
415
+ cmd: `run-shell "crtr canvas chord --pane '#{pane_id}' --key ${n} >/dev/null 2>&1"`,
416
+ });
417
+ }
236
418
  // tmux's -x sets the menu's LEFT edge. To sit the box INSIDE the pane's
237
419
  // top-right corner, shift x left by the box width (longest line + tmux chrome:
238
420
  // 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;