@crouton-kit/crouter 0.3.8 → 0.3.12

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 (184) 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 +25 -27
  23. package/dist/commands/{job.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/{agent.d.ts → daemon.d.ts} +1 -1
  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 +15 -427
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +354 -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 +8 -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 +12 -681
  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 +9 -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/__tests__/resolver.test.js +69 -1
  84. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  85. package/dist/core/__tests__/unknown-path.test.js +52 -0
  86. package/dist/core/bootstrap.d.ts +2 -0
  87. package/dist/core/bootstrap.js +66 -0
  88. package/dist/core/canvas/attention.d.ts +24 -0
  89. package/dist/core/canvas/attention.js +94 -0
  90. package/dist/core/canvas/canvas.d.ts +40 -0
  91. package/dist/core/canvas/canvas.js +210 -0
  92. package/dist/core/canvas/db.d.ts +7 -0
  93. package/dist/core/canvas/db.js +61 -0
  94. package/dist/core/canvas/index.d.ts +4 -0
  95. package/dist/core/canvas/index.js +6 -0
  96. package/dist/core/canvas/paths.d.ts +16 -0
  97. package/dist/core/canvas/paths.js +62 -0
  98. package/dist/core/canvas/render.d.ts +30 -0
  99. package/dist/core/canvas/render.js +186 -0
  100. package/dist/core/canvas/types.d.ts +87 -0
  101. package/dist/core/canvas/types.js +8 -0
  102. package/dist/core/command.d.ts +63 -2
  103. package/dist/core/command.js +97 -24
  104. package/dist/core/feed/feed.d.ts +43 -0
  105. package/dist/core/feed/feed.js +116 -0
  106. package/dist/core/feed/inbox.d.ts +50 -0
  107. package/dist/core/feed/inbox.js +124 -0
  108. package/dist/core/frontmatter.d.ts +10 -0
  109. package/dist/core/frontmatter.js +24 -9
  110. package/dist/core/help.d.ts +39 -8
  111. package/dist/core/help.js +69 -35
  112. package/dist/core/io.d.ts +15 -1
  113. package/dist/core/io.js +56 -6
  114. package/dist/core/personas/index.d.ts +12 -0
  115. package/dist/core/personas/index.js +10 -0
  116. package/dist/core/personas/loader.d.ts +44 -0
  117. package/dist/core/personas/loader.js +157 -0
  118. package/dist/core/personas/resolve.d.ts +36 -0
  119. package/dist/core/personas/resolve.js +110 -0
  120. package/dist/core/render.d.ts +11 -0
  121. package/dist/core/render.js +126 -0
  122. package/dist/core/resolver.d.ts +10 -0
  123. package/dist/core/resolver.js +160 -2
  124. package/dist/core/runtime/front-door.d.ts +10 -0
  125. package/dist/core/runtime/front-door.js +97 -0
  126. package/dist/core/runtime/kickoff.d.ts +23 -0
  127. package/dist/core/runtime/kickoff.js +134 -0
  128. package/dist/core/runtime/launch.d.ts +34 -0
  129. package/dist/core/runtime/launch.js +85 -0
  130. package/dist/core/runtime/nodes.d.ts +38 -0
  131. package/dist/core/runtime/nodes.js +95 -0
  132. package/dist/core/runtime/presence.d.ts +38 -0
  133. package/dist/core/runtime/presence.js +152 -0
  134. package/dist/core/runtime/promote.d.ts +30 -0
  135. package/dist/core/runtime/promote.js +105 -0
  136. package/dist/core/runtime/reset.d.ts +13 -0
  137. package/dist/core/runtime/reset.js +97 -0
  138. package/dist/core/runtime/revive.d.ts +26 -0
  139. package/dist/core/runtime/revive.js +89 -0
  140. package/dist/core/runtime/roadmap.d.ts +12 -0
  141. package/dist/core/runtime/roadmap.js +52 -0
  142. package/dist/core/runtime/spawn.d.ts +33 -0
  143. package/dist/core/runtime/spawn.js +118 -0
  144. package/dist/core/runtime/stop-guard.d.ts +18 -0
  145. package/dist/core/runtime/stop-guard.js +33 -0
  146. package/dist/core/runtime/tmux.d.ts +88 -0
  147. package/dist/core/runtime/tmux.js +198 -0
  148. package/dist/core/spawn.d.ts +17 -80
  149. package/dist/core/spawn.js +15 -219
  150. package/dist/daemon/crtrd-cli.d.ts +1 -0
  151. package/dist/daemon/crtrd-cli.js +4 -0
  152. package/dist/daemon/crtrd.d.ts +20 -0
  153. package/dist/daemon/crtrd.js +200 -0
  154. package/dist/daemon/manage.d.ts +17 -0
  155. package/dist/daemon/manage.js +57 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  158. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  159. package/dist/pi-extensions/canvas-nav.js +536 -0
  160. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  161. package/dist/pi-extensions/canvas-stophook.js +373 -0
  162. package/dist/types.d.ts +21 -0
  163. package/dist/types.js +3 -0
  164. package/package.json +6 -5
  165. package/dist/commands/agent.js +0 -384
  166. package/dist/commands/debug.d.ts +0 -3
  167. package/dist/commands/debug.js +0 -179
  168. package/dist/commands/job.js +0 -344
  169. package/dist/commands/plan.d.ts +0 -4
  170. package/dist/commands/plan.js +0 -309
  171. package/dist/commands/spec.d.ts +0 -3
  172. package/dist/commands/spec.js +0 -286
  173. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  174. package/dist/core/__tests__/job.test.js +0 -310
  175. package/dist/core/__tests__/jobs.test.js +0 -66
  176. package/dist/core/jobs.d.ts +0 -101
  177. package/dist/core/jobs.js +0 -462
  178. package/dist/prompts/agent.d.ts +0 -18
  179. package/dist/prompts/agent.js +0 -153
  180. package/dist/prompts/debug.d.ts +0 -8
  181. package/dist/prompts/debug.js +0 -44
  182. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  183. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  184. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
@@ -1,18 +1,11 @@
1
- // Tmux pane spawning machinery for crtr job subtree.
1
+ // Tmux pane detach helpers.
2
2
  //
3
- // Kept: spawnAgent (fire-and-forget new pane), spawnAndDetach (detach + kill originating pane),
4
- // shellQuote, isInTmux, countPanesInCurrentWindow, findWindowWithSpace.
5
- //
6
- // Removed: createSession, submitToSession, awaitSession, waitForResult,
7
- // sessionDirForId, writeSessionMeta, readSessionMeta all superseded
8
- // by the jobs.ts sidecar model (result.json + log.jsonl).
9
- //
10
- // Crash detection: the wrapper shell command is:
11
- // `claude --dangerously-skip-permissions <prompt>; crtr job _fail <job_id>`
12
- // If the worker calls `crtr job submit` before claude exits, result.json is
13
- // written and `_fail` is a no-op (writeResult is idempotent for done status).
14
- // If claude dies without a submit, `_fail` writes status 'failed'. Either way
15
- // `job read result` sees a terminal result.json.
3
+ // A small set of tmux primitives used by the `human` command tree to put the
4
+ // humanloop TUI in a detached pane: spawnAndDetach (open a pane running a given
5
+ // command), countPanesInCurrentWindow (placement decision), plus shellQuote and
6
+ // isInTmux. The canvas runtime has its own one-window-per-node machinery in
7
+ // core/runtime/tmux.ts; this module is only the pane-split path the human TUI
8
+ // needs.
16
9
  import { spawnSync } from 'node:child_process';
17
10
  export function isInTmux() {
18
11
  return Boolean(process.env.TMUX);
@@ -20,6 +13,7 @@ export function isInTmux() {
20
13
  export function shellQuote(s) {
21
14
  return `'${s.replace(/'/g, "'\\''")}'`;
22
15
  }
16
+ /** Count panes in the current tmux window (0 outside tmux / on error). */
23
17
  export function countPanesInCurrentWindow() {
24
18
  const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
25
19
  encoding: 'utf8',
@@ -28,126 +22,10 @@ export function countPanesInCurrentWindow() {
28
22
  return 0;
29
23
  return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
30
24
  }
31
- function listWindowsInCurrentSession() {
32
- const result = spawnSync('tmux', ['list-windows', '-F', '#{window_id} #{window_panes} #{window_active}'], { encoding: 'utf8' });
33
- if (result.status !== 0)
34
- return [];
35
- return result.stdout
36
- .split('\n')
37
- .filter((line) => line.trim() !== '')
38
- .map((line) => {
39
- const [id, count, active] = line.split(' ');
40
- return {
41
- windowId: id,
42
- paneCount: Number.parseInt(count, 10),
43
- isActive: active === '1',
44
- };
45
- });
46
- }
47
- /**
48
- * Map of window_id → list of pane TTYs (basename, e.g. `ttys008`) for every
49
- * pane in the current tmux session. Used as the bridge between tmux's pane
50
- * model and the system process table for foreground-command lookup.
51
- *
52
- * tmux's `#{pane_current_command}` is unreliable on macOS because the Claude
53
- * Code CLI sets `process.title` to its version (e.g. `2.1.143`), which is what
54
- * tmux then reports. Going through the TTY + `ps` gives us the real binary
55
- * name (`claude`) from the kernel.
56
- */
57
- function paneTtysByWindow() {
58
- const result = spawnSync('tmux', ['list-panes', '-s', '-F', '#{window_id} #{pane_tty}'], { encoding: 'utf8' });
59
- const out = new Map();
60
- if (result.status !== 0)
61
- return out;
62
- for (const line of result.stdout.split('\n')) {
63
- if (line.trim() === '')
64
- continue;
65
- const idx = line.indexOf(' ');
66
- if (idx === -1)
67
- continue;
68
- const windowId = line.slice(0, idx);
69
- const tty = line.slice(idx + 1);
70
- const ttyBase = tty.startsWith('/dev/') ? tty.slice(5) : tty;
71
- const existing = out.get(windowId);
72
- if (existing === undefined) {
73
- out.set(windowId, [ttyBase]);
74
- }
75
- else {
76
- existing.push(ttyBase);
77
- }
78
- }
79
- return out;
80
- }
81
- /**
82
- * Map of tty basename → set of foreground process `comm` names on that tty.
83
- * A process is "foreground" if its STAT field includes `+` (member of the
84
- * terminal's foreground process group). Built from one `ps -axo ...` call.
85
- */
86
- function foregroundCommsByTty() {
87
- const result = spawnSync('ps', ['-axo', 'stat=,comm=,tty='], { encoding: 'utf8' });
88
- const out = new Map();
89
- if (result.status !== 0)
90
- return out;
91
- for (const line of result.stdout.split('\n')) {
92
- if (line.trim() === '')
93
- continue;
94
- const m = line.match(/^(\S+)\s+(.+?)\s+(\S+)\s*$/);
95
- if (m === null)
96
- continue;
97
- const [, stat, comm, tty] = m;
98
- if (!stat.includes('+'))
99
- continue;
100
- if (tty === '??' || tty === '?')
101
- continue;
102
- const existing = out.get(tty);
103
- if (existing === undefined) {
104
- out.set(tty, new Set([comm.trim()]));
105
- }
106
- else {
107
- existing.add(comm.trim());
108
- }
109
- }
110
- return out;
111
- }
112
- /**
113
- * Find a window in the current tmux session with fewer than `maxPanesPerWindow`
114
- * panes AND where every existing pane has `claude` as a foreground process.
115
- * Prefers the active window so the spawned pane is visible to the user;
116
- * otherwise falls back to the first other eligible window. Returns the tmux
117
- * window id (e.g. `@5`) to pass via `-t`, or null if no window qualifies.
118
- *
119
- * Windows holding non-agent panes (dashboards, log tails, idle shells, editors,
120
- * REPLs, etc.) are skipped so spawning never disrupts those workflows. A pane
121
- * qualifies as long as `claude` is among its foreground commands — co-resident
122
- * helpers like `caffeinate` don't disqualify it.
123
- */
124
- export function findWindowWithSpace(maxPanesPerWindow) {
125
- const windows = listWindowsInCurrentSession();
126
- const ttysByWindow = paneTtysByWindow();
127
- const fgByTty = foregroundCommsByTty();
128
- const isClaudeOnly = (windowId) => {
129
- const ttys = ttysByWindow.get(windowId);
130
- if (ttys === undefined || ttys.length === 0)
131
- return false;
132
- return ttys.every((tty) => fgByTty.get(tty)?.has('claude') === true);
133
- };
134
- const eligible = windows.filter((w) => w.paneCount < maxPanesPerWindow && isClaudeOnly(w.windowId));
135
- const active = eligible.find((w) => w.isActive);
136
- if (active !== undefined)
137
- return active.windowId;
138
- const first = eligible[0];
139
- if (first === undefined)
140
- return null;
141
- return first.windowId;
142
- }
143
25
  /**
144
26
  * Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
145
- * so the caller can return normally before the pane dies. No-op outside tmux
146
- * or when TMUX_PANE is unset.
147
- *
148
- * Used by `crtr job submit` (kill_pane=true) so a reviewer agent can self-close
149
- * its pane after delivering its verdict, and by `spawnAndDetach` for handoff
150
- * self-kill.
27
+ * so the caller can return normally before the pane dies. No-op outside tmux,
28
+ * when TMUX_PANE is unset, or when delaySeconds <= 0.
151
29
  */
152
30
  export function scheduleKillCurrentPane(delaySeconds) {
153
31
  const currentPane = process.env.TMUX_PANE;
@@ -161,24 +39,9 @@ export function scheduleKillCurrentPane(delaySeconds) {
161
39
  return true;
162
40
  }
163
41
  /**
164
- * Build the wrapper shell command passed to the tmux pane.
165
- *
166
- * Pattern: `claude <args>; crtr job _fail <job_id>`
167
- *
168
- * If the worker submits via `crtr job submit` before claude exits,
169
- * result.json is already written (`done`); `_fail` sees it and is a no-op.
170
- * If claude crashes/exits without submitting, `_fail` writes status `failed`
171
- * so `job read result` can distinguish completion from crash.
172
- */
173
- function wrapperCmd(claudeCmd, jobId) {
174
- return `${claudeCmd}; crtr job _fail ${shellQuote(jobId)}`;
175
- }
176
- /**
177
- * Fire-and-forget: launch an interactive `claude` in a new pane (or window),
178
- * then schedule the originating pane to be killed after `killAfterSeconds`.
179
- *
180
- * No custom system prompt — the task is delivered as the first user message.
181
- * Returns as soon as the new pane is up; does NOT wait for claude to finish.
42
+ * Fire-and-forget: launch `opts.command` in a new pane (or window), then
43
+ * schedule the originating pane to be killed after `killAfterSeconds`. Returns
44
+ * as soon as the new pane is up; does NOT wait for the command to finish.
182
45
  */
183
46
  export function spawnAndDetach(opts) {
184
47
  if (!isInTmux()) {
@@ -187,17 +50,6 @@ export function spawnAndDetach(opts) {
187
50
  message: 'handoff requires tmux (TMUX env var not set)',
188
51
  };
189
52
  }
190
- const buildClaudeInner = () => {
191
- const parts = ['claude'];
192
- if (opts.name !== undefined && opts.name !== '') {
193
- parts.push('-n', shellQuote(opts.name));
194
- }
195
- parts.push('--dangerously-skip-permissions', shellQuote(opts.prompt));
196
- return parts.join(' ');
197
- };
198
- const inner = opts.command !== undefined ? opts.command : buildClaudeInner();
199
- const useFailGuard = opts.failGuard !== false;
200
- const fullCmd = useFailGuard ? wrapperCmd(inner, opts.jobId) : inner;
201
53
  const splitArgs = [];
202
54
  if (opts.placement === 'new-window') {
203
55
  splitArgs.push('new-window');
@@ -218,7 +70,7 @@ export function spawnAndDetach(opts) {
218
70
  if (opts.jobId !== undefined) {
219
71
  splitArgs.push('-e', `CRTR_JOB_ID=${opts.jobId}`);
220
72
  }
221
- splitArgs.push(fullCmd);
73
+ splitArgs.push(opts.command);
222
74
  const split = spawnSync('tmux', splitArgs, { encoding: 'utf8' });
223
75
  if (split.status !== 0) {
224
76
  const stderrText = split.stderr.trim();
@@ -231,62 +83,6 @@ export function spawnAndDetach(opts) {
231
83
  return {
232
84
  status: 'spawned',
233
85
  paneId,
234
- message: `handed off to pane ${paneId}; this pane will close in ${opts.killAfterSeconds}s`,
235
- };
236
- }
237
- /**
238
- * Async sibling spawn. Launches a claude session in a tmux pane, progressively
239
- * filling existing windows up to `maxPanesPerWindow` before creating a new
240
- * window. Returns immediately with the pane id; the parent stays alive.
241
- *
242
- * Placement order:
243
- * 1. Current window, if it has space.
244
- * 2. Any other window in the session with space.
245
- * 3. New window (every existing window at capacity).
246
- *
247
- * If `fork` is set, uses `claude --resume <id> --fork-session`.
248
- */
249
- export function spawnAgent(opts) {
250
- if (!isInTmux()) {
251
- return {
252
- status: 'not-in-tmux',
253
- message: 'crtr job requires tmux (TMUX env var not set)',
254
- };
255
- }
256
- const claudeParts = ['claude'];
257
- if (opts.name !== undefined && opts.name !== '') {
258
- claudeParts.push('-n', shellQuote(opts.name));
259
- }
260
- if (opts.fork !== undefined) {
261
- claudeParts.push('--resume', opts.fork.sessionId, '--fork-session');
262
- }
263
- claudeParts.push('--dangerously-skip-permissions', shellQuote(opts.prompt));
264
- const claudeCmd = claudeParts.join(' ');
265
- const fullCmd = wrapperCmd(claudeCmd, opts.jobId);
266
- const targetWindow = findWindowWithSpace(opts.maxPanesPerWindow);
267
- const placement = targetWindow === null ? 'new-window' : 'split-window';
268
- const tmuxArgs = [placement];
269
- if (placement === 'split-window') {
270
- tmuxArgs.push('-h', '-t', targetWindow);
271
- }
272
- tmuxArgs.push('-P', '-F', '#{pane_id}', '-c', opts.cwd, '-e', `CRTR_JOB_ID=${opts.jobId}`, fullCmd);
273
- const split = spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
274
- if (split.status !== 0) {
275
- const stderrText = split.stderr.trim();
276
- const msg = stderrText === '' ? `tmux ${placement} failed` : stderrText;
277
- return { status: 'spawn-failed', message: msg };
278
- }
279
- const paneId = split.stdout.trim();
280
- // Re-balance the target window's panes evenly so the new pane doesn't end up
281
- // half the size of its siblings. -t <pane_id> resolves to the window it lives
282
- // in for both placements (split + new-window).
283
- spawnSync('tmux', ['select-layout', '-t', paneId, 'even-horizontal'], {
284
- encoding: 'utf8',
285
- });
286
- return {
287
- status: 'spawned',
288
- paneId,
289
- placement,
290
- message: `agent spawned in pane ${paneId} (${placement})`,
86
+ message: `handed off to pane ${paneId}`,
291
87
  };
292
88
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ // crtrd entry point — spawned detached by `crtr canvas daemon start` and by bin/crtrd.
2
+ // Calls runDaemon() and never returns (the loop drives via setTimeout).
3
+ import { runDaemon } from './crtrd.js';
4
+ runDaemon();
@@ -0,0 +1,20 @@
1
+ /** Read the pid stored in the pidfile, or null if absent / malformed. */
2
+ export declare function readPidfile(): number | null;
3
+ /** True if a process with `pid` is currently alive (signal-0 probe). */
4
+ export declare function isPidAlive(pid: number): boolean;
5
+ /** True when a crtrd process is already running (pidfile exists + pid alive). */
6
+ export declare function isDaemonRunning(): boolean;
7
+ export interface DaemonOpts {
8
+ /** Milliseconds between supervision polls. Default 2000. */
9
+ intervalMs?: number;
10
+ }
11
+ /** Start the supervisor loop.
12
+ *
13
+ * If a live crtrd is already running (pidfile + pid alive), exits immediately
14
+ * (exit 0 — idempotent, not an error). Otherwise, writes the pidfile, sets up
15
+ * signal handlers, and enters the poll loop.
16
+ *
17
+ * Returns a teardown callback that stops the loop and removes the pidfile.
18
+ * (Mainly useful for tests; in production the daemon runs until signaled.) */
19
+ export declare function runDaemon(opts?: DaemonOpts): () => void;
20
+ export default runDaemon;
@@ -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;