@crouton-kit/crouter 0.3.18 → 0.3.19

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 (257) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/orchestrator.md +1 -1
  12. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  13. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  14. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  15. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  16. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  17. package/dist/builtin-personas/runtime-base.md +6 -3
  18. package/dist/builtin-personas/spec/orchestrator.md +1 -1
  19. package/dist/builtin-personas/waiting.md +8 -0
  20. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  21. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  22. package/dist/builtin-views/_lib/states.mjs +161 -0
  23. package/dist/builtin-views/canvas/client.mjs +303 -0
  24. package/dist/builtin-views/canvas/view.mjs +576 -0
  25. package/dist/builtin-views/git-pr/client.mjs +440 -0
  26. package/dist/builtin-views/git-pr/view.mjs +675 -0
  27. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  28. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  29. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  30. package/dist/builtin-views/inbox/view.mjs +889 -0
  31. package/dist/builtin-views/linkedin/client.mjs +610 -0
  32. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  34. package/dist/clients/attach/attach-cmd.js +354 -0
  35. package/dist/clients/attach/chat-view.d.ts +77 -0
  36. package/dist/clients/attach/chat-view.js +450 -0
  37. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  38. package/dist/clients/attach/clipboard-image.js +113 -0
  39. package/dist/clients/attach/config-load.d.ts +31 -0
  40. package/dist/clients/attach/config-load.js +113 -0
  41. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  42. package/dist/clients/attach/extension-dialogs.js +101 -0
  43. package/dist/clients/attach/input-controller.d.ts +54 -0
  44. package/dist/clients/attach/input-controller.js +204 -0
  45. package/dist/clients/attach/slash-commands.d.ts +36 -0
  46. package/dist/clients/attach/slash-commands.js +200 -0
  47. package/dist/clients/attach/view-socket.d.ts +48 -0
  48. package/dist/clients/attach/view-socket.js +126 -0
  49. package/dist/commands/attention.js +3 -3
  50. package/dist/commands/canvas-prune.js +1 -1
  51. package/dist/commands/daemon.js +3 -2
  52. package/dist/commands/human/prompts.js +1 -1
  53. package/dist/commands/human/queue.js +43 -8
  54. package/dist/commands/human/shared.d.ts +2 -0
  55. package/dist/commands/memory/find.d.ts +1 -0
  56. package/dist/commands/memory/find.js +180 -0
  57. package/dist/commands/memory/lint.d.ts +1 -0
  58. package/dist/commands/memory/lint.js +140 -0
  59. package/dist/commands/memory/list.d.ts +1 -0
  60. package/dist/commands/memory/list.js +79 -0
  61. package/dist/commands/memory/read.js +103 -0
  62. package/dist/commands/memory/shared.d.ts +30 -0
  63. package/dist/commands/memory/shared.js +122 -0
  64. package/dist/commands/memory/write.d.ts +1 -0
  65. package/dist/commands/memory/write.js +85 -0
  66. package/dist/commands/memory.d.ts +2 -0
  67. package/dist/commands/memory.js +27 -0
  68. package/dist/commands/node.d.ts +3 -2
  69. package/dist/commands/node.js +607 -61
  70. package/dist/commands/pkg/market-manage.js +1 -1
  71. package/dist/commands/push.js +6 -6
  72. package/dist/commands/revive.js +1 -1
  73. package/dist/commands/skill/author.js +1 -1
  74. package/dist/commands/skill/shared.d.ts +1 -8
  75. package/dist/commands/skill/shared.js +2 -55
  76. package/dist/commands/skill.js +9 -14
  77. package/dist/commands/sys/doctor.js +1 -1
  78. package/dist/commands/sys/update.js +1 -1
  79. package/dist/commands/view-cycle.d.ts +2 -0
  80. package/dist/commands/view-cycle.js +125 -0
  81. package/dist/commands/view-list.d.ts +2 -0
  82. package/dist/commands/view-list.js +66 -0
  83. package/dist/commands/view-new.d.ts +2 -0
  84. package/dist/commands/view-new.js +70 -0
  85. package/dist/commands/view-pick.d.ts +2 -0
  86. package/dist/commands/view-pick.js +119 -0
  87. package/dist/commands/view-run.d.ts +2 -0
  88. package/dist/commands/view-run.js +191 -0
  89. package/dist/commands/view.d.ts +2 -0
  90. package/dist/commands/view.js +29 -0
  91. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  92. package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
  93. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  94. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  95. package/dist/core/__tests__/cascade-close.test.js +12 -2
  96. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  97. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  98. package/dist/core/__tests__/context-intro.test.js +76 -62
  99. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  100. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  101. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  102. package/dist/core/__tests__/detach-focus.test.js +206 -0
  103. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  104. package/dist/core/__tests__/draw-style.test.js +258 -0
  105. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  106. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  107. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  108. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  109. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  110. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  111. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  112. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  113. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  114. package/dist/core/__tests__/helpers/harness.js +111 -1
  115. package/dist/core/__tests__/home-session.test.js +41 -8
  116. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  117. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  118. package/dist/core/__tests__/kickoff.test.js +37 -3
  119. package/dist/core/__tests__/live-mutation.test.js +50 -33
  120. package/dist/core/__tests__/memory.test.js +23 -115
  121. package/dist/core/__tests__/placement-focus.test.js +5 -0
  122. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  123. package/dist/core/__tests__/relaunch.test.js +4 -3
  124. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  125. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  126. package/dist/core/__tests__/spawn-root.test.js +10 -0
  127. package/dist/core/__tests__/spike-harness.test.js +1 -0
  128. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  129. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  130. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  131. package/dist/core/__tests__/wake-origin.test.js +110 -0
  132. package/dist/core/bootstrap.js +1 -1
  133. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  134. package/dist/core/canvas/browse/app.js +24 -2
  135. package/dist/core/canvas/browse/model.d.ts +38 -2
  136. package/dist/core/canvas/browse/model.js +134 -10
  137. package/dist/core/canvas/browse/render.d.ts +6 -12
  138. package/dist/core/canvas/browse/render.js +72 -104
  139. package/dist/core/canvas/canvas.js +15 -22
  140. package/dist/core/canvas/db.js +46 -0
  141. package/dist/core/canvas/index.d.ts +1 -0
  142. package/dist/core/canvas/index.js +1 -0
  143. package/dist/core/canvas/pid.d.ts +4 -0
  144. package/dist/core/canvas/pid.js +23 -0
  145. package/dist/core/canvas/render.d.ts +11 -2
  146. package/dist/core/canvas/render.js +69 -0
  147. package/dist/core/canvas/types.d.ts +85 -3
  148. package/dist/core/canvas/wakeups.d.ts +76 -0
  149. package/dist/core/canvas/wakeups.js +185 -0
  150. package/dist/core/config.js +4 -1
  151. package/dist/core/frontmatter.js +37 -124
  152. package/dist/core/memory-resolver.d.ts +49 -0
  153. package/dist/core/memory-resolver.js +141 -0
  154. package/dist/core/personas/loader.d.ts +7 -0
  155. package/dist/core/personas/loader.js +36 -5
  156. package/dist/core/personas/resolve.js +13 -3
  157. package/dist/core/predicate.d.ts +63 -0
  158. package/dist/core/predicate.js +189 -0
  159. package/dist/core/resolver.js +26 -5
  160. package/dist/core/runtime/bearings.d.ts +53 -12
  161. package/dist/core/runtime/bearings.js +132 -59
  162. package/dist/core/runtime/broker-cli.d.ts +1 -0
  163. package/dist/core/runtime/broker-cli.js +46 -0
  164. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  165. package/dist/core/runtime/broker-protocol.js +153 -0
  166. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  167. package/dist/core/runtime/broker-sdk.js +72 -0
  168. package/dist/core/runtime/broker.d.ts +55 -0
  169. package/dist/core/runtime/broker.js +1128 -0
  170. package/dist/core/runtime/close.js +35 -6
  171. package/dist/core/runtime/host.d.ts +53 -0
  172. package/dist/core/runtime/host.js +186 -0
  173. package/dist/core/runtime/kickoff.d.ts +2 -1
  174. package/dist/core/runtime/kickoff.js +91 -5
  175. package/dist/core/runtime/launch.d.ts +45 -2
  176. package/dist/core/runtime/launch.js +65 -2
  177. package/dist/core/runtime/lifecycle.js +23 -6
  178. package/dist/core/runtime/memory.d.ts +2 -42
  179. package/dist/core/runtime/memory.js +11 -162
  180. package/dist/core/runtime/nodes.d.ts +33 -0
  181. package/dist/core/runtime/nodes.js +59 -1
  182. package/dist/core/runtime/persona.js +21 -11
  183. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  184. package/dist/core/runtime/pi-vendored.js +49 -0
  185. package/dist/core/runtime/placement.d.ts +32 -14
  186. package/dist/core/runtime/placement.js +206 -52
  187. package/dist/core/runtime/promote.d.ts +0 -6
  188. package/dist/core/runtime/promote.js +1 -12
  189. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  190. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  191. package/dist/core/runtime/reset.js +8 -6
  192. package/dist/core/runtime/revive.d.ts +2 -0
  193. package/dist/core/runtime/revive.js +34 -34
  194. package/dist/core/runtime/spawn.d.ts +19 -0
  195. package/dist/core/runtime/spawn.js +75 -22
  196. package/dist/core/runtime/stop-guard.d.ts +1 -1
  197. package/dist/core/runtime/stop-guard.js +6 -1
  198. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  199. package/dist/core/runtime/tmux-chrome.js +1 -1
  200. package/dist/core/runtime/tmux.d.ts +28 -0
  201. package/dist/core/runtime/tmux.js +80 -6
  202. package/dist/core/scope.d.ts +11 -0
  203. package/dist/core/scope.js +39 -0
  204. package/dist/core/spawn.d.ts +5 -0
  205. package/dist/core/spawn.js +20 -2
  206. package/dist/core/substrate/gate.d.ts +13 -0
  207. package/dist/core/substrate/gate.js +21 -0
  208. package/dist/core/substrate/index.d.ts +7 -0
  209. package/dist/core/substrate/index.js +18 -0
  210. package/dist/core/substrate/on-read.d.ts +14 -0
  211. package/dist/core/substrate/on-read.js +292 -0
  212. package/dist/core/substrate/render.d.ts +25 -0
  213. package/dist/core/substrate/render.js +256 -0
  214. package/dist/core/substrate/schema.d.ts +76 -0
  215. package/dist/core/substrate/schema.js +124 -0
  216. package/dist/core/substrate/session-cache.d.ts +30 -0
  217. package/dist/core/substrate/session-cache.js +77 -0
  218. package/dist/core/substrate/subject.d.ts +41 -0
  219. package/dist/core/substrate/subject.js +54 -0
  220. package/dist/core/tui/contract.d.ts +83 -0
  221. package/dist/core/tui/contract.js +8 -0
  222. package/dist/core/tui/draw.d.ts +96 -0
  223. package/dist/core/tui/draw.js +339 -0
  224. package/dist/core/tui/host.d.ts +29 -0
  225. package/dist/core/tui/host.js +379 -0
  226. package/dist/core/tui/loader.d.ts +16 -0
  227. package/dist/core/tui/loader.js +94 -0
  228. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  229. package/dist/core/wake.d.ts +86 -0
  230. package/dist/core/wake.js +308 -0
  231. package/dist/daemon/crtrd.d.ts +29 -4
  232. package/dist/daemon/crtrd.js +662 -46
  233. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  234. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  235. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  236. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  237. package/dist/pi-extensions/canvas-commands.js +10 -0
  238. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  239. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  240. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  241. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  242. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  243. package/dist/pi-extensions/canvas-nav.js +87 -23
  244. package/dist/pi-extensions/canvas-stophook.js +17 -8
  245. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  246. package/dist/pi-extensions/canvas-view.js +75 -0
  247. package/dist/prompts/skill.js +19 -26
  248. package/dist/prompts/view.d.ts +7 -0
  249. package/dist/prompts/view.js +101 -0
  250. package/dist/types.d.ts +4 -0
  251. package/dist/types.js +1 -0
  252. package/package.json +9 -4
  253. package/dist/commands/skill/find.d.ts +0 -4
  254. package/dist/commands/skill/find.js +0 -257
  255. package/dist/commands/skill/read.js +0 -91
  256. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  257. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -124,19 +124,133 @@ export function matchIndices(query, text) {
124
124
  out.clear(); // no full match → no highlight
125
125
  return out;
126
126
  }
