@crouton-kit/crouter 0.3.11 → 0.3.13

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 (182) hide show
  1. package/bin/crtrd +2 -0
  2. package/dist/builtin-personas/design/base.md +9 -0
  3. package/dist/builtin-personas/design/orchestrator.md +10 -0
  4. package/dist/builtin-personas/developer/base.md +9 -0
  5. package/dist/builtin-personas/developer/orchestrator.md +12 -0
  6. package/dist/builtin-personas/explore/base.md +9 -0
  7. package/dist/builtin-personas/explore/orchestrator.md +9 -0
  8. package/dist/builtin-personas/general/base.md +5 -0
  9. package/dist/builtin-personas/general/orchestrator.md +7 -0
  10. package/dist/builtin-personas/orchestration-kernel.md +71 -0
  11. package/dist/builtin-personas/plan/base.md +7 -0
  12. package/dist/builtin-personas/plan/orchestrator.md +12 -0
  13. package/dist/builtin-personas/review/base.md +7 -0
  14. package/dist/builtin-personas/review/orchestrator.md +9 -0
  15. package/dist/builtin-personas/runtime-base.md +39 -0
  16. package/dist/builtin-personas/spec/base.md +7 -0
  17. package/dist/builtin-personas/spec/orchestrator.md +10 -0
  18. package/dist/builtin-skills/skills/design/SKILL.md +51 -0
  19. package/dist/builtin-skills/skills/development/SKILL.md +109 -0
  20. package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
  21. package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
  22. package/dist/cli.js +14 -6
  23. package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
  24. package/dist/commands/attention.js +152 -0
  25. package/dist/commands/canvas.d.ts +2 -0
  26. package/dist/commands/canvas.js +35 -0
  27. package/dist/commands/daemon.d.ts +2 -0
  28. package/dist/commands/daemon.js +111 -0
  29. package/dist/commands/dashboard.d.ts +2 -0
  30. package/dist/commands/dashboard.js +65 -0
  31. package/dist/commands/human/prompts.d.ts +5 -0
  32. package/dist/commands/human/prompts.js +269 -0
  33. package/dist/commands/human/queue.d.ts +3 -0
  34. package/dist/commands/human/queue.js +133 -0
  35. package/dist/commands/human/shared.d.ts +43 -0
  36. package/dist/commands/human/shared.js +107 -0
  37. package/dist/commands/human.js +10 -454
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +407 -0
  40. package/dist/commands/pkg/market-inspect.d.ts +1 -0
  41. package/dist/commands/pkg/market-inspect.js +157 -0
  42. package/dist/commands/pkg/market-manage.d.ts +1 -0
  43. package/dist/commands/pkg/market-manage.js +316 -0
  44. package/dist/commands/pkg/market.d.ts +1 -0
  45. package/dist/commands/pkg/market.js +16 -0
  46. package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
  47. package/dist/commands/pkg/plugin-inspect.js +142 -0
  48. package/dist/commands/pkg/plugin-manage.d.ts +1 -0
  49. package/dist/commands/pkg/plugin-manage.js +294 -0
  50. package/dist/commands/pkg/plugin.d.ts +1 -0
  51. package/dist/commands/pkg/plugin.js +16 -0
  52. package/dist/commands/pkg/shared.d.ts +5 -0
  53. package/dist/commands/pkg/shared.js +61 -0
  54. package/dist/commands/pkg.js +3 -1004
  55. package/dist/commands/push.d.ts +3 -0
  56. package/dist/commands/push.js +159 -0
  57. package/dist/commands/revive.d.ts +2 -0
  58. package/dist/commands/revive.js +64 -0
  59. package/dist/commands/skill/author.d.ts +3 -0
  60. package/dist/commands/skill/author.js +147 -0
  61. package/dist/commands/skill/find.d.ts +4 -0
  62. package/dist/commands/skill/find.js +254 -0
  63. package/dist/commands/skill/read.d.ts +1 -0
  64. package/dist/commands/skill/read.js +89 -0
  65. package/dist/commands/skill/shared.d.ts +19 -0
  66. package/dist/commands/skill/shared.js +207 -0
  67. package/dist/commands/skill/state.d.ts +3 -0
  68. package/dist/commands/skill/state.js +69 -0
  69. package/dist/commands/skill.js +6 -691
  70. package/dist/commands/sys/config.d.ts +1 -0
  71. package/dist/commands/sys/config.js +186 -0
  72. package/dist/commands/sys/doctor.d.ts +1 -0
  73. package/dist/commands/sys/doctor.js +369 -0
  74. package/dist/commands/sys/shared.d.ts +3 -0
  75. package/dist/commands/sys/shared.js +24 -0
  76. package/dist/commands/sys/update.d.ts +2 -0
  77. package/dist/commands/sys/update.js +114 -0
  78. package/dist/commands/sys.js +4 -694
  79. package/dist/core/__tests__/argv-parser.test.js +19 -1
  80. package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
  81. package/dist/core/__tests__/canvas.test.js +154 -0
  82. package/dist/core/__tests__/reset.test.js +105 -0
  83. package/dist/core/canvas/attention.d.ts +24 -0
  84. package/dist/core/canvas/attention.js +94 -0
  85. package/dist/core/canvas/canvas.d.ts +40 -0
  86. package/dist/core/canvas/canvas.js +210 -0
  87. package/dist/core/canvas/db.d.ts +7 -0
  88. package/dist/core/canvas/db.js +61 -0
  89. package/dist/core/canvas/index.d.ts +4 -0
  90. package/dist/core/canvas/index.js +6 -0
  91. package/dist/core/canvas/paths.d.ts +16 -0
  92. package/dist/core/canvas/paths.js +62 -0
  93. package/dist/core/canvas/render.d.ts +30 -0
  94. package/dist/core/canvas/render.js +186 -0
  95. package/dist/core/canvas/types.d.ts +87 -0
  96. package/dist/core/canvas/types.js +8 -0
  97. package/dist/core/command.d.ts +5 -0
  98. package/dist/core/command.js +35 -10
  99. package/dist/core/feed/feed.d.ts +43 -0
  100. package/dist/core/feed/feed.js +116 -0
  101. package/dist/core/feed/inbox.d.ts +50 -0
  102. package/dist/core/feed/inbox.js +124 -0
  103. package/dist/core/help.js +5 -3
  104. package/dist/core/io.d.ts +15 -1
  105. package/dist/core/io.js +56 -6
  106. package/dist/core/personas/index.d.ts +12 -0
  107. package/dist/core/personas/index.js +10 -0
  108. package/dist/core/personas/loader.d.ts +44 -0
  109. package/dist/core/personas/loader.js +157 -0
  110. package/dist/core/personas/resolve.d.ts +36 -0
  111. package/dist/core/personas/resolve.js +110 -0
  112. package/dist/core/render.d.ts +11 -0
  113. package/dist/core/render.js +126 -0
  114. package/dist/core/resolver.d.ts +10 -0
  115. package/dist/core/resolver.js +109 -1
  116. package/dist/core/runtime/front-door.d.ts +10 -0
  117. package/dist/core/runtime/front-door.js +97 -0
  118. package/dist/core/runtime/kickoff.d.ts +23 -0
  119. package/dist/core/runtime/kickoff.js +134 -0
  120. package/dist/core/runtime/launch.d.ts +34 -0
  121. package/dist/core/runtime/launch.js +85 -0
  122. package/dist/core/runtime/nodes.d.ts +38 -0
  123. package/dist/core/runtime/nodes.js +95 -0
  124. package/dist/core/runtime/presence.d.ts +55 -0
  125. package/dist/core/runtime/presence.js +198 -0
  126. package/dist/core/runtime/promote.d.ts +30 -0
  127. package/dist/core/runtime/promote.js +105 -0
  128. package/dist/core/runtime/reset.d.ts +13 -0
  129. package/dist/core/runtime/reset.js +97 -0
  130. package/dist/core/runtime/revive.d.ts +26 -0
  131. package/dist/core/runtime/revive.js +87 -0
  132. package/dist/core/runtime/roadmap.d.ts +12 -0
  133. package/dist/core/runtime/roadmap.js +52 -0
  134. package/dist/core/runtime/spawn.d.ts +31 -0
  135. package/dist/core/runtime/spawn.js +123 -0
  136. package/dist/core/runtime/stop-guard.d.ts +18 -0
  137. package/dist/core/runtime/stop-guard.js +33 -0
  138. package/dist/core/runtime/tmux.d.ts +107 -0
  139. package/dist/core/runtime/tmux.js +244 -0
  140. package/dist/core/spawn.d.ts +17 -197
  141. package/dist/core/spawn.js +16 -539
  142. package/dist/daemon/crtrd-cli.js +4 -0
  143. package/dist/daemon/crtrd.d.ts +20 -0
  144. package/dist/daemon/crtrd.js +200 -0
  145. package/dist/daemon/manage.d.ts +17 -0
  146. package/dist/daemon/manage.js +57 -0
  147. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  148. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  149. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  150. package/dist/pi-extensions/canvas-nav.js +536 -0
  151. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  152. package/dist/pi-extensions/canvas-stophook.js +396 -0
  153. package/package.json +6 -5
  154. package/dist/commands/agent.d.ts +0 -6
  155. package/dist/commands/agent.js +0 -585
  156. package/dist/commands/debug.d.ts +0 -3
  157. package/dist/commands/debug.js +0 -192
  158. package/dist/commands/job.d.ts +0 -11
  159. package/dist/commands/job.js +0 -384
  160. package/dist/commands/mode.js +0 -231
  161. package/dist/commands/plan.d.ts +0 -4
  162. package/dist/commands/plan.js +0 -322
  163. package/dist/commands/spec.d.ts +0 -3
  164. package/dist/commands/spec.js +0 -299
  165. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  166. package/dist/core/__tests__/job.test.js +0 -310
  167. package/dist/core/__tests__/jobs.test.js +0 -98
  168. package/dist/core/__tests__/spawn.test.js +0 -138
  169. package/dist/core/__tests__/subagents.test.d.ts +0 -1
  170. package/dist/core/__tests__/subagents.test.js +0 -75
  171. package/dist/core/jobs.d.ts +0 -107
  172. package/dist/core/jobs.js +0 -565
  173. package/dist/core/subagents.d.ts +0 -18
  174. package/dist/core/subagents.js +0 -163
  175. package/dist/prompts/agent.d.ts +0 -27
  176. package/dist/prompts/agent.js +0 -184
  177. package/dist/prompts/debug.d.ts +0 -8
  178. package/dist/prompts/debug.js +0 -44
  179. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  180. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  181. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
  182. /package/dist/{core/__tests__/spawn.test.d.ts → daemon/crtrd-cli.d.ts} +0 -0
