@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
@@ -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`);
@@ -0,0 +1,266 @@
1
+ // Run with: node --import tsx/esm --test src/pi-extensions/__tests__/canvas-stophook-agentend.test.ts
2
+ //
3
+ // The stophook's agent_end routing no longer auto-pushes anything: a node
4
+ // reaches its subscribers ONLY through its own explicit `crtr push` calls.
5
+ // These tests pin that on the three stop outcomes:
6
+ // • natural stop while awaiting a live worker → idle-release, NO push
7
+ // • refresh-yield (intent='refresh') → re-exec/shutdown, NO push
8
+ // • stalled leaf (nothing live, no final) → reprompt still fires
9
+ // Every assertion is on DB / disk effects (report files, inbox pointers) plus
10
+ // the captured sendUserMessage — tmux is unavailable here, so the focus/respawn
11
+ // helpers no-op (TMUX_PANE is cleared) and we drive a clean shutdown path.
12
+ import { test, before, after, beforeEach } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import registerCanvasStophook from '../canvas-stophook.js';
18
+ import { createNode, subscribe, getNode, setStatus } from '../../core/canvas/canvas.js';
19
+ import { openFocusRow, getFocusByNode, getFocusById, listFocuses } from '../../core/canvas/focuses.js';
20
+ import { closeDb } from '../../core/canvas/db.js';
21
+ import { reportsDir } from '../../core/canvas/paths.js';
22
+ import { readInboxSince } from '../../core/feed/inbox.js';
23
+ import { STALL_REPROMPT } from '../../core/runtime/stop-guard.js';
24
+ let home;
25
+ let origNode;
26
+ let origPane;
27
+ function node(id, over = {}) {
28
+ return {
29
+ node_id: id,
30
+ name: id,
31
+ created: new Date().toISOString(),
32
+ cwd: '/tmp/work',
33
+ kind: 'general',
34
+ mode: 'base',
35
+ lifecycle: 'terminal',
36
+ status: 'active',
37
+ ...over,
38
+ };
39
+ }
40
+ function makeFakePi() {
41
+ const handlers = {};
42
+ return {
43
+ injected: [],
44
+ on(e, h) { handlers[e] = h; },
45
+ sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
46
+ fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
47
+ };
48
+ }
49
+ /** A natural-stop agent_end event carrying one assistant text block. */
50
+ function stopEvent(text) {
51
+ return { messages: [{ role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] }] };
52
+ }
53
+ /** Count of report files written under a node's reports/ dir (0 when none). */
54
+ function reportCount(id) {
55
+ const dir = reportsDir(id);
56
+ return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')).length : 0;
57
+ }
58
+ before(() => {
59
+ origNode = process.env['CRTR_NODE_ID'];
60
+ origPane = process.env['TMUX_PANE'];
61
+ });
62
+ beforeEach(() => {
63
+ closeDb();
64
+ if (home)
65
+ rmSync(home, { recursive: true, force: true });
66
+ home = mkdtempSync(join(tmpdir(), 'crtr-stophook-end-'));
67
+ process.env['CRTR_HOME'] = home;
68
+ // Force the clean-shutdown path (no in-place respawn) so the refresh test is
69
+ // deterministic even when the suite runs inside a tmux pane.
70
+ delete process.env['TMUX_PANE'];
71
+ });
72
+ after(() => {
73
+ closeDb();
74
+ if (home)
75
+ rmSync(home, { recursive: true, force: true });
76
+ delete process.env['CRTR_HOME'];
77
+ if (origNode === undefined)
78
+ delete process.env['CRTR_NODE_ID'];
79
+ else
80
+ process.env['CRTR_NODE_ID'] = origNode;
81
+ if (origPane === undefined)
82
+ delete process.env['TMUX_PANE'];
83
+ else
84
+ process.env['TMUX_PANE'] = origPane;
85
+ });
86
+ test('natural stop while awaiting a live worker → idle-release with NO push (no report, no inbox pointer)', () => {
87
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
88
+ createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
89
+ createNode(node('worker', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
90
+ subscribe('root', 'mgr', true); // root would receive any push mgr emits
91
+ subscribe('mgr', 'worker', true); // mgr holds an active live subscription → "awaiting"
92
+ process.env['CRTR_NODE_ID'] = 'mgr';
93
+ const pi = makeFakePi();
94
+ registerCanvasStophook(pi);
95
+ let shutdown = false;
96
+ pi.fire('agent_end', stopEvent('still waiting on the worker'), { shutdown: () => { shutdown = true; } });
97
+ const m = getNode('mgr');
98
+ assert.equal(m?.intent, 'idle-release', 'mgr idle-released');
99
+ assert.equal(m?.status, 'idle', 'mgr marked idle');
100
+ assert.equal(shutdown, true, 'pi shut down');
101
+ assert.equal(reportCount('mgr'), 0, 'NO report file written');
102
+ assert.equal(readInboxSince('root').length, 0, 'NO inbox pointer fanned to subscriber');
103
+ assert.equal(pi.injected.length, 0, 'no reprompt on a legitimate idle-release');
104
+ // §5.1 case 6 (awaiting + UNFOCUSED → idle-release, no focus): the awaiting
105
+ // branch must never create/touch a focus row. Non-vacuous: an impl that ran
106
+ // the done-branch handoff/openFocus on an idle-release would leave a row here.
107
+ assert.equal(listFocuses().length, 0, 'awaiting+unfocused leaves the focuses table empty');
108
+ });
109
+ // ---------------------------------------------------------------------------
110
+ // §5.1 — the §1.7 agent_end branch map on the focuses table. Every assertion is
111
+ // on the canvas focuses/runtime rows after firing agent_end (TMUX_PANE is
112
+ // cleared in beforeEach, so the focus helpers in the handler are pure DB and the
113
+ // '%pane' ids below are never read by tmux). status='done' is reached by setting
114
+ // the runtime row directly (the branch reads getNode(nodeId).status).
115
+ // ---------------------------------------------------------------------------
116
+ test('§5.1.1 truly-done + focused + manager-not-focused → MANAGER TAKEOVER of the focus row', () => {
117
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
118
+ createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
119
+ // M starts WITH a recorded LOCATION so the MINOR presence-null is observable.
120
+ createNode(node('M', { parent: 'mgr', lifecycle: 'terminal', pane: '%m', tmux_session: 'Suser', window: '@wm' }));
121
+ subscribe('mgr', 'M', true);
122
+ openFocusRow('fM', '%m', 'Suser', 'M');
123
+ process.env['CRTR_NODE_ID'] = 'M';
124
+ setStatus('M', 'done'); // pushed final this turn
125
+ const pi = makeFakePi();
126
+ registerCanvasStophook(pi);
127
+ let shutdown = false;
128
+ pi.fire('agent_end', stopEvent('done — pushed final'), { shutdown: () => { shutdown = true; } });
129
+ // managerId = M.parent = 'mgr' (not focused elsewhere, no live pane here → the
130
+ // DORMANT-takeover path) → handFocusToManager repoints fM's occupant M→mgr. The
131
+ // daemon later revives mgr INTO M's frozen focus pane. Non-vacuous: a no-op (no
132
+ // handoff) impl leaves M as occupant, so getFocusByNode('mgr') is null AND
133
+ // getFocusByNode('M') still names fM — both asserts fail.
134
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'fM', 'focus row taken over by the manager');
135
+ assert.equal(getFocusByNode('M'), null, 'the finished node no longer occupies any focus');
136
+ assert.equal(shutdown, true, 'pi shut down after the handoff');
137
+ // MINOR: after a successful takeover M (done) owns no pane (Invariant P) — its
138
+ // own presence is nulled so two rows never reference %m. Non-vacuous: an impl
139
+ // that skips the done-path setPresence-null leaves getNode('M').pane === '%m'.
140
+ assert.equal(getNode('M')?.pane ?? null, null, 'the finished node\'s own LOCATION pane is nulled');
141
+ assert.equal(getNode('M')?.window ?? null, null, 'the finished node\'s window presence is nulled too');
142
+ });
143
+ test('§5.1.2 truly-done + focused + NO manager (root) → focus row CLOSED (Q1)', () => {
144
+ // R carries a LOCATION so the close-path presence-null is observable.
145
+ createNode(node('R', { parent: null, lifecycle: 'terminal', pane: '%r', tmux_session: 'Suser', window: '@wr' }));
146
+ openFocusRow('fR', '%r', 'Suser', 'R');
147
+ process.env['CRTR_NODE_ID'] = 'R';
148
+ setStatus('R', 'done');
149
+ const pi = makeFakePi();
150
+ registerCanvasStophook(pi);
151
+ pi.fire('agent_end', stopEvent('root done'), { shutdown: () => { } });
152
+ // managerId = R.parent(null) ?? subscribersOf(R)[0](none) = null →
153
+ // handFocusToManager returns false → the close path: closeFocusRow(fR) +
154
+ // setRemainOnExit(%r's window, false) (return-to-shell) + null R's presence.
155
+ // Non-vacuous: a takeover-instead-of-close impl would leave the row present;
156
+ // an impl that skips the MINOR presence-null leaves getNode('R').pane === '%r'.
157
+ assert.equal(getFocusById('fR'), null, 'a manager-less finished focus is closed, not handed off');
158
+ assert.equal(listFocuses().length, 0, 'no focus rows survive');
159
+ assert.equal(getNode('R')?.pane ?? null, null, 'the finished root\'s own LOCATION pane is nulled (close path reaps)');
160
+ });
161
+ test('§5.1.3 truly-done + focused + manager ALREADY focused elsewhere → focus CLOSED, manager UNMOVED', () => {
162
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
163
+ createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
164
+ createNode(node('M', { parent: 'mgr', lifecycle: 'terminal', pane: '%m', tmux_session: 'Sa', window: '@wm' }));
165
+ subscribe('mgr', 'M', true);
166
+ openFocusRow('fOther', '%o', 'Sb', 'mgr'); // mgr already on its OWN viewport
167
+ openFocusRow('fM', '%m', 'Sa', 'M');
168
+ process.env['CRTR_NODE_ID'] = 'M';
169
+ setStatus('M', 'done');
170
+ const pi = makeFakePi();
171
+ registerCanvasStophook(pi);
172
+ pi.fire('agent_end', stopEvent('M done'), { shutdown: () => { } });
173
+ // handFocusToManager sees getFocusByNode('mgr') != null → returns false →
174
+ // closeFocusRow(fM). Non-vacuous: moving mgr would either repoint its focus_id
175
+ // to fM (and a wrong impl that didn't close fM would leave it present) or throw
176
+ // UNIQUE(node_id); this pins mgr's OTHER focus untouched and M's focus gone.
177
+ assert.equal(getFocusById('fM'), null, "M's focus is closed");
178
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'fOther', "the manager's other viewport is NOT stolen");
179
+ // MINOR: M (done) is reaped on the close path — its own presence nulled.
180
+ // Non-vacuous: an impl that skips the done-path setPresence-null leaves
181
+ // getNode('M').pane === '%m'.
182
+ assert.equal(getNode('M')?.pane ?? null, null, "the finished node's own LOCATION pane is nulled");
183
+ });
184
+ test('§5.1.4 truly-done + UNFOCUSED → no focus row created/touched, shuts down (Invariant P)', () => {
185
+ createNode(node('mgr', { parent: null, lifecycle: 'terminal', mode: 'orchestrator' }));
186
+ createNode(node('M', { parent: 'mgr', lifecycle: 'terminal' }));
187
+ subscribe('mgr', 'M', true);
188
+ process.env['CRTR_NODE_ID'] = 'M';
189
+ setStatus('M', 'done');
190
+ const pi = makeFakePi();
191
+ registerCanvasStophook(pi);
192
+ let shutdown = false;
193
+ pi.fire('agent_end', stopEvent('done, never had a viewport'), { shutdown: () => { shutdown = true; } });
194
+ // focusOf(M) is null → the focus block is skipped entirely → just shutdown.
195
+ // Non-vacuous: an impl that created or handed off a focus row would leave
196
+ // listFocuses non-empty.
197
+ assert.equal(shutdown, true, 'an unfocused done node shuts down');
198
+ assert.equal(listFocuses().length, 0, 'no focus row was created or touched');
199
+ });
200
+ test('§5.1.5 awaiting + focused → idle-release FREEZE: the focus row SURVIVES untouched (F3)', () => {
201
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
202
+ createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
203
+ createNode(node('worker', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
204
+ subscribe('root', 'mgr', true);
205
+ subscribe('mgr', 'worker', true); // mgr awaits a live worker → idle-release
206
+ openFocusRow('fMgr', '%g', 'Suser', 'mgr');
207
+ process.env['CRTR_NODE_ID'] = 'mgr';
208
+ const pi = makeFakePi();
209
+ registerCanvasStophook(pi);
210
+ pi.fire('agent_end', stopEvent('still waiting on the worker'), { shutdown: () => { } });
211
+ // The awaiting branch only transition('release')s — it must NOT close or
212
+ // repoint the focus (that is the done branch). Non-vacuous: a wrong impl that
213
+ // routed an idle-release through the done-branch handoff/close would change or
214
+ // remove fMgr.
215
+ assert.equal(getNode('mgr')?.intent, 'idle-release', 'mgr idle-released (frozen)');
216
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'fMgr', 'the focus row is UNCHANGED — not closed, not handed off');
217
+ });
218
+ test('§5.1.7 resident attended (no live subs) → nothing happens; focus + status survive', () => {
219
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
220
+ openFocusRow('fR', '%r', 'Suser', 'root');
221
+ process.env['CRTR_NODE_ID'] = 'root';
222
+ const pi = makeFakePi();
223
+ registerCanvasStophook(pi);
224
+ let shutdown = false;
225
+ pi.fire('agent_end', stopEvent('I have wrapped up'), { shutdown: () => { shutdown = true; } });
226
+ // evaluateStop on a resident → reason 'dormant' (NOT 'awaiting'), so the
227
+ // awaiting branch is skipped and the handler does nothing: no release, no
228
+ // shutdown, no focus touch. Non-vacuous: an impl that idle-released a resident
229
+ // would flip status→idle / intent→idle-release; one that touched focus would
230
+ // change/remove fR.
231
+ assert.equal(getNode('root')?.status, 'active', 'a resident is never forced dormant');
232
+ assert.equal(getNode('root')?.intent ?? null, null, 'no idle-release intent on a resident');
233
+ assert.equal(getFocusByNode('root')?.focus_id, 'fR', 'focus row survives untouched');
234
+ assert.equal(shutdown, false, 'a resident attended node is not shut down');
235
+ });
236
+ test('refresh-yield (intent=refresh) writes NO push — silent to subscribers', () => {
237
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
238
+ createNode(node('orch', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator', intent: 'refresh' }));
239
+ subscribe('root', 'orch', true);
240
+ process.env['CRTR_NODE_ID'] = 'orch';
241
+ const pi = makeFakePi();
242
+ registerCanvasStophook(pi);
243
+ let shutdown = false;
244
+ pi.fire('agent_end', stopEvent('checkpoint before refreshing'), { shutdown: () => { shutdown = true; } });
245
+ assert.equal(shutdown, true, 'pi shut down (no tmux pane → clean shutdown)');
246
+ assert.equal(reportCount('orch'), 0, 'a yield is silent: NO report file');
247
+ assert.equal(readInboxSince('root').length, 0, 'a yield is silent: NO inbox pointer');
248
+ assert.equal(pi.injected.length, 0, 'no reprompt on a refresh-yield');
249
+ });
250
+ test('stalled leaf (nothing live to await, no final) is still reprompted', () => {
251
+ createNode(node('mgr', { parent: null, lifecycle: 'terminal', mode: 'orchestrator' }));
252
+ createNode(node('leaf', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
253
+ subscribe('mgr', 'leaf', true); // mgr subscribes to leaf; leaf itself awaits nothing
254
+ process.env['CRTR_NODE_ID'] = 'leaf';
255
+ const pi = makeFakePi();
256
+ registerCanvasStophook(pi);
257
+ let shutdown = false;
258
+ pi.fire('agent_end', stopEvent('I think I am basically done here'), { shutdown: () => { shutdown = true; } });
259
+ assert.equal(pi.injected.length, 1, 'the stall reprompt fired');
260
+ assert.equal(pi.injected[0].content, STALL_REPROMPT, 'reprompt carries the stall nudge to push final / ask');
261
+ assert.equal(pi.injected[0].deliverAs, 'followUp', 'reprompt delivered as a followUp');
262
+ assert.equal(shutdown, false, 'a stalled leaf is NOT shut down — it is re-prompted to finish');
263
+ assert.notEqual(getNode('leaf')?.intent, 'idle-release', 'a stalled leaf does not idle-release');
264
+ assert.equal(reportCount('leaf'), 0, 'NO report file written on a stall');
265
+ assert.equal(readInboxSince('mgr').length, 0, 'NO inbox pointer fanned on a stall');
266
+ });
@@ -0,0 +1,34 @@
1
+ interface AutocompleteItem {
2
+ value: string;
3
+ label?: string;
4
+ }
5
+ interface CommandUI {
6
+ notify(message: string, type?: 'info' | 'warning' | 'error'): void;
7
+ setStatus(key: string, text: string | undefined): void;
8
+ }
9
+ interface CommandCtx {
10
+ ui: CommandUI;
11
+ }
12
+ interface CustomMessage {
13
+ customType: string;
14
+ content: string;
15
+ display?: boolean;
16
+ }
17
+ interface PiLike {
18
+ registerCommand(name: string, options: {
19
+ description?: string;
20
+ getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
21
+ handler: (args: string, ctx: CommandCtx) => Promise<void>;
22
+ }): void;
23
+ sendMessage(message: CustomMessage, options?: {
24
+ triggerTurn?: boolean;
25
+ }): void;
26
+ }
27
+ /**
28
+ * Register the canvas slash-commands on `pi`.
29
+ *
30
+ * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
31
+ * inert in a non-canvas pi session.
32
+ */
33
+ export declare function registerCanvasCommands(pi: PiLike): void;
34
+ export default registerCanvasCommands;