127
+ /** Chars of leading context kept before the match start when windowing. */
128
+ const SNIPPET_LEAD = 12;
129
+ /** Highlight indices for the preview: the literal case-insensitive SUBSTRING span
130
+ * when present (so "where does this text appear?" is answered exactly), else the
131
+ * scattered subsequence indices, else empty (empty query). This is a different,
132
+ * stricter model than the subsequence super-search on purpose — a contiguous span
133
+ * is what reads as a highlight. */
134
+ export function highlightIndices(query, text) {
135
+ if (query === '')
136
+ return new Set();
137
+ const lower = text.toLowerCase();
138
+ const q = query.toLowerCase();
139
+ const sub = lower.indexOf(q);
140
+ if (sub >= 0) {
141
+ const out = new Set();
142
+ for (let i = sub; i < sub + q.length; i++)
143
+ out.add(i);
144
+ return out;
145
+ }
146
+ return matchIndices(query, text);
147
+ }
148
+ /** Greedy word-wrap of single-spaced `s` (caller normalizes whitespace) to `width`
149
+ * cols × `maxLines` lines, returning each line's text AND its start offset in `s`.
150
+ * Only the single space at each wrap break is dropped, so a highlight index maps
151
+ * to a line column as `idx - line.start` with no drift across the wrap. */
152
+ function wrapTracked(s, width, maxLines) {
153
+ const out = [];
154
+ if (width <= 0 || maxLines <= 0)
155
+ return out;
156
+ const n = s.length;
157
+ let i = 0;
158
+ while (i < n && out.length < maxLines) {
159
+ const lineStart = i;
160
+ let lineEnd = i; // exclusive end of committed words
161
+ let j = i;
162
+ while (j < n) {
163
+ let ws = j;
164
+ while (ws < n && s[ws] === ' ')
165
+ ws++;
166
+ let we = ws;
167
+ while (we < n && s[we] !== ' ')
168
+ we++;
169
+ if (ws === we)
170
+ break; // only trailing spaces remain
171
+ if (we - lineStart <= width || lineEnd === lineStart) {
172
+ lineEnd = we; // include this word (always take ≥1; a huge word is clipped below)
173
+ j = we;
174
+ }
175
+ else
176
+ break;
177
+ }
178
+ let text = s.slice(lineStart, lineEnd);
179
+ if (text.length > width)
180
+ text = text.slice(0, width);
181
+ out.push({ text, start: lineStart });
182
+ i = lineEnd;
183
+ if (i < n && s[i] === ' ')
184
+ i++; // drop the single break space
185
+ }
186
+ return out;
187
+ }
188
+ /**
189
+ * Build the preview snippet for `text` under the live `query`: up to `maxLines`
190
+ * word-wrapped lines (each ≤ `width` cols), WINDOWED so the best match is visible
191
+ * (long conversations: the match can be thousands of chars in), with the matched
192
+ * columns flagged for highlight. Empty query (or empty text) → a plain wrap from
193
+ * the start with no highlight. The snippet string itself is what carries the
194
+ * highlight indices, so they never drift across the windowing.
195
+ */
196
+ export function previewSnippet(query, text, width, maxLines) {
197
+ const norm = text.replace(/\s+/g, ' ').trim();
198
+ if (norm === '' || width <= 0 || maxLines <= 0)
199
+ return [];
200
+ // 1. Locate the match in the normalized text + choose a window start that keeps
201
+ // a little leading context, snapped to a word boundary.
202
+ let matchStart = -1;
203
+ if (query !== '') {
204
+ const sub = norm.toLowerCase().indexOf(query.toLowerCase());
205
+ if (sub >= 0)
206
+ matchStart = sub;
207
+ else {
208
+ const ix = matchIndices(query, norm);
209
+ if (ix.size > 0)
210
+ matchStart = Math.min(...ix);
211
+ }
212
+ }
213
+ let windowStart = 0;
214
+ if (matchStart > SNIPPET_LEAD) {
215
+ let s = matchStart - SNIPPET_LEAD;
216
+ while (s < matchStart && norm[s] !== ' ')
217
+ s++; // forward to the next word boundary
218
+ windowStart = s < matchStart ? s + 1 : matchStart;
219
+ }
220
+ const snippet = (windowStart > 0 ? '… ' : '') + norm.slice(windowStart);
221
+ // 2. Recompute highlight indices on the FINAL snippet string (no drift), then
222
+ // word-wrap with offset tracking and split the highlight into per-line cols.
223
+ const hi = highlightIndices(query, snippet);
224
+ return wrapTracked(snippet, width, maxLines).map(({ text: lt, start }) => {
225
+ const lhi = new Set();
226
+ for (const idx of hi)
227
+ if (idx >= start && idx < start + lt.length)
228
+ lhi.add(idx - start);
229
+ return { text: lt, hi: lhi };
230
+ });
231
+ }
127
232
  function shortId(id) {
128
233
  return id.slice(0, 8);
129
234
  }