@@ -0,0 +1,200 @@
1
+ // crtrd — the thin supervisor daemon. One instance per canvas.
2
+ //
3
+ // Sole responsibility: supervise tmux window exit and revive nodes. No
4
+ // orchestration logic lives here. The daemon is a process-lifecycle watcher.
5
+ //
6
+ // Model
7
+ // • 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
12
+ // dormant; clear the stale window ref and revive (resume) when its inbox
13
+ // gains an unseen entry.
14
+ // • Window gone + any other intent → crash: mark 'dead'.
15
+ // • Nodes with no tmux placement (inline roots) are skipped.
16
+ //
17
+ // Single-instance guarantee
18
+ // A PID file at crtrHome()/crtrd.pid prevents double-runs. On start, if the
19
+ // file exists and the recorded pid is alive, we refuse to start (exit 0).
20
+ // On stop (SIGINT/SIGTERM/exit) we remove the file.
21
+ import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ 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';
26
+ import { reviveNode } from '../core/runtime/revive.js';
27
+ import { readInboxSince, readCursor } from '../core/feed/inbox.js';
28
+ const DEFAULT_INTERVAL_MS = 2000;
29
+ // ---------------------------------------------------------------------------
30
+ // Pidfile
31
+ // ---------------------------------------------------------------------------
32
+ function pidfilePath() {
33
+ return join(crtrHome(), 'crtrd.pid');
34
+ }
35
+ function writePidfile() {
36
+ // Ensure the canvas home exists before writing.
37
+ mkdirSync(crtrHome(), { recursive: true });
38
+ writeFileSync(pidfilePath(), String(process.pid), 'utf8');
39
+ }
40
+ function removePidfile() {
41
+ try {
42
+ rmSync(pidfilePath());
43
+ }
44
+ catch {
45
+ // Already gone — nothing to do.
46
+ }
47
+ }
48
+ /** Read the pid stored in the pidfile, or null if absent / malformed. */
49
+ export function readPidfile() {
50
+ const p = pidfilePath();
51
+ if (!existsSync(p))
52
+ return null;
53
+ const raw = readFileSync(p, 'utf8').trim();
54
+ const n = Number(raw);
55
+ return Number.isFinite(n) && n > 0 ? n : null;
56
+ }
57
+ /** True if a process with `pid` is currently alive (signal-0 probe). */
58
+ export function isPidAlive(pid) {
59
+ try {
60
+ process.kill(pid, 0);
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ /** True when a crtrd process is already running (pidfile exists + pid alive). */
68
+ export function isDaemonRunning() {
69
+ const pid = readPidfile();
70
+ return pid !== null && isPidAlive(pid);
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Supervisor tick
74
+ // ---------------------------------------------------------------------------
75
+ async function superviseTick() {
76
+ let rows;
77
+ try {
78
+ rows = listNodes({ status: ['active', 'idle'] });
79
+ }
80
+ catch (err) {
81
+ process.stderr.write(`[crtrd] listNodes error: ${err.message}\n`);
82
+ return;
83
+ }
84
+ for (const row of rows) {
85
+ 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)
93
+ continue;
94
+ if (windowAlive(meta.tmux_session, meta.window))
95
+ continue; // healthy
96
+ // Window is gone. Branch on why.
97
+ if (meta.intent === 'refresh') {
98
+ // The node set intent=refresh before stopping — a clean yield. Respawn
99
+ // fresh so it re-reads its roadmap/context dir.
100
+ process.stderr.write(`[crtrd] revive ${row.node_id} (refresh-yield)\n`);
101
+ reviveNode(row.node_id, { resume: false });
102
+ }
103
+ else if (meta.intent === 'idle-release') {
104
+ // The node freed its own window on purpose while dormant. Drop the stale
105
+ // window ref and keep it 'idle'; the inbox-poll pass below revives it
106
+ // (resume) the moment a subscribed worker delivers.
107
+ updateNode(row.node_id, { window: null });
108
+ }
109
+ 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');
113
+ }
114
+ }
115
+ catch (err) {
116
+ // One bad node must never kill the loop.
117
+ process.stderr.write(`[crtrd] error supervising ${row.node_id}: ${err.message}\n`);
118
+ }
119
+ }
120
+ // Second pass: revive idle-released nodes whose inbox has unseen entries.
121
+ // The in-process inbox-watcher dies with pi, so the daemon owns wake-on-message
122
+ // for dormant nodes. readCursor is the cursor the watcher persisted before
123
+ // exit; any entry past it is undelivered work — resume the node to handle it.
124
+ for (const row of rows) {
125
+ try {
126
+ const meta = getNode(row.node_id);
127
+ if (meta === null)
128
+ continue;
129
+ if (meta.status !== 'idle' || meta.intent !== 'idle-release')
130
+ 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)) {
133
+ continue;
134
+ }
135
+ const entries = readInboxSince(row.node_id, readCursor(row.node_id));
136
+ if (entries.length > 0) {
137
+ process.stderr.write(`[crtrd] revive ${row.node_id} (idle-release, inbox)\n`);
138
+ reviveNode(row.node_id, { resume: true });
139
+ }
140
+ }
141
+ catch (err) {
142
+ process.stderr.write(`[crtrd] error polling inbox ${row.node_id}: ${err.message}\n`);
143
+ }
144
+ }
145
+ }
146
+ /** Start the supervisor loop.
147
+ *
148
+ * If a live crtrd is already running (pidfile + pid alive), exits immediately
149
+ * (exit 0 — idempotent, not an error). Otherwise, writes the pidfile, sets up
150
+ * signal handlers, and enters the poll loop.
151
+ *
152
+ * Returns a teardown callback that stops the loop and removes the pidfile.
153
+ * (Mainly useful for tests; in production the daemon runs until signaled.) */
154
+ export function runDaemon(opts = {}) {
155
+ if (isDaemonRunning()) {
156
+ const pid = readPidfile();
157
+ process.stderr.write(`[crtrd] already running (pid ${pid ?? '?'})\n`);
158
+ process.exit(0);
159
+ }
160
+ const interval = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
161
+ writePidfile();
162
+ process.stderr.write(`[crtrd] started (pid ${process.pid}, interval ${interval}ms)\n`);
163
+ let running = true;
164
+ // Cleanup — idempotent.
165
+ const cleanup = () => {
166
+ removePidfile();
167
+ };
168
+ process.on('SIGINT', () => {
169
+ cleanup();
170
+ process.exit(0);
171
+ });
172
+ process.on('SIGTERM', () => {
173
+ cleanup();
174
+ process.exit(0);
175
+ });
176
+ process.on('exit', () => {
177
+ cleanup();
178
+ });
179
+ // Recursive setTimeout keeps ticks sequential and avoids overlap on slow
180
+ // canvases (a timer that fires while a prior tick is awaiting is dropped).
181
+ const scheduleTick = () => {
182
+ if (!running)
183
+ return;
184
+ superviseTick()
185
+ .catch((err) => {
186
+ process.stderr.write(`[crtrd] tick error: ${err.message}\n`);
187
+ })
188
+ .finally(() => {
189
+ if (running)
190
+ setTimeout(scheduleTick, interval);
191
+ });
192
+ };
193
+ const initialTimer = setTimeout(scheduleTick, interval);
194
+ return () => {
195
+ running = false;
196
+ clearTimeout(initialTimer);
197
+ cleanup();
198
+ };
199
+ }
200
+ export default runDaemon;
@@ -0,0 +1,17 @@
1
+ export interface SpawnDaemonResult {
2
+ /** True when a new daemon process was spawned. */
3
+ started: boolean;
4
+ /** PID of the newly spawned process, if started. */
5
+ pid?: number;
6
+ /** PID of the already-running daemon, if it was already up. */
7
+ existing_pid?: number;
8
+ }
9
+ /** Spawn crtrd detached. Returns immediately; the child outlives this process.
10
+ *
11
+ * If the daemon is already running, returns {started:false, existing_pid}.
12
+ * If spawning fails (e.g. missing dist — run `npm run build` first), throws. */
13
+ export declare function spawnDaemon(): SpawnDaemonResult;
14
+ /** Start the daemon if it is not already running. No-op if already up.
15
+ * Silently swallows spawn errors (the canvas still works without the daemon;
16
+ * nodes just won't be auto-revived). */
17
+ export declare function ensureDaemon(): void;
@@ -0,0 +1,57 @@
1
+ // Daemon management helpers — importable without the full command tree.
2
+ //
3
+ // spawnDaemon() is the low-level spawn call shared by `crtr canvas daemon start` and
4
+ // ensureDaemon(). ensureDaemon() is the silent "start if not running" front-
5
+ // door helper called by the canvas runtime before spawning child nodes.
6
+ import { spawn } from 'node:child_process';
7
+ import { dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { mkdirSync } from 'node:fs';
10
+ import { crtrHome } from '../core/canvas/paths.js';
11
+ import { isDaemonRunning, readPidfile } from './crtrd.js';
12
+ // ---------------------------------------------------------------------------
13
+ // Entry point resolution
14
+ // ---------------------------------------------------------------------------
15
+ /** Resolve the absolute path to the crtrd-cli entry point.
16
+ *
17
+ * At runtime this file is dist/daemon/manage.js; the entry lives at
18
+ * dist/daemon/crtrd-cli.js (sibling in the same directory). */
19
+ function resolveCrtrdEntry() {
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ return join(here, 'crtrd-cli.js');
22
+ }
23
+ /** Spawn crtrd detached. Returns immediately; the child outlives this process.
24
+ *
25
+ * If the daemon is already running, returns {started:false, existing_pid}.
26
+ * If spawning fails (e.g. missing dist — run `npm run build` first), throws. */
27
+ export function spawnDaemon() {
28
+ if (isDaemonRunning()) {
29
+ return { started: false, existing_pid: readPidfile() ?? undefined };
30
+ }
31
+ // Ensure the canvas home directory exists so the daemon can write its pidfile.
32
+ mkdirSync(crtrHome(), { recursive: true });
33
+ const entry = resolveCrtrdEntry();
34
+ const child = spawn(process.execPath, [entry], {
35
+ detached: true,
36
+ stdio: 'ignore',
37
+ });
38
+ const pid = child.pid;
39
+ child.unref();
40
+ return { started: true, pid };
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // ensureDaemon — fire-and-forget front-door helper
44
+ // ---------------------------------------------------------------------------
45
+ /** Start the daemon if it is not already running. No-op if already up.
46
+ * Silently swallows spawn errors (the canvas still works without the daemon;
47
+ * nodes just won't be auto-revived). */
48
+ export function ensureDaemon() {
49
+ try {
50
+ if (!isDaemonRunning())
51
+ spawnDaemon();
52
+ }
53
+ catch {
54
+ // Intentionally silent — a missing dist/daemon/crtrd-cli.js (dev mode,
55
+ // pre-build) must not break the calling command.
56
+ }
57
+ }
@@ -0,0 +1,16 @@
1
+ type PiEvents = 'session_start' | 'turn_end' | 'agent_start' | 'agent_end' | 'session_shutdown';
2
+ interface PiLike {
3
+ on: (event: PiEvents, handler: (event: any, ctx: any) => void | Promise<void>) => void;
4
+ sendUserMessage: (content: string, options?: {
5
+ deliverAs?: 'steer' | 'followUp';
6
+ }) => void;
7
+ }
8
+ /**
9
+ * Register the canvas inbox watcher on `pi`.
10
+ *
11
+ * CRTR_NODE_ID is re-read each tick so late-injected env (edge case) is
12
+ * handled gracefully. Returns a disposer for testability; pi ignores it —
13
+ * the module-level liveTimer guard is the actual stacking prevention.
14
+ */
15
+ export declare function registerCanvasInboxWatcher(pi: PiLike): () => void;
16
+ export default registerCanvasInboxWatcher;
@@ -0,0 +1,229 @@
1
+ // canvas-inbox-watcher.ts — pi extension for pi-native canvas agent nodes.
2
+ //
3
+ // Loaded into every canvas node's pi process via the node's launch.extensions
4
+ // list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
5
+ //
6
+ // The canvas model: each node is a long-lived resident that alternates between
7
+ // "working" (pi actively generating) and "dormant" (pi idle, waiting for a
8
+ // subscribed worker to push a report). This watcher bridges the dormant→working
9
+ // transition automatically: it polls the node's inbox.jsonl every 800ms and,
10
+ // when new entries arrive, coalesces them into a single digest and injects it as
11
+ // a pi user message — waking the node to react.
12
+ //
13
+ // Key differences from the legacy agent-inbox-watcher:
14
+ // • Target resolution is trivial. CRTR_NODE_ID IS the node; its inbox lives at
15
+ // nodes/<CRTR_NODE_ID>/inbox.jsonl. No session-dir scanning, no pi_session_id
16
+ // matching, no spawned-vs-top-level branching.
17
+ // • readInboxSince / readCursor / writeCursor from the canvas inbox primitive
18
+ // replace the hand-rolled JSONL scanner and cursor-file helpers.
19
+ // • coalesce() renders the digest (pointer list, not job-status prose).
20
+ // • No crtr root-init or spawnSync bootstrap — the canvas runtime wires up the
21
+ // node before launching pi; CRTR_NODE_ID is always present when we activate.
22
+ // • Deliver-as decision is driven by InboxEntry.tier (and kind): critical →
23
+ // true preempt (ctx.abort() the live turn, redeliver next tick), urgent →
24
+ // steer at the turn boundary, normal|deferred → followUp. A finished node
25
+ // (kind 'final') ALSO steers — a completion the subscriber may be blocked on
26
+ // must interrupt the current turn, not wait behind it as a follow-up.
27
+ //
28
+ // Double-notify prevention (copied from legacy watcher):
29
+ // A module-level `liveTimer` ensures that a /reload re-init clears the previous
30
+ // setInterval before starting a new one — exactly one watcher is live at a time.
31
+ //
32
+ // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
33
+ // crouter's own tsc build without a dep on the pi packages.
34
+ import { readInboxSince, readCursor, writeCursor, coalesce, } from '../core/feed/inbox.js';
35
+ // ---------------------------------------------------------------------------
36
+ // Module-level timer — prevents stacking on /reload (the double-notify bug).
37
+ //
38
+ // pi ignores an extension factory's returned disposer, so a /reload re-enters
39
+ // this module and would ADD a new setInterval on top of any running one.
40
+ // N reloads → N live watchers, each with its own in-memory cursor → N deliveries
41
+ // of the same entry. Clearing the prior timer on each re-init ensures exactly
42
+ // one watcher is live. Pattern copied verbatim from agent-inbox-watcher.ts.
43
+ // ---------------------------------------------------------------------------
44
+ let liveTimer;
45
+ const TICK_MS = 800; // polling cadence
46
+ const DEBOUNCE_MS = 1000; // flush once the burst has been quiet for this long
47
+ // ---------------------------------------------------------------------------
48
+ // Extension
49
+ // ---------------------------------------------------------------------------
50
+ /**
51
+ * Register the canvas inbox watcher on `pi`.
52
+ *
53
+ * CRTR_NODE_ID is re-read each tick so late-injected env (edge case) is
54
+ * handled gracefully. Returns a disposer for testability; pi ignores it —
55
+ * the module-level liveTimer guard is the actual stacking prevention.
56
+ */
57
+ export function registerCanvasInboxWatcher(pi) {
58
+ // Capture the latest event context so isIdle() is readable inside the timer
59
+ // callback, which has no ctx of its own.
60
+ let lastCtx;
61
+ let streaming = false;
62
+ const captureCtx = (_event, ctx) => {
63
+ if (ctx !== undefined)
64
+ lastCtx = ctx;
65
+ };
66
+ pi.on('session_start', captureCtx);
67
+ pi.on('turn_end', captureCtx);
68
+ pi.on('agent_start', (_e, ctx) => {
69
+ captureCtx(_e, ctx);
70
+ streaming = true;
71
+ });
72
+ pi.on('agent_end', (_e, ctx) => {
73
+ captureCtx(_e, ctx);
74
+ streaming = false;
75
+ });
76
+ /**
77
+ * True when pi is not currently streaming a response.
78
+ * When idle, sendUserMessage triggers a new turn immediately.
79
+ * When streaming, steer (interrupt) on urgency or a finished node, else follow up.
80
+ */
81
+ const isIdle = () => {
82
+ try {
83
+ if (typeof lastCtx?.isIdle === 'function')
84
+ return lastCtx.isIdle() === true;
85
+ }
86
+ catch {
87
+ /* fall through to the streaming flag */
88
+ }
89
+ return !streaming;
90
+ };
91
+ // ---------------------------------------------------------------------------
92
+ // Debounce state
93
+ // ---------------------------------------------------------------------------
94
+ /** Entries received since the last flush — coalesced into one message. */
95
+ let buffer = [];
96
+ /** Epoch-ms of the most recent entry arrival. Used to detect burst-quiet. */
97
+ let lastArrival = 0;
98
+ /**
99
+ * Durable cursor — ISO 8601 of the last entry we've consumed.
100
+ * Seeded from the persisted cursor file on first resolution; undefined means
101
+ * "read from the beginning" (no prior cursor → process all existing entries).
102
+ * NOT reset to `now` on first tick: that would silently drop entries that
103
+ * arrived between node creation and watcher startup (the startup race).
104
+ */
105
+ let cursor;
106
+ let seeded = false;
107
+ // ---------------------------------------------------------------------------
108
+ // Flush: deliver the buffered entries as a single pi user message.
109
+ // ---------------------------------------------------------------------------
110
+ const flush = () => {
111
+ if (buffer.length === 0)
112
+ return;
113
+ // Deferred-tier entries must never WAKE an idle node — by contract they ride
114
+ // the next natural turn, never interrupt. If everything buffered is deferred
115
+ // and the node is idle, hold them (leave buffered, cheap re-check each tick)
116
+ // and return without delivering. They flush the moment the node is next
117
+ // streaming, or a higher-tier entry joins the batch (every() turns false).
118
+ if (isIdle() && buffer.every((e) => e.tier === 'deferred'))
119
+ return;
120
+ const batch = buffer;
121
+ buffer = [];
122
+ const digest = coalesce(batch);
123
+ // Tier (and kind) drive delivery mode. Critical is a TRUE preempt; urgent —
124
+ // and a finished node (kind 'final') — steers at the turn boundary;
125
+ // normal/deferred ride the next turn (followUp). (A purely-deferred idle
126
+ // batch was already held above and never reaches here.) A completion a
127
+ // subscriber is likely blocked on must not drain as a follow-up, so 'final'
128
+ // steers exactly like 'urgent'.
129
+ const anyCritical = batch.some((e) => e.tier === 'critical');
130
+ const steerMidStream = anyCritical || batch.some((e) => e.tier === 'urgent' || e.kind === 'final');
131
+ try {
132
+ if (isIdle()) {
133
+ // Idle → trigger a new turn immediately (sendUserMessage always triggers).
134
+ pi.sendUserMessage(digest);
135
+ }
136
+ else if (anyCritical) {
137
+ // Critical mid-stream → TRUE preempt. ctx.abort() cancels the live LLM
138
+ // stream right now (stopReason becomes 'aborted'; the stophook stays alive
139
+ // on that). We then re-buffer and let the next tick deliver via the idle
140
+ // path — by then the turn has torn down and sendUserMessage starts a fresh
141
+ // turn. Relying on the proven idle path (not steer-after-abort semantics)
142
+ // keeps this robust; if abort hasn't settled by the next tick we simply
143
+ // abort again and retry — idempotent and self-healing.
144
+ try {
145
+ lastCtx?.abort?.();
146
+ }
147
+ catch { /* abort is best-effort */ }
148
+ buffer = batch.concat(buffer);
149
+ }
150
+ else {
151
+ // Mid-stream → steer on urgency or a finished node, else enqueue for the
152
+ // turn after this one.
153
+ pi.sendUserMessage(digest, { deliverAs: steerMidStream ? 'steer' : 'followUp' });
154
+ }
155
+ }
156
+ catch {
157
+ // Re-queue on delivery failure so a transient error doesn't silently drop
158
+ // inbox entries. They will be retried on the next flush.
159
+ buffer = batch.concat(buffer);
160
+ }
161
+ };
162
+ // ---------------------------------------------------------------------------
163
+ // Tick: poll the node's inbox and buffer new arrivals.
164
+ // ---------------------------------------------------------------------------
165
+ const tick = () => {
166
+ try {
167
+ // Re-read env each tick: CRTR_NODE_ID could theoretically be set after the
168
+ // extension factory runs (e.g. the runtime injects it just before the first
169
+ // turn). In practice it is always present before turn_end fires, but the
170
+ // check is cheap and keeps the watcher robust.
171
+ const nodeId = process.env['CRTR_NODE_ID'];
172
+ if (nodeId === undefined || nodeId.trim() === '')
173
+ return;
174
+ // Seed the cursor once, on the first tick that resolves a nodeId.
175
+ // readCursor returns undefined when no cursor file exists → readInboxSince
176
+ // with undefined returns ALL entries (no truncation to `now`).
177
+ if (!seeded) {
178
+ cursor = readCursor(nodeId);
179
+ seeded = true;
180
+ }
181
+ const newEntries = readInboxSince(nodeId, cursor);
182
+ if (newEntries.length > 0) {
183
+ // Advance and persist the cursor BEFORE buffering, so a crash after this
184
+ // point loses at most one coalesced message rather than re-injecting
185
+ // already-delivered entries on restart (exactly-once over restart contract).
186
+ const latest = newEntries.reduce((a, b) => (a.ts > b.ts ? a : b));
187
+ cursor = latest.ts;
188
+ writeCursor(nodeId, cursor);
189
+ buffer.push(...newEntries);
190
+ lastArrival = Date.now();
191
+ }
192
+ // Flush only once the burst has settled (no new entry within DEBOUNCE_MS)
193
+ // so near-simultaneous pushes from multiple workers arrive as one message.
194
+ if (buffer.length > 0 && Date.now() - lastArrival >= DEBOUNCE_MS) {
195
+ flush();
196
+ }
197
+ }
198
+ catch {
199
+ /* watcher is best-effort; a tick must never crash the host session */
200
+ }
201
+ };
202
+ // ---------------------------------------------------------------------------
203
+ // Timer management — clear any leftover timer from a prior /reload.
204
+ // ---------------------------------------------------------------------------
205
+ if (liveTimer !== undefined)
206
+ clearInterval(liveTimer);
207
+ const timer = setInterval(tick, TICK_MS);
208
+ // unref() so the watcher doesn't keep the Node process alive when everything
209
+ // else has finished (matches legacy watcher behaviour).
210
+ if (typeof timer.unref === 'function')
211
+ timer.unref();
212
+ liveTimer = timer;
213
+ // pi DOES fire session_shutdown — use it as the authoritative teardown so a
214
+ // re-init (e.g. /reload) never discovers a live sibling timer.
215
+ pi.on('session_shutdown', () => {
216
+ clearInterval(timer);
217
+ if (liveTimer === timer)
218
+ liveTimer = undefined;
219
+ });
220
+ // Disposer: returned for testability + explicit teardown in test harnesses.
221
+ // pi ignores the factory return value, so the module-level guard above is what
222
+ // actually prevents stacking in production.
223
+ return () => {
224
+ clearInterval(timer);
225
+ if (liveTimer === timer)
226
+ liveTimer = undefined;
227
+ };
228
+ }
229
+ export default registerCanvasInboxWatcher;
@@ -0,0 +1,32 @@
1
+ type PiEvents = 'session_start' | 'turn_end' | 'session_shutdown';
2
+ interface ExtensionWidgetOptions {
3
+ /** Where the widget is rendered. "aboveEditor" | "belowEditor" */
4
+ placement?: 'aboveEditor' | 'belowEditor';
5
+ }
6
+ interface UIContext {
7
+ setWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): void;
8
+ /** Raw key tap that fires BEFORE the editor. Return {consume:true} to swallow
9
+ * the key (so e.g. UP doesn't trigger pi's history recall). Returns unsub. */
10
+ onTerminalInput?(handler: (data: string) => {
11
+ consume?: boolean;
12
+ data?: string;
13
+ } | undefined): () => void;
14
+ /** Current editor buffer text — used to only hijack keys on an empty editor. */
15
+ getEditorText?(): string;
16
+ /** Transient toast, used to report a failed focus. */
17
+ notify?(message: string, type?: 'info' | 'warning' | 'error'): void;
18
+ }
19
+ interface ExtensionCtx {
20
+ ui: UIContext;
21
+ }
22
+ interface PiLike {
23
+ on(event: PiEvents, handler: (event: any, ctx: ExtensionCtx) => void | Promise<void>): void;
24
+ }
25
+ /**
26
+ * Register the canvas nav chrome on `pi`.
27
+ *
28
+ * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
29
+ * inert in a non-canvas pi session.
30
+ */
31
+ export declare function registerCanvasNav(pi: PiLike): void;
32
+ export default registerCanvasNav;