@north-light/crouter 0.3.308 → 0.3.310

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 (49) hide show
  1. package/dist/clients/attach/viewer.js +394 -394
  2. package/dist/commands/__tests__/node-message.test.js +13 -0
  3. package/dist/commands/canvas-history/grep.js +1 -1
  4. package/dist/commands/canvas-history/read.js +1 -1
  5. package/dist/commands/canvas-history/search.js +1 -1
  6. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +20 -0
  7. package/dist/commands/memory/edit.js +8 -11
  8. package/dist/commands/node-inspect-artifacts.js +2 -2
  9. package/dist/commands/push.js +1 -1
  10. package/dist/core/__tests__/canvas-inbox-watcher.test.js +3 -3
  11. package/dist/core/__tests__/human-deliver.test.js +1 -1
  12. package/dist/core/__tests__/integration/broker-wedge-supervision.test.d.ts +1 -0
  13. package/dist/core/__tests__/integration/broker-wedge-supervision.test.js +102 -0
  14. package/dist/core/__tests__/integration/refresh-stall-recycle.test.js +68 -2
  15. package/dist/core/__tests__/integration/worktree-reap.test.js +31 -0
  16. package/dist/core/__tests__/seam/inbox-reference-guidance.test.d.ts +1 -0
  17. package/dist/core/__tests__/seam/inbox-reference-guidance.test.js +70 -0
  18. package/dist/core/canvas/__tests__/session-window.test.d.ts +1 -0
  19. package/dist/core/canvas/__tests__/session-window.test.js +70 -0
  20. package/dist/core/canvas/browse/app.js +52 -27
  21. package/dist/core/canvas/browse/render.js +11 -2
  22. package/dist/core/canvas/render-source.js +63 -25
  23. package/dist/core/command.js +15 -0
  24. package/dist/core/feed/inbox.js +6 -4
  25. package/dist/core/runtime/bearings-render.js +3 -3
  26. package/dist/core/runtime/broker/inbox.js +6 -4
  27. package/dist/core/runtime/fleet.js +1 -1
  28. package/dist/core/runtime/placement.d.ts +1 -1
  29. package/dist/core/runtime/placement.js +1 -1
  30. package/dist/core/runtime/tmux-driver.d.ts +7 -0
  31. package/dist/core/runtime/tmux-driver.js +20 -0
  32. package/dist/core/substrate/on-read.d.ts +6 -4
  33. package/dist/core/substrate/on-read.js +14 -6
  34. package/dist/core/substrate/surface-match.d.ts +12 -4
  35. package/dist/core/substrate/surface-match.js +19 -10
  36. package/dist/core/worktree-mutation-async.js +1 -2
  37. package/dist/core/worktree.d.ts +0 -1
  38. package/dist/core/worktree.js +0 -113
  39. package/dist/daemon/api/handlers/canvas.js +3 -3
  40. package/dist/daemon/reconcilers/broker-supervision.d.ts +14 -2
  41. package/dist/daemon/reconcilers/broker-supervision.js +109 -57
  42. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  43. package/dist/daemon/reconcilers/storage-maintenance.js +31 -8
  44. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +10 -10
  45. package/dist/pi-extensions/canvas-doc-substrate.js +11 -5
  46. package/dist/shared/generated-context.d.ts +2 -0
  47. package/dist/shared/generated-context.js +6 -0
  48. package/package.json +1 -1
  49. package/runtime.lock.json +5 -5
@@ -253,17 +253,30 @@ export async function runBrowse(opts) {
253
253
  if (r !== undefined)
254
254
  slice.push(r);
255
255
  }
256
+ // The row this frame is FOR. Everything below awaits disk and daemon I/O, and
257
+ // renderFrame reads state.cursor live — so a keystroke landing mid-await would
258
+ // otherwise paint the newly selected row's header above the previous row's
259
+ // (or no) conversation, which is what made the panel flash empty while
260
+ // scrolling. Drop a frame whose selection has moved instead: scheduleFlush's
261
+ // drain loop guarantees the keystroke that moved it gets its own flush.
262
+ const selectedId = curRow()?.id;
263
+ const stale = () => curRow()?.id !== selectedId;
256
264
  await enrichRowsFromSource(source, slice, askCounts);