235
+ /** The searchable conversation text for a row: EVERY user prompt across the pi
236
+ * session (`prompts`) when present, else the spawn prompt (`goal`) for a
237
+ * never-revived node that has no session yet. Searched by super-search and
238
+ * windowed in the preview, so search matches a prompt from ANYWHERE in the
239
+ * conversation, not just the first one. */
240
+ export function promptText(row) {
241
+ return row.prompts ?? row.goal ?? '';
242
+ }
130
243
  /** Does this row match the live query? Super-search spans name (which already
131
- * folds in the pi-generated description), kind, short-id, AND the spawn prompt
132
- * (`row.goal`). Empty query matches everything. */
244
+ * folds in the pi-generated description), kind, short-id, AND every user prompt
245
+ * in the conversation (`promptText`). Empty query matches everything. */
133
246
  export function queryMatch(query, row) {
134
247
  if (query === '')
135
248
  return true;
249
+ const prompt = promptText(row);
136
250
  return (fuzzyMatch(query, row.name) ||
137
251
  fuzzyMatch(query, row.kind) ||
138
252
  fuzzyMatch(query, shortId(row.node_id)) ||
139
- (row.goal !== undefined && fuzzyMatch(query, row.goal)));
253
+ (prompt !== '' && fuzzyMatch(query, prompt)));
140
254
  }
141
255
  // ---------------------------------------------------------------------------
142
256
  // Relevance scoring (super-search)
@@ -159,23 +273,30 @@ export function fieldScore(query, text) {
159
273
  }
160
274
  return fuzzyMatch(q, t) ? 0.2 : 0; // scattered subsequence
161
275
  }
162
- /** Per-field weights — name (handle + description) dominates, the spawn prompt
163
- * is the long-tail super-search field. */
164
- const FIELD_WEIGHTS = { name: 4, kind: 2, id: 1, goal: 1.5 };
276
+ /** Per-field weights — name (handle + description) dominates, the conversation
277
+ * prompts are the long-tail super-search field. */
278
+ const FIELD_WEIGHTS = { name: 4, kind: 2, id: 1, prompt: 1.5 };
165
279
  /** Weighted relevance of a row to the query across all searched fields. 0 means
166
280
  * no field matched (excluded from relevance results, same as `queryMatch`). */
167
281
  export function scoreRow(query, row) {
168
282
  if (query === '')
169
283
  return 0;
284
+ const prompt = promptText(row);
170
285
  return (FIELD_WEIGHTS.name * fieldScore(query, row.name) +
171
286
  FIELD_WEIGHTS.kind * fieldScore(query, row.kind) +
172
287
  FIELD_WEIGHTS.id * fieldScore(query, shortId(row.node_id)) +
173
- FIELD_WEIGHTS.goal * (row.goal !== undefined ? fieldScore(query, row.goal) : 0));
288
+ FIELD_WEIGHTS.prompt * (prompt !== '' ? fieldScore(query, prompt) : 0));
174
289
  }
175
290
  /** Is this row inside the active cwd scope? No scope (null/undefined) = All dirs. */
176
291
  export function cwdMatch(scope, row) {
177
292
  return scope === null || scope === undefined || row.cwd === scope;
178
293
  }
294
+ /** Is this row inside the active lifecycle filter? `residentsOnly` hides `terminal`
295
+ * nodes; off (false/undefined) = every lifecycle. A row with an unknown lifecycle
296
+ * (older snapshot field absent) is treated as resident so it is never hidden. */
297
+ export function lifecycleMatch(residentsOnly, row) {
298
+ return residentsOnly !== true || row.lifecycle !== 'terminal';
299
+ }
179
300
  /**
180
301
  * Flatten the tree to the ordered list of currently-visible rows.
181
302
  *
@@ -189,11 +310,14 @@ export function cwdMatch(scope, row) {
189
310
  * always reachable.
190
311
  */