257
- const cur = curRow();
258
- if (cur !== undefined) {
259
- const r = rowOf(cur.id);
265
+ if (stale())
266
+ return;
267
+ if (selectedId !== undefined) {
268
+ const r = rowOf(selectedId);
260
269
  if (r !== undefined) {
261
270
  await loadPreviewFromSource(source, r);
271
+ if (stale())
272
+ return;
262
273
  // The chat transcript is the side panel's body, so it is read for the
263
274
  // SELECTED row only — never on the background warm pass, where one ordered
264
275
  // transcript per node would hold the whole canvas's conversations in memory.
265
276
  if (previewLayout(size.cols, previewOn).mode === 'side')
266
277
  await loadTranscriptFromSource(source, r);
278
+ if (stale())
279
+ return;
267
280
  }
268
281
  }
269
282
  const frame = renderFrame({
@@ -771,6 +784,7 @@ export async function runBrowse(opts) {
771
784
  // Search. Starting a search ranks by relevance (decision) so the best prompt/
772
785
  // name match floats to the top as you type.
773
786
  if (action === 'crtr.browse.search') {
787
+ startWarm();
774
788
  state.search = true;
775
789
  state.query = '';
776
790
  state.sort = 'relevance';
@@ -842,31 +856,42 @@ export async function runBrowse(opts) {
842
856
  }
843
857
  setupTerminal();
844
858
  await flush();
845
- // Background corpus warmer: after the instant first paint, progressively enrich
846
- // + load-preview every row (in small chunks, off the event loop) so the prompt
847
- // super-search corpus lights up shortly after the instant name/kind/id search.
848
- // Mutates row objects only; re-flushes solely while a live search is open so new
849
- // matches surface. Stops as soon as the terminal is restored (quit/resume/crash).
850
- const warmRows = [...tree.nodes.values()].map((n) => n.row);
851
- let warmIdx = 0;
852
- const WARM_CHUNK = 30;
853
- const warmChunk = async () => {
854
- if (restored)
855
- return;
856
- const end = Math.min(warmIdx + WARM_CHUNK, warmRows.length);
857
- const slice = warmRows.slice(warmIdx, end);
858
- await enrichRowsFromSource(source, slice, askCounts);
859
- await Promise.all(slice.map((r) => loadPreviewFromSource(source, r)));
860
- warmIdx = end;
861
- // A live search may match the freshly-warmed prompts → recompute + repaint.
862
- if (state.search && state.query !== '') {
863
- recompute(curRow()?.id);
864
- scheduleFlush();
865
- }
866
- if (warmIdx < warmRows.length)
867
- setImmediate(() => { void warmChunk().catch((err) => logAsyncFailure('background warm failed', err)); });
859
+ // Background corpus warmer: progressively enrich + load-preview every row (in
860
+ // small chunks, off the event loop) so the prompt super-search corpus lights up
861
+ // shortly after the instant name/kind/id search. Mutates row objects only;
862
+ // re-flushes solely while a live search is open so new matches surface. Stops as
863
+ // soon as the terminal is restored (quit/resume/crash).
864
+ //
865
+ // It runs on the FIRST search, not at boot. The pass reads a session file per
866
+ // node, and the only thing it feeds is prompt-text search — so paying for it at
867
+ // launch charged every user who merely navigates for a corpus they never query,
868
+ // and each chunk's disk read stalled the loop in front of their keystrokes.
869
+ let warmStarted = false;
870
+ const startWarm = () => {
871
+ if (warmStarted)
872
+ return;
873
+ warmStarted = true;
874
+ const warmRows = [...tree.nodes.values()].map((n) => n.row);
875
+ let warmIdx = 0;
876
+ const WARM_CHUNK = 30;
877
+ const warmChunk = async () => {
878
+ if (restored)
879
+ return;
880
+ const end = Math.min(warmIdx + WARM_CHUNK, warmRows.length);
881
+ const slice = warmRows.slice(warmIdx, end);
882
+ await enrichRowsFromSource(source, slice, askCounts);
883
+ await Promise.all(slice.map((r) => loadPreviewFromSource(source, r)));
884
+ warmIdx = end;
885
+ // A live search may match the freshly-warmed prompts → recompute + repaint.
886
+ if (state.search && state.query !== '') {
887
+ recompute(curRow()?.id);
888
+ scheduleFlush();
889
+ }
890
+ if (warmIdx < warmRows.length)
891
+ setImmediate(() => { void warmChunk().catch((err) => logAsyncFailure('background warm failed', err)); });
892
+ };
893
+ setImmediate(() => { void warmChunk().catch((err) => logAsyncFailure('background warm failed', err)); });
868
894
  };
869
- setImmediate(() => { void warmChunk().catch((err) => logAsyncFailure('background warm failed', err)); });
870
895
  await new Promise(() => {
871
896
  const onData = (data) => {
872
897
  try {
@@ -764,6 +764,15 @@ function transcriptLines(turns, query, width, height, caps) {
764
764
  }
765
765
  return blocks.reverse().flat().slice(-height);
766
766
  }
767
+ /** What an empty panel body MEANS. `(no conversation yet)` is a claim about the
768
+ * node; it is only true once the session has actually been read. Until then the
769
+ * same blank body means the read is still in flight, and printing the claim made a
770
+ * node the panel had simply not gotten to yet look like a node that had never
771
+ * spoken. */
772
+ function emptyBodyNote(r) {
773
+ const read = r.transcriptLoaded === true || r.previewLoaded === true;
774
+ return read ? `${DIM}(no conversation yet)${RESET}` : `${DIM}(loading…)${RESET}`;
775
+ }
767
776
  /** The turns the panel paints. A node that was never revived has no session, so its
768
777
  * spawn prompt and last reply stand in — the panel then still reads as a chat
769
778
  * rather than going blank, and a remote canvas (no local session file) keeps a body. */
@@ -836,7 +845,7 @@ function sidePanelBody(r, width, height, caps, now, query, showReport) {
836
845
  ? { text: `${hits} ${hits === 1 ? 'turn' : 'turns'}`, fg: FG_BRIGHT_CYAN }
837
846
  : { text: 'matched on name', fg: FG_GRAY })];
838
847
  const body = transcriptLines(turns, query, width, height - 1, caps);
839
- out.push(...(body.length === 0 ? [`${DIM}(no conversation yet)${RESET}`] : body));
848
+ out.push(...(body.length === 0 ? [emptyBodyNote(r)] : body));
840
849
  return out.slice(0, height);
841
850
  }
842
851
  const rep = r.report;
@@ -878,7 +887,7 @@ function sidePanelBody(r, width, height, caps, now, query, showReport) {
878
887
  if (chatH >= 2) {
879
888
  out.push(sectionRule('CHAT', width, caps));
880
889
  const body = transcriptLines(panelTurns(r), '', width, chatH, caps);
881
- out.push(...(body.length === 0 ? [`${DIM}(no conversation yet)${RESET}`] : body));
890
+ out.push(...(body.length === 0 ? [emptyBodyNote(r)] : body));
882
891
  }
883
892
  if (stateBlock.length > 0) {
884
893
  while (out.length < height - stateBlock.length)
@@ -12,7 +12,7 @@
12
12
  // ./status-glyph.js, ./node-recap.js, ../runtime/fault.js, ../frontmatter.js,
13
13
  // ./paths.js, ./remote-canvas-source.js, and TYPE-ONLY ./source.js + ./types.js. Do NOT import ./canvas.js, ./attention.js,
14
14
  // ./focuses.js, or ./db.js here — that is the whole point of the split.
15
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { RemoteCanvasSource } from './remote-canvas-source.js';
18
18
  import { fullName } from './labels.js';
@@ -227,6 +227,58 @@ export function readNewestReport(nodeId) {
227
227
  const text = rest.replace(/\s+/g, ' ').trim();
228
228
  return { kind, ts, heading, body: text.length > REPORT_BODY_CAP ? text.slice(0, REPORT_BODY_CAP) : text };
229
229
  }
230
+ /** Read only the two ENDS of a pi session jsonl that any preview actually needs.
231
+ *
232
+ * A session file grows without bound — this canvas holds 2 GB of them across ~1900
233
+ * nodes, the largest single file 67 MB — and every reader below wants either the
234
+ * FIRST few prompts or the LAST few turns. Slurping the whole file to find them is
235
+ * what made the browse preview panel stall the event loop for seconds at a time.
236
+ *
237
+ * A file at or under head+tail is read whole, so small sessions (nearly all of
238
+ * them) behave exactly as before. Above that, one head slice and one tail slice are
239
+ * read positionally. The line adjacent to each cut is dropped: it is a fragment of
240
+ * a record that continues outside the window, and a partial line is not parseable
241
+ * JSON. Never throws; returns null when there is no readable session file. */
242
+ const PARTS_HEAD_BYTES = 1 << 20;
243
+ const PARTS_TAIL_BYTES = 1 << 20;
244
+ const TRANSCRIPT_TAIL_BYTES = 4 << 20;
245
+ function readSessionWindow(sessionFile, headBytes, tailBytes) {
246
+ if (sessionFile === undefined || sessionFile === null || sessionFile === '')
247
+ return null;
248
+ try {
249
+ if (!existsSync(sessionFile))
250
+ return null;
251
+ const size = statSync(sessionFile).size;
252
+ if (size <= headBytes + tailBytes) {
253
+ const lines = readFileSync(sessionFile, 'utf8').split('\n');
254
+ return { head: lines, tail: lines };
255
+ }
256
+ const fd = openSync(sessionFile, 'r');
257
+ try {
258
+ const slice = (start, length) => {
259
+ if (length <= 0)
260
+ return [];
261
+ const buf = Buffer.allocUnsafe(length);
262
+ const got = readSync(fd, buf, 0, length, start);
263
+ return buf.subarray(0, got).toString('utf8').split('\n');
264
+ };
265
+ const head = slice(0, headBytes);
266
+ const tail = slice(size - tailBytes, tailBytes);
267
+ // Drop the fragment on the inside edge of each cut.
268
+ if (head.length > 0)
269
+ head.pop();
270
+ if (tail.length > 0)
271
+ tail.shift();
272
+ return { head, tail };
273
+ }
274
+ finally {
275
+ closeSync(fd);
276
+ }
277
+ }
278
+ catch {
279
+ return null;
280
+ }
281
+ }
230
282
  /** The node's session-derived preview text in ONE file read (today's two reads,
231
283
  * folded): EVERY user prompt across the pi session (`prompts` — the whole
232
284
  * conversation, not just the spawn prompt) AND the LAST assistant message text
@@ -239,21 +291,13 @@ export function readNewestReport(nodeId) {
239
291
  const CONVO_CAP = 8192;
240
292
  const CONVO_MSG_CAP = 2048;
241
293
  export function readSessionParts(sessionFile) {
242
- if (sessionFile === undefined || sessionFile === null || sessionFile === '')
294
+ const win = readSessionWindow(sessionFile, PARTS_HEAD_BYTES, PARTS_TAIL_BYTES);
295
+ if (win === null)
243
296
  return {};
244
- let lines;
245
- try {
246
- if (!existsSync(sessionFile))
247
- return {};
248
- lines = readFileSync(sessionFile, 'utf8').split('\n');
249
- }
250
- catch {
251
- return {};
252
- }
253
297
  // Forward pass: every user prompt, joined + capped.
254
298
  const parts = [];
255
299
  let total = 0;
256
- for (const line of lines) {
300
+ for (const line of win.head) {
257
301
  // Cheap prefilter: skip every line that isn't a user-role message before the
258
302
  // (relatively costly) JSON.parse. Pi writes compact JSON (no spaces), but
259
303
  // tolerate the spaced form too.
@@ -286,8 +330,8 @@ export function readSessionParts(sessionFile) {
286
330
  }
287
331
  // Backward pass: the newest assistant message carrying text.
288
332
  let lastAssistant;
289
- for (let i = lines.length - 1; i >= 0; i--) {
290
- const line = lines[i];
333
+ for (let i = win.tail.length - 1; i >= 0; i--) {
334
+ const line = win.tail[i];
291
335
  if (line === '' || (line.indexOf('"role":"assistant"') === -1 && line.indexOf('"role": "assistant"') === -1))
292
336
  continue;
293
337
  let rec;
@@ -319,19 +363,13 @@ const TRANSCRIPT_MSG_CAP = 4096;
319
363
  * with no text (tool-only turns), so the result reads as a chat rather than a log.
320
364
  * One file read; never throws; empty for a node that was never revived. */
321
365
  export function readTranscript(sessionFile) {
322
- if (sessionFile === undefined || sessionFile === null || sessionFile === '')
323
- return [];
324
- let lines;
325
- try {
326
- if (!existsSync(sessionFile))
327
- return [];
328
- lines = readFileSync(sessionFile, 'utf8').split('\n');
329
- }
330
- catch {
366
+ // Only the last TRANSCRIPT_TURNS survive, so this is a pure tail read: the head
367
+ // window is 0 and the tail is sized to hold far more than that many turns.
368
+ const win = readSessionWindow(sessionFile, 0, TRANSCRIPT_TAIL_BYTES);
369
+ if (win === null)
331
370
  return [];
332
- }
333
371
  const turns = [];
334
- for (const line of lines) {
372
+ for (const line of win.tail) {
335
373
  // Same cheap prefilter as readSessionParts: skip every line that cannot be a
336
374
  // user or assistant message before paying for JSON.parse.
337
375
  if (line === '')
@@ -460,6 +460,14 @@ export async function parseArgv(params, tokens, options) {
460
460
  continue;
461
461
  }
462
462
  if (positionalValues.length > 0) {
463
+ // Defer this rejection until flags are parsed: a leaf-specific recovery
464
+ // may depend on a target flag that follows the positional body.
465
+ if (stdinParam !== undefined && positionalParams.length === 0) {
466
+ positionalValues.push(token);
467
+ positionalWasSupplied = true;
468
+ i++;
469
+ continue;
470
+ }
463
471
  // A leaf that also declares a `stdin` param treats its stdin value as
464
472
  // write-only/sensitive by convention (`profile env set`'s value, etc.).
465
473
  // An unexpected extra positional is the likely secret-as-argv mistake, so
@@ -484,6 +492,13 @@ export async function parseArgv(params, tokens, options) {
484
492
  // genuinely piped, non-empty stdin is ambiguous and errors loudly.
485
493
  let stdinWasSupplied = false;
486
494
  if (stdinParam !== undefined) {
495
+ if (positionalValues.length > 1 && positionalParams.length === 0) {
496
+ const conflict = stdinParam.positionalStdinConflict?.(result);
497
+ if (conflict !== undefined) {
498
+ throw parseArgvError('bad_invocation', conflict.message, undefined, conflict.field, conflict.next);
499
+ }
500
+ throw parseArgvError('bad_invocation', `unexpected extra positional argument (value withheld: this leaf reads "${stdinParam.name}" from stdin, never argv)`, undefined, undefined, `Pipe it on stdin instead of passing it as a positional argument.`);
501
+ }
487
502
  if (positionalValues.length > 0 && positionalParams.length === 0 && stdinParam.allowPositional !== false) {
488
503
  const positionalValue = positionalValues[0];
489
504
  if (positionalValue === '-') {
@@ -18,7 +18,7 @@ import { resolveRef } from '../canvas/history.js';
18
18
  import { emitEvent } from '../events/emit.js';
19
19
  import { errorClassFromError } from '../events/errors.js';
20
20
  import { generateOperationId, operationIdContext, validateOperationId } from '../events/operation-id.js';
21
- import { formatInboxCard } from '../../shared/generated-context.js';
21
+ import { formatInboxCard, formatInboxRefInstruction } from '../../shared/generated-context.js';
22
22
  import { renderBirthAnnouncementBody } from '../../shared/birth-announcement.js';
23
23
  const INBOX_ENTRY_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
24
24
  function validateInboxEntryId(value) {
@@ -387,12 +387,14 @@ function entryBody(e) {
387
387
  return birth;
388
388
  const report = inlineReport(e);
389
389
  if (report !== null)
390
- return report.trimEnd();
390
+ return `${report.trimEnd()}\n\n${formatInboxRefInstruction(e.ref)}`;
391
391
  const body = inlineBody(e);
392
392
  if (body === '')
393
- return e.label;
393
+ return e.ref === undefined ? e.label : `${e.label}\n\n${formatInboxRefInstruction(e.ref)}`;
394
394
  const { text, clipped } = clipBody(body);
395
- return clipped && e.ref === undefined ? `${text}\n… (body clipped)` : text;
395
+ if (e.ref !== undefined)
396
+ return `${text}\n\n${formatInboxRefInstruction(e.ref)}`;
397
+ return clipped ? `${text}\n… (body clipped)` : text;
396
398
  }
397
399
  function cardEntry(e) {
398
400
  return {
@@ -151,13 +151,13 @@ export function buildProjectContextBlock(cwd) {
151
151
  * subsection of <crtr-bearings>. */
152
152
  export function contextDirNote(nodeId) {
153
153
  return (`Your context directory is \`${contextDir(nodeId)}\` (also in the env as \`$CRTR_CONTEXT_DIR\`) — ` +
154
- 'durable scratch space on disk, and the one place the other nodes on the canvas can read from. ' +
154
+ 'durable scratch space on disk where you write documents to share with other nodes on the canvas. ' +
155
155
  'Put documents here that you want to share by reference instead of re-explaining them in a ' +
156
156
  'prompt: specs, designs, findings, notes worth pointing a sibling, child, or parent at. It is a ' +
157
157
  'shared document store, not a task tracker. **Address it by absolute path** — write artifacts as ' +
158
158
  '`$CRTR_CONTEXT_DIR/<name>.md` (or the full path above). Your working dir is the project, NOT ' +
159
- 'this directory, so a bare relative `context/...` lands in the repo, not here — and other nodes ' +
160
- 'can only read what is actually here. When you report a file, report its absolute path.');
159
+ 'this directory, so a bare relative `context/...` lands in the repo, not here. When you report a ' +
160
+ 'file, report its absolute path.');
161
161
  }
162
162
  /** The profile-purview note — rendered ONLY when the profile resolves to a
163
163
  * manifest that names at least one project dir. Phrased as PURVIEW, not a cwd
@@ -2,7 +2,7 @@
2
2
  // broker only reads its own files and uses daemon-projected report senders.
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { inboxPath, nodeDir } from '../../canvas/paths.js';
5
- import { formatInboxCard } from '../../../shared/generated-context.js';
5
+ import { formatInboxCard, formatInboxRefInstruction } from '../../../shared/generated-context.js';
6
6
  import { renderBirthAnnouncementBody } from '../../../shared/birth-announcement.js';
7
7
  const ENTRY_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
8
8
  const BODY_MAX_LINES = 12;
@@ -146,12 +146,14 @@ function entryBody(entry, reportNodes) {
146
146
  return birth;
147
147
  const report = inlineReport(entry, reportNodes);
148
148
  if (report !== null)
149
- return report.trimEnd();
149
+ return `${report.trimEnd()}\n\n${formatInboxRefInstruction(entry.ref)}`;
150
150
  const body = inlineBody(entry);
151
151
  if (body === '')
152
- return entry.label;
152
+ return entry.ref === undefined ? entry.label : `${entry.label}\n\n${formatInboxRefInstruction(entry.ref)}`;
153
153
  const { text, clipped } = clipBody(body);
154
- return clipped && entry.ref === undefined ? `${text}\n… (body clipped)` : text;
154
+ if (entry.ref !== undefined)
155
+ return `${text}\n\n${formatInboxRefInstruction(entry.ref)}`;
156
+ return clipped ? `${text}\n… (body clipped)` : text;
155
157
  }
156
158
  function cardEntry(entry, reportNodes) {
157
159
  return {
@@ -31,7 +31,7 @@ export function brokerCapReached(cap, live) {
31
31
  return new InputError({
32
32
  error: 'broker_cap_reached',
33
33
  message: `no broker slot is free — ${live} live brokers at the cap of ${cap}.`,
34
- next: `Finish or close a live node (\`crtr canvas render\` shows them), or raise the cap with CRTR_MAX_LIVE_BROKERS, then retry.`,
34
+ next: `Finish or close a live node (\`crtr canvas dashboard\` shows them), or raise the cap with CRTR_MAX_LIVE_BROKERS, then retry.`,
35
35
  });
36
36
  }
37
37
  let bound;
@@ -1,6 +1,6 @@
1
1
  import { type FocusRow } from '../canvas/index.js';
2
2
  export type { FocusRow };
3
- export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, paneOfNode, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, displayPopup, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux-driver.js';
3
+ export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, paneOfNode, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, displayPopup, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, listLivePanesAsync, } from './tmux-driver.js';
4
4
  export type { RespawnPaneOpts } from './tmux-driver.js';
5
5
  export { installTmuxBindings } from './tmux-bindings.js';
6
6
  export type { TmuxInstalledPair, TmuxInstallDiagnostic, TmuxInstallResult } from './tmux-bindings.js';
@@ -47,7 +47,7 @@ import { isPidAlive } from '../canvas/pid.js';
47
47
  // `piCommand`, `paneCurrentPath`, and `shellQuote` remain available to command
48
48
  // modules that need to build or inspect tmux pane commands without importing
49
49
  // tmux-driver.ts directly.
50
- export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, paneOfNode, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, displayPopup, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux-driver.js';
50
+ export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, paneOfNode, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, displayPopup, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, listLivePanesAsync, } from './tmux-driver.js';
51
51
  export { installTmuxBindings } from './tmux-bindings.js';
52
52
  // Viewer registry reads — COMPOSE over the canvas focuses table (§2.3/§4).
53
53
  //
@@ -158,6 +158,13 @@ export declare function paneRunning(pane: string): boolean;
158
158
  * liveness sweeps (e.g. the daemon's stale-focus GC) don't pay a per-pane
159
159
  * display-message each. */
160
160
  export declare function listLivePanes(): Set<string> | null;
161
+ /** The same probe without blocking the event loop, for callers running inside
162
+ * crtrd's detached work. The daemon runs this sweep on every supervision tick,
163
+ * and a blocking fork/exec there freezes the API socket — which every CLI
164
+ * command and every live broker's inbox poll shares — for its full duration.
165
+ * Identical contract to `listLivePanes`: null means the probe failed (no
166
+ * server, timeout), which a GC pass must treat as "can't tell" and skip. */
167
+ export declare function listLivePanesAsync(): Promise<Set<string> | null>;
161
168
  /** The working directory of a pane (`display-message -p -t <pane>
162
169
  * '#{pane_current_path}'`). Null if tmux fails. */
163
170
  export declare function paneCurrentPath(pane: string): string | null;
@@ -315,6 +315,26 @@ export function listLivePanes() {
315
315
  return null;
316
316
  return new Set(r.stdout.split('\n').filter((p) => p !== ''));
317
317
  }
318
+ const PANE_PROBE_TIMEOUT_MS = 2000;
319
+ /** The same probe without blocking the event loop, for callers running inside
320
+ * crtrd's detached work. The daemon runs this sweep on every supervision tick,
321
+ * and a blocking fork/exec there freezes the API socket — which every CLI
322
+ * command and every live broker's inbox poll shares — for its full duration.
323
+ * Identical contract to `listLivePanes`: null means the probe failed (no
324
+ * server, timeout), which a GC pass must treat as "can't tell" and skip. */
325
+ export async function listLivePanesAsync() {
326
+ const stdout = await new Promise((resolve) => {
327
+ try {
328
+ execFile('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], { encoding: 'utf8', timeout: PANE_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }, (err, out) => resolve(err ? null : out));
329
+ }
330
+ catch {
331
+ resolve(null);
332
+ }
333
+ });
334
+ if (stdout === null)
335
+ return null;
336
+ return new Set(stdout.trim().split('\n').filter((p) => p !== ''));
337
+ }
318
338
  /** The working directory of a pane (`display-message -p -t <pane>
319
339
  * '#{pane_current_path}'`). Null if tmux fails. */
320
340
  export function paneCurrentPath(pane) {
@@ -85,10 +85,12 @@ export declare function renderOnCommandDocsForSubject(subject: NodeConfigSubject
85
85
  * its matching rung or above, which is the release — the caller lets the
86
86
  * command through. */
87
87
  export declare function renderPreCommandDocsForSubject(subject: NodeConfigSubject, command: string, target?: ExposureTarget, accounting?: DeliveryAccounting): string;
88
- /** Does ANY doc in this session's corpus carry a `pre-command` entry? The
89
- * short-circuit a pre-execution caller checks first: with no such doc — the
90
- * common case — no command need ever pay for a subject lookup. */
91
- export declare function corpusHasPreCommandSurfaces(): boolean;
88
+ /** Whether this command matches a `command` surface before its node-config
89
+ * gate is evaluated. A matching gate still requires a subject lookup. */
90
+ export declare function corpusHasCommandSurfaceForCommand(command: string): boolean;
91
+ /** Whether this command matches a `pre-command` surface before its node-config
92
+ * gate is evaluated. A matching gate still requires a subject lookup. */
93
+ export declare function corpusHasPreCommandSurfaceForCommand(command: string): boolean;
92
94
  /** Forget the transcript exposure of every `pre-command` doc, re-arming their
93
95
  * holds. Compaction drops delivered guidance out of the transcript while the
94
96
  * ledger still claims it was delivered, so without this pass a doc could be
@@ -43,7 +43,7 @@
43
43
  import { sep } from 'node:path';
44
44
  import { realpathOrSelf } from '../fs-utils.js';
45
45
  import { ambientMemoryTarget, listAllMemoryDocs, loadMemoryTargetView, } from '../memory-resolver.js';
46
- import { owningRootOf } from './surface-match.js';
46
+ import { commandEntryMatches, owningRootOf, preCommandEntryMatches } from './surface-match.js';
47
47
  import { demoteDocumentTranscriptExposure, documentExposedAtOrAbove, emptyContextExposureState, exposedAtOrAbove, exposureTarget, registerDocumentExposure, registerExposure, } from './injected-store.js';
48
48
  import { planDelivery } from './plan.js';
49
49
  import { parseSubstrateDoc, previewLine, rungRank } from './schema.js';
@@ -288,11 +288,19 @@ export function renderPreCommandDocsForSubject(subject, command, target = transi
288
288
  function carriesPreCommandEntry(doc) {
289
289
  return doc.surfaces.some((entry) => entry.on === 'pre-command');
290
290
  }
291
- /** Does ANY doc in this session's corpus carry a `pre-command` entry? The
292
- * short-circuit a pre-execution caller checks first: with no such doc — the
293
- * common case — no command need ever pay for a subject lookup. */
294
- export function corpusHasPreCommandSurfaces() {
295
- return resolvedDocs().some(({ doc }) => carriesPreCommandEntry(doc));
291
+ function hasCommandSurfaceForCommand(command, event) {
292
+ const entryMatches = event === 'command' ? commandEntryMatches : preCommandEntryMatches;
293
+ return resolvedDocs().some(({ doc }) => doc.surfaces.some((entry) => entryMatches(entry, command)));
294
+ }
295
+ /** Whether this command matches a `command` surface before its node-config
296
+ * gate is evaluated. A matching gate still requires a subject lookup. */
297
+ export function corpusHasCommandSurfaceForCommand(command) {
298
+ return hasCommandSurfaceForCommand(command, 'command');
299
+ }
300
+ /** Whether this command matches a `pre-command` surface before its node-config
301
+ * gate is evaluated. A matching gate still requires a subject lookup. */
302
+ export function corpusHasPreCommandSurfaceForCommand(command) {
303
+ return hasCommandSurfaceForCommand(command, 'pre-command');
296
304
  }
297
305
  /** Forget the transcript exposure of every `pre-command` doc, re-arming their
298
306
  * holds. Compaction drops delivered guidance out of the transcript while the
@@ -18,13 +18,21 @@ export declare function matchesReadEntry(entry: SurfaceEntry, doc: Pick<Substrat
18
18
  /** Does a `memory-read` entry on a carrier anchored at `routingAnchor` fit a
19
19
  * resolved read of the doc known by its exact canonical `name`? */
20
20
  export declare function matchesMemoryReadEntry(entry: SurfaceEntry, routingAnchor: string, subject: NodeConfigSubject | null, name: string): boolean;
21
+ /** Does a `command` entry's parsed command pattern fit, without evaluating its
22
+ * node-config gate? Callers use this only to determine whether resolving a
23
+ * subject could be necessary; delivery must still use `matchesCommandEntry`. */
24
+ export declare function commandEntryMatches(entry: SurfaceEntry, command: string): boolean;
21
25
  /** Does a `command` entry fit the executed command string? */
22
26
  export declare function matchesCommandEntry(entry: SurfaceEntry, subject: NodeConfigSubject | null, command: string): boolean;
27
+ /** Does a `pre-command` entry's parsed command pattern fit, without evaluating
28
+ * its node-config gate? Callers use this only to determine whether resolving a
29
+ * subject could be necessary; delivery must still use `matchesPreCommandEntry`. */
30
+ export declare function preCommandEntryMatches(entry: SurfaceEntry, command: string): boolean;
23
31
  /** Does a `pre-command` entry fit a command that is ABOUT to run? The same
24
- * glob machinery `command` uses — one matcher, one semantics — applied to the
25
- * command with its heredoc bodies stripped. Subshell parentheses, backticks,
26
- * and path-prefixed binaries stay unhandled: this is a guardrail against the
27
- * faithful-but-uninformed action, not an enforcement boundary. */
32
+ * glob machinery `command` uses — one matcher, one semantics — applied to the
33
+ * command with its heredoc bodies stripped. Subshell parentheses, backticks,
34
+ * and path-prefixed binaries stay unhandled: this is a guardrail against the
35
+ * faithful-but-uninformed action, not an enforcement boundary. */
28
36
  export declare function matchesPreCommandEntry(entry: SurfaceEntry, subject: NodeConfigSubject | null, command: string): boolean;
29
37
  /** What an event occurrence exposes for matching. A field the event does not
30
38
  * use is ignored; a field the event needs and lacks matches nothing. */
@@ -157,22 +157,31 @@ function commandGlobMatches(glob, command) {
157
157
  .filter(({ tokens }) => tokens.length > 0)
158
158
  .some(({ tokens }) => segmentMatches(pattern, tokens));
159
159
  }
160
+ /** Does a `command` entry's parsed command pattern fit, without evaluating its
161
+ * node-config gate? Callers use this only to determine whether resolving a
162
+ * subject could be necessary; delivery must still use `matchesCommandEntry`. */
163
+ export function commandEntryMatches(entry, command) {
164
+ return entry.on === 'command' && entry.match !== undefined && entry.match.some((g) => commandGlobMatches(g, command));
165
+ }
160
166
  /** Does a `command` entry fit the executed command string? */
161
167
  export function matchesCommandEntry(entry, subject, command) {
162
- if (entry.on !== 'command' || entry.match === undefined || !surfaceEntryGatePasses(entry, subject))
168
+ return commandEntryMatches(entry, command) && surfaceEntryGatePasses(entry, subject);
169
+ }
170
+ /** Does a `pre-command` entry's parsed command pattern fit, without evaluating
171
+ * its node-config gate? Callers use this only to determine whether resolving a
172
+ * subject could be necessary; delivery must still use `matchesPreCommandEntry`. */
173
+ export function preCommandEntryMatches(entry, command) {
174
+ if (entry.on !== 'pre-command' || entry.match === undefined)
163
175
  return false;
164
- return entry.match.some((g) => commandGlobMatches(g, command));
176
+ return entry.match.some((g) => commandGlobMatches(g, stripHeredocs(command)));
165
177
  }
166
178
  /** Does a `pre-command` entry fit a command that is ABOUT to run? The same
167
- * glob machinery `command` uses — one matcher, one semantics — applied to the
168
- * command with its heredoc bodies stripped. Subshell parentheses, backticks,
169
- * and path-prefixed binaries stay unhandled: this is a guardrail against the
170
- * faithful-but-uninformed action, not an enforcement boundary. */
179
+ * glob machinery `command` uses — one matcher, one semantics — applied to the
180
+ * command with its heredoc bodies stripped. Subshell parentheses, backticks,
181
+ * and path-prefixed binaries stay unhandled: this is a guardrail against the
182
+ * faithful-but-uninformed action, not an enforcement boundary. */
171
183
  export function matchesPreCommandEntry(entry, subject, command) {
172
- if (entry.on !== 'pre-command' || entry.match === undefined || !surfaceEntryGatePasses(entry, subject))
173
- return false;
174
- const stripped = stripHeredocs(command);
175
- return entry.match.some((g) => commandGlobMatches(g, stripped));
184
+ return preCommandEntryMatches(entry, command) && surfaceEntryGatePasses(entry, subject);
176
185
  }
177
186
  /** Does ONE entry of `event` fit this occurrence — constraints and the entry's
178
187
  * own gate together? */
@@ -247,9 +247,8 @@ export async function abandonManagedWorktreeAsync(nodeId, by) {
247
247
  cleanBase = base;
248
248
  }
249
249
  catch (error) {
250
- if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch' || head !== current.base_sha)
250
+ if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch')
251
251
  throw error;
252
- cleanBase = current.base_sha;
253
252
  }
254
253
  const at = new Date().toISOString();
255
254
  const status = await runGit(current.path, ['status', '--porcelain'], 'status_failed', 'Inspect the worktree manually, then retry.');
@@ -76,7 +76,6 @@ export interface AbandonManagedWorktreeResult {
76
76
  worktree_path: string;
77
77
  branch_deleted: boolean;
78
78
  }
79
- export declare function abandonManagedWorktree(nodeId: string, by: string): AbandonManagedWorktreeResult;
80
79
  /** Recovery guidance for a vanished checkout whose refs do not prove that its
81
80
  * work is delivered. This deliberately never recommends `worktree close`:
82
81
  * that command cannot operate on the absent checkout. */