191
312
  export function flatten(tree, opts) {
192
- const { collapsed, tab, query, cwdScope, sort = 'tree' } = opts;
193
- // 1. Directly-matched nodes: tab predicate AND cwd scope AND query.
313
+ const { collapsed, tab, query, cwdScope, residentsOnly, sort = 'tree' } = opts;
314
+ // 1. Directly-matched nodes: tab predicate AND cwd scope AND lifecycle AND query.
194
315
  const matched = new Set();
195
316
  for (const [id, node] of tree.nodes) {
196
- if (tabPredicate(tab, node.row) && cwdMatch(cwdScope, node.row) && queryMatch(query, node.row)) {
317
+ if (tabPredicate(tab, node.row) &&
318
+ cwdMatch(cwdScope, node.row) &&
319
+ lifecycleMatch(residentsOnly, node.row) &&
320
+ queryMatch(query, node.row)) {
197
321
  matched.add(id);
198
322
  }
199
323
  }
@@ -1,19 +1,10 @@
1
1
  import type { Tab, Tree, VisibleRow, SortMode } from './model.js';
2
+ import { detectColorCaps, type ColorCaps } from '../../tui/draw.js';
3
+ export { detectColorCaps };
4
+ export type { ColorCaps };
2
5
  export declare const PREVIEW_BODY = 5;
3
6
  export declare const PREVIEW_HEIGHT: number;
4
7
  export declare function headerHeight(search: boolean): number;
5
- export interface ColorCaps {
6
- /** Any hue (fg/bg color) allowed. */
7
- color: boolean;
8
- /** 256-color bg allowed — drives the subtle cursor-row background. */
9
- color256: boolean;
10
- }
11
- /** Detect color capability. Honors `NO_COLOR` and `TERM=dumb`, and only emits
12
- * hue when stdout is a TTY. `color256` additionally requires a 256/truecolor
13
- * terminal (for the cursor-row background; otherwise we fall back to reverse). */
14
- export declare function detectColorCaps(stream?: {
15
- isTTY?: boolean;
16
- }, env?: NodeJS.ProcessEnv): ColorCaps;
17
8
  export interface RenderState {
18
9
  tree: Tree;
19
10
  visible: VisibleRow[];
@@ -29,6 +20,9 @@ export interface RenderState {
29
20
  sort: SortMode;
30
21
  /** Whether the bottom preview panel is drawn. */
31
22
  preview: boolean;
23
+ /** Lifecycle filter: when true, `terminal` (one-shot worker) nodes are hidden
24
+ * — surfaced as a status-line cue. */
25
+ residentsOnly: boolean;
32
26
  }
33
27
  /**
34
28
  * Render the whole frame. Returns a single string that, written as-is, repaints
@@ -11,7 +11,12 @@
11
11
  // Every hue (fg / bg color) is gated on `caps.color`; structural SGR (bold/dim/
12
12
  // reverse) is allowed always. See detectColorCaps() for the gate, and the
13
13
  // canvas-browse color spec for the rationale + palette.
14
- import { TABS, matchIndices } from './model.js';
14
+ import { TABS, matchIndices, promptText, previewSnippet } from './model.js';
15
+ // Span/color primitives live in core/tui/draw.ts (one copy, shared with the
16
+ // `crtr view` host). Re-export the color caps so browse's importers + tests keep
17
+ // resolving them from this module.
18
+ import { clip, assemble, detectColorCaps } from '../../tui/draw.js';
19
+ export { detectColorCaps };
15
20
  // Fixed chrome heights, shared with app.ts so its viewport math never drifts
16
21
  // from what renderFrame actually draws.
17
22
  // header = title + tab bar + status line + separator (+ search input when searching)
@@ -52,16 +57,6 @@ const STATUS_COLOR = {
52
57
  dead: FG_RED,
53
58
  canceled: FG_GRAY,
54
59
  };
55
- /** Detect color capability. Honors `NO_COLOR` and `TERM=dumb`, and only emits
56
- * hue when stdout is a TTY. `color256` additionally requires a 256/truecolor
57
- * terminal (for the cursor-row background; otherwise we fall back to reverse). */
58
- export function detectColorCaps(stream = process.stdout, env = process.env) {
59
- const term = env['TERM'] ?? '';
60
- const color = stream.isTTY === true && !env['NO_COLOR'] && term !== 'dumb';
61
- const colorTerm = env['COLORTERM'] ?? '';
62
- const color256 = color && (/256|direct/i.test(term) || /truecolor|24bit/i.test(colorTerm));
63
- return { color, color256 };
64
- }
65
60
  function fmtCtx(tokens) {
66
61
  if (tokens <= 0)
67
62
  return '0k';
@@ -76,12 +71,6 @@ function ctxColorCode(tokens) {
76
71
  return FG_YELLOW;
77
72
  return undefined;
78
73
  }
79
- /** Truncate to `max` visible cols (plain text, no ANSI). */
80
- function clip(text, max) {
81
- if (max <= 0)
82
- return '';
83
- return text.length <= max ? text : text.slice(0, Math.max(0, max - 1)) + '…';
84
- }
85
74
  /** Compact relative age, e.g. `45s` `12m` `3h` `5d` `2w` `4mo`. Empty on a bad
86
75
  * timestamp. Drives the per-row recency cue + the preview meta line. */
87
76
  function relAge(created, now) {
@@ -110,74 +99,13 @@ function baseDir(cwd) {
110
99
  const parts = cwd.replace(/\/+$/, '').split('/');
111
100
  return parts[parts.length - 1] || cwd;
112
101
  }
113
- /** Greedy word-wrap to `width` cols, capped at `maxLines` (last line clipped). */
114
- function wrap(text, width, maxLines) {
115
- if (width <= 0 || maxLines <= 0)
116
- return [];
117
- const words = text.replace(/\s+/g, ' ').trim().split(' ');
118
- const out = [];
119
- let cur = '';
120
- for (const w of words) {
121
- const next = cur === '' ? w : `${cur} ${w}`;
122
- if (next.length <= width) {
123
- cur = next;
124
- continue;
125
- }
126
- if (cur !== '')
127
- out.push(cur);
128
- cur = w;
129
- if (out.length >= maxLines)
130
- break;
131
- }
132
- if (out.length < maxLines && cur !== '')
133
- out.push(cur);
134
- return out.slice(0, maxLines).map((l) => clip(l, width));
135
- }
136
- /** Style one span. Hue is gated on `color`; bold/dim are not. After the span we
137
- * return to `lineBase` (not a bare reset) so a row-level background/dim persists
138
- * across the span instead of bleeding or being cleared. */
139
- function styleSpan(text, span, color, lineBase) {
140
- if (text === '')
141
- return '';
142
- let pre = '';
143
- if (span.dim)
144
- pre += DIM;
145
- if (span.bold)
146
- pre += BOLD;
147
- if (color && span.fg)
148
- pre += `${ESC}${span.fg}m`;
149
- if (pre === '')
150
- return text; // inherits lineBase / default
151
- return `${pre}${text}${RESET}${lineBase}`;
152
- }
153
- /** Assemble styled spans into one line clipped to `width` visible cols. When
154
- * `fill`, pad the remainder with spaces (under `lineBase`) so a cursor-row
155
- * background spans the full width. Always RESET-terminated so no color bleeds
156
- * into the next line. */
157
- function assemble(spans, width, color, lineBase, fill) {
158
- let used = 0;
159
- let body = '';
160
- for (const span of spans) {
161
- if (used >= width)
162
- break;
163
- if (span.text === '')
164
- continue;
165
- let t = span.text;
166
- const remaining = width - used;
167
- let cut = false;
168
- if (t.length > remaining) {
169
- t = t.slice(0, Math.max(0, remaining - 1)) + '…';
170
- cut = true;
171
- }
172
- body += styleSpan(t, span, color, lineBase);
173
- used += t.length;
174
- if (cut)
175
- break;
176
- }
177
- if (fill && used < width)
178
- body += ' '.repeat(width - used);
179
- return lineBase === '' ? body : `${lineBase}${body}${RESET}`;
180
- }
102
+ // ── Styled spans ──────────────────────────────────────────────────────────────
103
+ //
104
+ // A row is built from styled spans, then assembled to a width-clipped line. This
105
+ // lets each cell carry its own hue (status glyph, ctx tier, asks, match highlight)
106
+ // while clipping by VISIBLE width — ANSI bytes don't count toward the column
107
+ // budget. `fg` is hue (gated on `color`); `bold`/`dim` are structural (always).
108
+ // Span / styleSpan / assemble now live in core/tui/draw.ts (imported above).
181
109
  /** Status tallies across the whole canvas, for the right-aligned header. Memoized
182
110
  * per tree — the snapshot is immutable for a browse session, so counting is done
183
111
  * once, not O(N) on every keystroke (the "massive canvas" target). */
@@ -213,7 +141,7 @@ const EMPTY_HI = new Set();
213
141
  function nameSpans(name, query, style) {
214
142
  const hi = query === '' ? EMPTY_HI : matchIndices(query, name);
215
143
  if (hi.size === 0)
216
- return [{ text: name, dim: style.dim, bold: style.bold }];
144
+ return [{ text: name, style: { dim: style.dim, bold: style.bold } }];
217
145
  const out = [];
218
146
  let buf = '';
219
147
  let bufHi = false;
@@ -221,9 +149,9 @@ function nameSpans(name, query, style) {
221
149
  if (buf === '')
222
150
  return;
223
151
  if (bufHi)
224
- out.push({ text: buf, fg: FG_BRIGHT_CYAN, bold: true });
152
+ out.push({ text: buf, style: { fg: FG_BRIGHT_CYAN, bold: true } });
225
153
  else
226
- out.push({ text: buf, dim: style.dim, bold: style.bold });
154
+ out.push({ text: buf, style: { dim: style.dim, bold: style.bold } });
227
155
  buf = '';
228
156
  };
229
157
  for (let i = 0; i < name.length; i++) {
@@ -255,19 +183,19 @@ function rowLine(row, tree, width, isCursor, query, caps, showCwd, now) {
255
183
  const nameStyle = { dim: !isCursor && terminal, bold: isCursor };
256
184
  const spans = [
257
185
  { text: `${indent}${collapse} ` },
258
- { text: glyph, fg: STATUS_COLOR[r.status] }, // load-bearing status hue
186
+ { text: glyph, style: { fg: STATUS_COLOR[r.status] } }, // load-bearing status hue
259
187
  { text: ' ' },
260
188
  ...nameSpans(r.name, query, nameStyle),
261
- { text: ` [${r.kind}/${r.mode}]`, fg: FG_GRAY }, // recedes
262
- { text: ' ctx ', dim: true },
263
- { text: ctxStr, fg: ctxFg, dim: ctxFg === undefined }, // tiered budget cue
189
+ { text: ` [${r.kind}/${r.mode}]`, style: { fg: FG_GRAY } }, // recedes
190
+ { text: ' ctx ', style: { dim: true } },
191
+ { text: ctxStr, style: { fg: ctxFg, dim: ctxFg === undefined } }, // tiered budget cue
264
192
  ];
265
193
  if (age !== '')
266
- spans.push({ text: ` ${age}`, dim: true }); // recency cue
194
+ spans.push({ text: ` ${age}`, style: { dim: true } }); // recency cue
267
195
  if (showCwd)
268
- spans.push({ text: ` ~${baseDir(r.cwd)}`, fg: FG_GRAY }); // project cue (All dirs)
196
+ spans.push({ text: ` ~${baseDir(r.cwd)}`, style: { fg: FG_GRAY } }); // project cue (All dirs)
269
197
  if (r.asks > 0)
270
- spans.push({ text: ` ⚑${r.asks}`, fg: FG_BRIGHT_YELLOW, bold: true }); // attention
198
+ spans.push({ text: ` ⚑${r.asks}`, style: { fg: FG_BRIGHT_YELLOW, bold: true } }); // attention
271
199
  // Row base: cursor → subtle bg (256) or reverse fallback (also covers !color);
272
200
  // non-matched ancestor → whole-row dim for tree context (keep prior behavior).
273
201
  let lineBase = '';
@@ -288,15 +216,44 @@ function statusLine(state) {
288
216
  const dim = (s) => `${DIM}${s}${RESET}`;
289
217
  const scope = state.cwdScope === null ? 'all dirs' : baseDir(state.cwdScope);
290
218
  const segs = [`${dim('scope')} ${scope}`, `${dim('sort')} ${state.sort}`];
219
+ if (state.residentsOnly)
220
+ segs.push(`${dim('show')} residents`);
291
221
  if (!state.search && state.query !== '')
292
222
  segs.push(`${dim('filter')} ${state.query}`);
293
223
  return segs.join(dim(' · '));
294
224
  }
225
+ /** One snippet line → a rendered string with the query-matched columns in bold
226
+ * bright-cyan (mirrors the row name highlight). Unmatched text stays default. */
227
+ function snippetLine(ln, width, caps) {
228
+ if (ln.hi.size === 0)
229
+ return assemble([{ text: ln.text }], width, caps.color, '', false);
230
+ const spans = [];
231
+ let buf = '';
232
+ let bufHi = false;
233
+ const flush = () => {
234
+ if (buf === '')
235
+ return;
236
+ spans.push(bufHi ? { text: buf, style: { fg: FG_BRIGHT_CYAN, bold: true } } : { text: buf });
237
+ buf = '';
238
+ };
239
+ for (let i = 0; i < ln.text.length; i++) {
240
+ const h = ln.hi.has(i);
241
+ if (h !== bufHi) {
242
+ flush();
243
+ bufHi = h;
244
+ }
245
+ buf += ln.text[i];
246
+ }
247
+ flush();
248
+ return assemble(spans, width, caps.color, '', false);
249
+ }
295
250
  /** The bottom preview panel — exactly PREVIEW_HEIGHT lines: a separator, a meta
296
- * line (status · kind/mode · project · age · ctx · asks), then the selected
297
- * node's spawn prompt wrapped to PREVIEW_BODY lines. The "which one was this?"
251
+ * line (status · kind/mode · project · age · ctx · asks), then the selected node's
252
+ * prompt wrapped to PREVIEW_BODY lines. Under a live query the body is WINDOWED to
253
+ * the matching prompt (anywhere in the conversation) with the match highlighted;
254
+ * with no query it shows the spawn prompt from the start. The "which one was this?"
298
255
  * answer — paired with super-search. Always full height so viewport math holds. */
299
- function previewPanel(r, width, caps, now) {
256
+ function previewPanel(r, width, caps, now, query) {
300
257
  const out = [`${DIM}${'─'.repeat(width)}${RESET}`];
301
258
  if (r === undefined) {
302
259
  while (out.length < PREVIEW_HEIGHT)
@@ -311,10 +268,21 @@ function previewPanel(r, width, caps, now) {
311
268
  metaPieces.push(`⚑${r.asks}`);
312
269
  const metaText = clip(metaPieces.filter((p) => p !== '').join(' · '), Math.max(0, width - 2));
313
270
  out.push(caps.color ? `${glyph} ${DIM}${metaText}${RESET}` : `${glyph} ${metaText}`);
314
- const goalText = (r.goal ?? '').trim();
315
- const body = goalText === '' ? [`${DIM}(no spawn prompt)${RESET}`] : wrap(goalText, width, PREVIEW_BODY);
316
- for (let i = 0; i < PREVIEW_BODY; i++)
317
- out.push(body[i] ?? '');
271
+ // With a query, window+highlight the matching prompt from the WHOLE conversation;
272
+ // otherwise show the spawn prompt from the start.
273
+ const sourceText = query !== '' ? promptText(r) : (r.goal ?? '');
274
+ const snippet = previewSnippet(query, sourceText, width, PREVIEW_BODY);
275
+ if (snippet.length === 0) {
276
+ out.push(`${DIM}(no spawn prompt)${RESET}`);
277
+ for (let i = 1; i < PREVIEW_BODY; i++)
278
+ out.push('');
279
+ }
280
+ else {
281
+ for (let i = 0; i < PREVIEW_BODY; i++) {
282
+ const ln = snippet[i];
283
+ out.push(ln === undefined ? '' : snippetLine(ln, width, caps));
284
+ }
285
+ }
318
286
  return out;
319
287
  }
320
288
  /**
@@ -373,13 +341,13 @@ export function renderFrame(state, size, caps = { color: false, color256: false
373
341
  if (previewOn) {
374
342
  const sel = state.visible[state.cursor];
375
343
  const r = sel !== undefined ? state.tree.nodes.get(sel.id)?.row : undefined;
376
- for (const l of previewPanel(r, width, caps, now))
344
+ for (const l of previewPanel(r, width, caps, now, state.query))
377
345
  lines.push(l);
378
346
  }
379
347
  // footer.
380
348
  const footer = state.search
381
349
  ? '⏎ commit Esc cancel ⌫ delete'
382
- : '↑↓ move →/← tree ⏎ resume Tab tabs / search s sort c cwd p preview q quit';
350
+ : '↑↓ move →/← tree ⏎ resume Tab tabs / search s sort c cwd r residents p preview q quit';
383
351
  lines.push(`${DIM}${clip(footer, width)}${RESET}`);
384
352
  // Assemble: home, each line cleared to EOL, then clear below.
385
353
  const body = lines.map((l) => `${l}${ESC}K`).join('\r\n');
@@ -7,6 +7,7 @@
7
7
  import { existsSync, readFileSync, writeFileSync, renameSync, readdirSync, rmSync, } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { openDb } from './db.js';
10
+ import { isPidAlive } from './pid.js';
10
11
  import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, } from './paths.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // meta.json (durable identity — the source of truth for what PERSISTS)
@@ -19,8 +20,8 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, } from '.
19
20
  /** The identity keys meta.json persists. Listed explicitly so no runtime field
20
21
  * can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
21
22
  const IDENTITY_KEYS = [
22
- 'node_id', 'name', 'description', 'cycles', 'created', 'cwd', 'kind', 'mode',
23
- 'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'passive_default',
23
+ 'node_id', 'name', 'description', 'cycles', 'created', 'cwd', 'host_kind', 'kind', 'mode',
24
+ 'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'passive_default',
24
25
  'home_session', 'pi_session_id', 'pi_session_file', 'launch',
25
26
  ];
26
27
  /** Project any node object down to its durable-identity subset. */
@@ -60,12 +61,13 @@ function writeMeta(meta) {
60
61
  * state. A fresh insert takes the schema defaults for runtime. */
61
62
  function upsertRow(meta) {
62
63
  openDb()
63
- .prepare(`INSERT INTO nodes (node_id, name, kind, mode, lifecycle, cwd, parent, created)
64
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
64
+ .prepare(`INSERT INTO nodes (node_id, name, kind, mode, lifecycle, cwd, host_kind, parent, created)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
65
66
  ON CONFLICT(node_id) DO UPDATE SET
66
67
  name=excluded.name, kind=excluded.kind, mode=excluded.mode,
67
- lifecycle=excluded.lifecycle, cwd=excluded.cwd, parent=excluded.parent`)
68
- .run(meta.node_id, meta.name, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.parent ?? null, meta.created);
68
+ lifecycle=excluded.lifecycle, cwd=excluded.cwd, host_kind=excluded.host_kind,
69
+ parent=excluded.parent`)
70
+ .run(meta.node_id, meta.name, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.host_kind ?? null, meta.parent ?? null, meta.created);
69
71
  }
70
72
  /** Seed a node's row at BIRTH: identity columns + runtime columns taken from the
71
73
  * incoming meta (defaults: status='active', the rest null). The only writer
@@ -74,16 +76,17 @@ function upsertRow(meta) {
74
76
  function seedRow(meta) {
75
77
  openDb()
76
78
  .prepare(`INSERT INTO nodes
77
- (node_id, name, kind, mode, lifecycle, cwd, parent, created,
79
+ (node_id, name, kind, mode, lifecycle, cwd, host_kind, parent, created,
78
80
  status, intent, pi_pid, "window", tmux_session, pane)
79
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
81
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
80
82
  ON CONFLICT(node_id) DO UPDATE SET
81
83
  name=excluded.name, kind=excluded.kind, mode=excluded.mode,
82
- lifecycle=excluded.lifecycle, cwd=excluded.cwd, parent=excluded.parent,
84
+ lifecycle=excluded.lifecycle, cwd=excluded.cwd, host_kind=excluded.host_kind,
85
+ parent=excluded.parent,
83
86
  status=excluded.status, intent=excluded.intent, pi_pid=excluded.pi_pid,
84
87
  "window"=excluded."window", tmux_session=excluded.tmux_session,
85
88
  pane=excluded.pane`)
86
- .run(meta.node_id, meta.name, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.parent ?? null, meta.created, meta.status ?? 'active', meta.intent ?? null, meta.pi_pid ?? null, meta.window ?? null, meta.tmux_session ?? null, meta.pane ?? null);
89
+ .run(meta.node_id, meta.name, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.host_kind ?? null, meta.parent ?? null, meta.created, meta.status ?? 'active', meta.intent ?? null, meta.pi_pid ?? null, meta.window ?? null, meta.tmux_session ?? null, meta.pane ?? null);
87
90
  }
88
91
  function rowFrom(r) {
89
92
  return {
@@ -94,6 +97,7 @@ function rowFrom(r) {
94
97
  lifecycle: r['lifecycle'],
95
98
  status: r['status'],
96
99
  cwd: r['cwd'],
100
+ host_kind: r['host_kind'] ?? null,
97
101
  parent: r['parent'] ?? null,
98
102
  created: r['created'],
99
103
  intent: r['intent'] ?? null,
@@ -352,17 +356,6 @@ export function rebuildIndex() {
352
356
  recordSpawn(meta.node_id, prov);
353
357
  }
354
358
  }
355
- /** Is `pid` a live process? `kill(pid, 0)` sends no signal — it only probes
356
- * existence/permission. ESRCH ⇒ gone; EPERM ⇒ alive but not ours (still alive). */
357
- function pidAlive(pid) {
358
- try {
359
- process.kill(pid, 0);
360
- return true;
361
- }
362
- catch (e) {
363
- return e.code === 'EPERM';
364
- }
365
- }
366
359
  /** Retention sweep: remove TERMINAL nodes (status dead | done | canceled) whose
367
360
  * `created` is older than `ttlDays`, bounding the otherwise-unbounded growth of
368
361
  * node rows + dirs. The edges→nodes FK (`ON DELETE CASCADE`, migration v4) GCs
@@ -406,7 +399,7 @@ export function pruneNodes(opts) {
406
399
  if (r['node_id'] === selfId)
407
400
  return false;
408
401
  const pid = r['pi_pid'];
409
- return pid === null || !pidAlive(pid);
402
+ return !isPidAlive(pid);
410
403
  })
411
404
  .map((r) => ({
412
405
  node_id: r['node_id'],
@@ -208,6 +208,50 @@ CREATE TABLE IF NOT EXISTS focuses (
208
208
  );
209
209
  `);
210
210
  }
211
+ /** v7 — the `wakeups` table: durable store for scheduled wake-ups (the
212
+ * scheduled-wakeups feature). One row per armed wake. `node_id` is the TARGET
213
+ * and the node-anchored cancel anchor — it carries a `ON DELETE CASCADE` FK to
214
+ * `nodes(node_id)` so a pruned/deleted node's node-anchored wakes are reaped in
215
+ * the same transaction (mirroring the edges v4 cascade; works because
216
+ * `PRAGMA foreign_keys = ON` is set at every open). NULL `node_id` = a
217
+ * canvas-detached wake (deferred spawn / spawn-cron) that survives.
218
+ *
219
+ * `owner_id` is the ARMER, set on EVERY row, and is deliberately a PLAIN indexed
220
+ * column with NO FK (design D1/D2): an FK on `owner_id` would cascade-drop a
221
+ * crashed-then-pruned armer's detached cron, violating Invariant E. A detached
222
+ * wake is reaped only by the explicit `cancelWakesFor` DELETE, never by cascade.
223
+ *
224
+ * `fire_at` is an ISO 8601 UTC string for the NEXT occurrence, so the per-tick
225
+ * due query is a lexicographic `<=` compare. The partial unique index
226
+ * `idx_wakeups_deadline` enforces ≤1 deadline per node. Additive, forward-only. */
227
+ function appendWakeupsTable(db) {
228
+ db.exec(`
229
+ CREATE TABLE IF NOT EXISTS wakeups (
230
+ wakeup_id TEXT PRIMARY KEY,
231
+ node_id TEXT, -- TARGET + node-anchored cancel anchor; NULL = canvas-detached
232
+ owner_id TEXT NOT NULL, -- ARMER; set on EVERY row. PLAIN column, NO FK (design D1/D2)
233
+ fire_at TEXT NOT NULL, -- ISO 8601 UTC; NEXT occurrence; lexicographic <=
234
+ kind TEXT NOT NULL, -- 'bare' | 'noted' | 'deadline' | 'spawn'
235
+ recur TEXT, -- NULL = one-shot; else the pinned recur JSON
236
+ payload TEXT, -- JSON per kind (shared contracts)
237
+ created TEXT NOT NULL,
238
+ FOREIGN KEY (node_id) REFERENCES nodes(node_id) ON DELETE CASCADE
239
+ );
240
+ CREATE INDEX IF NOT EXISTS idx_wakeups_fire ON wakeups(fire_at); -- per-tick due query
241
+ CREATE INDEX IF NOT EXISTS idx_wakeups_node ON wakeups(node_id); -- by-node cancel / list
242
+ CREATE INDEX IF NOT EXISTS idx_wakeups_owner ON wakeups(owner_id); -- by-owner reap (ruling A)
243
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_wakeups_deadline ON wakeups(node_id) WHERE kind = 'deadline';
244
+ `);
245
+ }
246
+ /** v8 — additive identity column `host_kind`: which HOST launches + supervises a
247
+ * node — `'tmux'` (a pane) or `'broker'` (the headless in-process engine). It is
248
+ * durable IDENTITY mirrored from meta.json like `cwd` (NOT runtime like `pane`),
249
+ * so it survives an index rebuild via the `IDENTITY_KEYS`/`upsertRow` path.
250
+ * Defaults NULL ⇒ `'tmux'` (every existing node is tmux-hosted); no data
251
+ * backfill. Additive, forward-only. */
252
+ function addHostKindColumn(db) {
253
+ db.exec(`ALTER TABLE nodes ADD COLUMN host_kind TEXT;`);
254
+ }
211
255
  /** The ordered migration list. Index `i` is migration version `i + 1`; the db's
212
256
  * `user_version` tracks how many have been applied. Append only. */
213
257
  export const MIGRATIONS = [
@@ -217,6 +261,8 @@ export const MIGRATIONS = [
217
261
  /* v4 */ edgesForeignKeyCascade,
218
262
  /* v5 */ addPaneColumn,
219
263
  /* v6 */ addFocusesTable,
264
+ /* v7 */ appendWakeupsTable,
265
+ /* v8 */ addHostKindColumn,
220
266
  ];
221
267
  /** Bring `db` up to the latest schema version. Reads `user_version`, runs each
222
268
  * pending migration in order, and bumps `user_version` after each so the work
@@ -3,5 +3,6 @@ export * from './labels.js';
3
3
  export * from './paths.js';
4
4
  export * from './canvas.js';
5
5
  export * from './focuses.js';
6
+ export * from './wakeups.js';
6
7
  export * from './telemetry.js';
7
8
  export { openDb, closeDb } from './db.js';
@@ -5,5 +5,6 @@ export * from './labels.js';
5
5
  export * from './paths.js';
6
6
  export * from './canvas.js';
7
7
  export * from './focuses.js';
8
+ export * from './wakeups.js';
8
9
  export * from './telemetry.js';
9
10
  export { openDb, closeDb } from './db.js';
@@ -0,0 +1,4 @@
1
+ /** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
2
+ * 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
3
+ * ours — still alive. A null/undefined pid (legacy / never-booted) reads dead. */
4
+ export declare function isPidAlive(pid: number | null | undefined): boolean;