@crouton-kit/crouter 0.3.65 → 0.3.67

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 (83) hide show
  1. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
  2. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +24 -12
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +41 -16
  5. package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  7. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  8. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  9. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  10. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  11. package/dist/clients/attach/attach-cmd.js +632 -632
  12. package/dist/clients/attach/canvas-panels.js +9 -3
  13. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  14. package/dist/clients/attach/graph-overlay.js +65 -11
  15. package/dist/clients/attach/input-controller.d.ts +9 -0
  16. package/dist/clients/attach/input-controller.js +47 -8
  17. package/dist/clients/attach/slash-commands.d.ts +11 -4
  18. package/dist/clients/attach/slash-commands.js +44 -9
  19. package/dist/clients/attach/transport-relay.d.ts +5 -5
  20. package/dist/clients/attach/transport-relay.js +3 -30
  21. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  22. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  23. package/dist/commands/canvas-browse.js +26 -1
  24. package/dist/commands/canvas-config.d.ts +2 -0
  25. package/dist/commands/canvas-config.js +210 -0
  26. package/dist/commands/canvas-use.d.ts +1 -0
  27. package/dist/commands/canvas-use.js +63 -0
  28. package/dist/commands/canvas.js +4 -2
  29. package/dist/commands/node-lifecycle-revive.js +9 -2
  30. package/dist/commands/node.js +41 -11
  31. package/dist/core/__tests__/on-read-crouter-home-fence.test.d.ts +1 -0
  32. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +67 -0
  33. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  34. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  35. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  36. package/dist/core/__tests__/secrets.test.js +55 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  38. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  39. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  40. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  41. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  42. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  43. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  44. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  45. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  46. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  47. package/dist/core/canvas/browse/app.d.ts +14 -0
  48. package/dist/core/canvas/browse/app.js +97 -15
  49. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  50. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  51. package/dist/core/canvas/browse/render.d.ts +4 -0
  52. package/dist/core/canvas/browse/render.js +3 -1
  53. package/dist/core/canvas/nav-model.d.ts +19 -10
  54. package/dist/core/canvas/nav-model.js +30 -12
  55. package/dist/core/canvas/pid.d.ts +71 -13
  56. package/dist/core/canvas/pid.js +73 -29
  57. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  58. package/dist/core/canvas/remote-canvas-source.js +222 -0
  59. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  60. package/dist/core/canvas/remote-event-stream.js +94 -0
  61. package/dist/core/canvas/render.d.ts +13 -1
  62. package/dist/core/canvas/render.js +56 -37
  63. package/dist/core/canvas/source.d.ts +9 -0
  64. package/dist/core/canvas/source.js +15 -0
  65. package/dist/core/config.js +4 -3
  66. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  67. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  68. package/dist/core/runtime/nodes.js +10 -0
  69. package/dist/core/runtime/revive-all.js +8 -2
  70. package/dist/core/runtime/revive.js +18 -8
  71. package/dist/core/secrets.d.ts +25 -0
  72. package/dist/core/secrets.js +55 -0
  73. package/dist/core/substrate/on-read.js +13 -2
  74. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  75. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  76. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  77. package/dist/core/view/remote-canvas-target.js +12 -7
  78. package/dist/core/view/transport-remote.d.ts +2 -0
  79. package/dist/core/view/transport-remote.js +53 -0
  80. package/dist/daemon/crtrd.js +18 -8
  81. package/dist/types.d.ts +19 -11
  82. package/dist/types.js +1 -1
  83. package/package.json +1 -1
@@ -11,9 +11,11 @@ import { execFileSync } from 'node:child_process';
11
11
  import { resolve } from 'node:path';
12
12
  import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render.js';
13
13
  import { localCanvasSource } from '../source.js';
14
+ import { RemoteCanvasSource } from '../remote-canvas-source.js';
14
15
  import { closeNode } from '../../runtime/close.js';
15
16
  import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../../tui/terminal.js';
16
17
  import { buildTree, flatten, pruneNode, TABS } from './model.js';
18
+ import { createCoalescedRebuilder } from './rebuild-coalescer.js';
17
19
  // Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
18
20
  // press restores the structural `tree` view, then relevance/recency, then back.
19
21
  const SORT_CYCLE = ['attention', 'tree', 'relevance', 'recency'];
@@ -26,8 +28,22 @@ function viewportHeight(rowsTotal, search, previewOn) {
26
28
  const previewH = previewOn ? PREVIEW_HEIGHT : 0;
27
29
  return Math.max(1, rows - headerHeight(search) - 1 /* footer */ - previewH);
28
30
  }
31
+ /** Wires a RemoteEventStream's `nodes`/`inbox` events through the coalescer
32
+ * (`rebuild-coalescer.ts`) so a burst of SSE events collapses to ONE rebuild
33
+ * round, never one N+1 remote-subprocess rebuild per event. Exported (rather
34
+ * than left inline in `runBrowse`) so the coalescer integration test can
35
+ * drive this ACTUAL wiring, not just `createCoalescedRebuilder` in isolation
36
+ * — the test asserts this function routes through the coalescer rather than
37
+ * calling `rebuild()` directly per event. */
38
+ export function wireEventStreamRebuild(eventStream, rebuild) {
39
+ const rebuilder = createCoalescedRebuilder(rebuild);
40
+ const unsubscribe = eventStream.subscribe(() => rebuilder.trigger());
41
+ eventStream.start();
42
+ return { rebuilder, unsubscribe };
43
+ }
29
44
  export async function runBrowse(opts = {}) {
30
45
  const source = opts.source ?? localCanvasSource;
46
+ const isRemote = source instanceof RemoteCanvasSource;
31
47
  // No TTY → print the static forest and exit 0 (no raw mode).
32
48
  if (!process.stdin.isTTY) {
33
49
  process.stdout.write(await renderForestFromSource(source) + '\n');
@@ -35,18 +51,41 @@ export async function runBrowse(opts = {}) {
35
51
  }
36
52
  // Snapshot the canvas. Drop kind:'human' control-plane decks — they have no pi
37
53
  // session, so `node focus` refuses them; they are never a navigation/resume
38
- // target (mirrors the node focus guard).
39
- const rows = (await dashboardRowsAllFromSource(source)).filter((r) => r.kind !== 'human');
40
- const nodeRows = await source.listNodes();
41
- const rootIds = nodeRows
42
- .filter((n) => n.parent === null && n.kind !== 'human')
43
- .map((n) => n.node_id);
44
- const childIds = new Map();
45
- await Promise.all(nodeRows.map(async (n) => {
46
- childIds.set(n.node_id, (await source.subscriptionsOf(n.node_id).catch(() => [])).map((s) => s.node_id));
47
- }));
48
- const tree = buildTree(rows, rootIds, (id) => childIds.get(id) ?? []);
49
- const totalNodes = tree.nodes.size;
54
+ // target (mirrors the node focus guard). Factored into a function so the SSE
55
+ // listener below can re-pull the tree on a `nodes`/`inbox` event instead of
56
+ // recomputing over a stale snapshot.
57
+ let rows;
58
+ let nodeRows;
59
+ let rootIds;
60
+ let childIds;
61
+ let tree;
62
+ let totalNodes;
63
+ // Remote-only: pending-ask counts per node, merged from one
64
+ // `askCountsForView` call per forest root (RemoteCanvasSource.askCountsForView
65
+ // already shells a single `canvas attention map --view <root>` per root, same
66
+ // cost class as the per-node `subscriptionsOf` fan-out this snapshot already
67
+ // does below) — fetched once per snapshot rebuild, not per flush/keystroke, so
68
+ // enrichRowsFromSource's per-row loop stays a pure sync-shaped map lookup.
69
+ let remoteAskCounts = {};
70
+ const rebuildSnapshot = async () => {
71
+ rows = (await dashboardRowsAllFromSource(source)).filter((r) => r.kind !== 'human');
72
+ nodeRows = await source.listNodes();
73
+ rootIds = nodeRows
74
+ .filter((n) => n.parent === null && n.kind !== 'human')
75
+ .map((n) => n.node_id);
76
+ const nextChildIds = new Map();
77
+ await Promise.all(nodeRows.map(async (n) => {
78
+ nextChildIds.set(n.node_id, (await source.subscriptionsOf(n.node_id).catch(() => [])).map((s) => s.node_id));
79
+ }));
80
+ childIds = nextChildIds;
81
+ tree = buildTree(rows, rootIds, (id) => childIds.get(id) ?? []);
82
+ totalNodes = tree.nodes.size;
83
+ if (isRemote) {
84
+ const maps = await Promise.all(rootIds.map((root) => source.askCountsForView(root).catch(() => ({}))));
85
+ remoteAskCounts = Object.assign({}, ...maps);
86
+ }
87
+ };
88
+ await rebuildSnapshot();
50
89
  // Default cwd scope = the dir browse was launched from (the request). The popup
51
90
  // / command passes --cwd; resolve it so it compares cleanly against stored cwds.
52
91
  // null when unknown → All dirs (the toggle's other state).
@@ -82,10 +121,43 @@ export async function runBrowse(opts = {}) {
82
121
  restoreTerminal();
83
122
  }
84
123
  catch { /* best-effort */ }
124
+ unsubscribeEventStream?.();
125
+ rebuilder?.stop();
126
+ opts.eventStream?.stop();
85
127
  };
86
128
  // Safety net: an uncaught throw in the (un-unit-tested) keystroke path must
87
129
  // never strand the tty in raw + alt-screen + hidden-cursor.
88
130
  process.once('exit', cleanup);
131
+ // Remote-only live invalidation: `canvas browse` has no local fs-watch/poll to
132
+ // replace (there is none — this is a one-shot snapshot TUI, re-rendering only
133
+ // on keystrokes). This is net-new plumbing for the remote case only: when a
134
+ // pre-built RemoteEventStream is threaded in (canvas-browse.ts, once it has
135
+ // already resolved the target's credentials), a `nodes`/`inbox` SSE event
136
+ // invalidates the remote source's cache and triggers the same recompute +
137
+ // coalesced repaint a keystroke would. The local (no eventStream) path is
138
+ // untouched.
139
+ let unsubscribeEventStream;
140
+ let rebuilder;
141
+ if (opts.eventStream !== undefined) {
142
+ if (source instanceof RemoteCanvasSource)
143
+ source.invalidate();
144
+ // `trigger()` debounces a burst into one rebuild, and single-flights it
145
+ // against an already-in-flight rebuild — see wireEventStreamRebuild above.
146
+ const wired = wireEventStreamRebuild(opts.eventStream, async () => {
147
+ try {
148
+ if (source instanceof RemoteCanvasSource)
149
+ source.invalidate();
150
+ await rebuildSnapshot();
151
+ recompute(curRow()?.id);
152
+ scheduleFlush();
153
+ }
154
+ catch (err) {
155
+ logAsyncFailure('SSE rebuild failed', err);
156
+ }
157
+ });
158
+ rebuilder = wired.rebuilder;
159
+ unsubscribeEventStream = wired.unsubscribe;
160
+ }
89
161
  const logAsyncFailure = (label, err) => {
90
162
  process.stderr.write(`[crtr browse] ${label}: ${String(err?.message ?? err)}\n`);
91
163
  };
@@ -154,7 +226,7 @@ export async function runBrowse(opts = {}) {
154
226
  if (r !== undefined)
155
227
  slice.push(r);
156
228
  }
157
- await enrichRowsFromSource(source, slice);
229
+ await enrichRowsFromSource(source, slice, remoteAskCounts);
158
230
  const cur = curRow();
159
231
  if (cur !== undefined) {
160
232
  const r = rowOf(cur.id);
@@ -166,6 +238,7 @@ export async function runBrowse(opts = {}) {
166
238
  query: state.query, search: state.search, totalNodes,
167
239
  cwdScope: state.cwdScope, sort: state.sort, preview: state.preview,
168
240
  residentsOnly: state.residentsOnly, pendingClose: state.pendingClose,
241
+ readOnly: isRemote,
169
242
  }, size, caps);
170
243
  process.stdout.write(frame);
171
244
  };
@@ -459,7 +532,10 @@ export async function runBrowse(opts = {}) {
459
532
  }
460
533
  // Close-out the selected node (+ its exclusive subtree). If anything in that
461
534
  // subtree is actively streaming, confirm first (y/n); otherwise close at once.
535
+ // No-op against a remote canvas — mutations are disabled (footer says so).
462
536
  if (input === 'x') {
537
+ if (isRemote)
538
+ return;
463
539
  if (row === undefined) {
464
540
  scheduleFlush();
465
541
  return;
@@ -510,7 +586,10 @@ export async function runBrowse(opts = {}) {
510
586
  // waiting out the 5-min auto-revive grace. Shells `node lifecycle revive <id>
511
587
  // --now` (browse goes through commands, never touching the runtime directly). 'k' is
512
588
  // vim-up here, so the kick is on SHIFT-K.
589
+ // No-op against a remote canvas — mutations are disabled (footer says so).
513
590
  if (input === 'K') {
591
+ if (isRemote)
592
+ return;
514
593
  if (row !== undefined) {
515
594
  const r = rowOf(row.id);
516
595
  if (r?.hanging != null)
@@ -519,8 +598,11 @@ export async function runBrowse(opts = {}) {
519
598
  scheduleFlush();
520
599
  return;
521
600
  }
522
- // Resume.
601
+ // Resume. No-op against a remote canvas — mutations/focus are disabled
602
+ // (footer says so).
523
603
  if (key.return) {
604
+ if (isRemote)
605
+ return;
524
606
  if (row !== undefined)
525
607
  selectAndFocus(row.id);
526
608
  return;
@@ -566,7 +648,7 @@ export async function runBrowse(opts = {}) {
566
648
  return;
567
649
  const end = Math.min(warmIdx + WARM_CHUNK, warmRows.length);
568
650
  const slice = warmRows.slice(warmIdx, end);
569
- await enrichRowsFromSource(source, slice);
651
+ await enrichRowsFromSource(source, slice, remoteAskCounts);
570
652
  await Promise.all(slice.map((r) => loadPreviewFromSource(source, r)));
571
653
  warmIdx = end;
572
654
  // A live search may match the freshly-warmed prompts → recompute + repaint.
@@ -0,0 +1,7 @@
1
+ export interface CoalescedRebuilder {
2
+ /** Called on every SSE event. Cheap — only schedules/marks work. */
3
+ trigger: () => void;
4
+ /** Cancels any pending debounce timer (does not abort an in-flight rebuild). */
5
+ stop: () => void;
6
+ }
7
+ export declare function createCoalescedRebuilder(rebuild: () => Promise<void>, debounceMs?: number): CoalescedRebuilder;
@@ -0,0 +1,61 @@
1
+ // rebuild-coalescer.ts — single-flight + debounce coalescing for a burst of
2
+ // SSE-triggered rebuilds (`canvas browse`'s remote path — see app.ts).
3
+ //
4
+ // A remote `RemoteEventStream` `nodes`/`inbox` event means "invalidate + re-pull
5
+ // the tree", which for a remote source costs one `node inspect list` plus N
6
+ // `node inspect show` round trips. A burst of events (several nodes changing at
7
+ // once) must NOT fan into one overlapping rebuild per event — it must collapse
8
+ // to one rebuild per burst. This module is the pure, TTY-free coalescing policy
9
+ // so it can be unit-tested without the raw-mode stdin loop in app.ts:
10
+ //
11
+ // - `trigger()` debounces a burst into ONE rebuild call after `debounceMs` of
12
+ // quiet.
13
+ // - if a rebuild is already in flight when the debounce fires (or another
14
+ // trigger lands mid-rebuild), it does NOT launch an overlapping rebuild —
15
+ // it marks exactly one trailing rebuild to run immediately after the
16
+ // in-flight one finishes.
17
+ //
18
+ // Net effect: N events in a burst → 1 rebuild (or, if the burst spans an
19
+ // in-flight rebuild, 2: the one already running + one trailing catch-up) —
20
+ // never N overlapping rebuilds.
21
+ export function createCoalescedRebuilder(rebuild, debounceMs = 200) {
22
+ let timer;
23
+ let inFlight = false;
24
+ let pending = false;
25
+ const run = () => {
26
+ inFlight = true;
27
+ void rebuild().finally(() => {
28
+ inFlight = false;
29
+ if (pending) {
30
+ // A trailing catch-up was armed while this rebuild ran — fire it
31
+ // immediately (no further debounce: the just-finished rebuild WAS the
32
+ // quiet period), collapsing every event that landed mid-flight into
33
+ // exactly one follow-up rebuild, not one per event.
34
+ pending = false;
35
+ run();
36
+ }
37
+ });
38
+ };
39
+ const trigger = () => {
40
+ if (inFlight) {
41
+ // Already rebuilding — mark one trailing catch-up instead of starting a
42
+ // second overlapping rebuild or re-arming the debounce timer (which
43
+ // would keep pushing the catch-up back on every further event).
44
+ pending = true;
45
+ return;
46
+ }
47
+ if (timer !== undefined)
48
+ return; // already debounced in this quiet window
49
+ timer = setTimeout(() => {
50
+ timer = undefined;
51
+ run();
52
+ }, debounceMs);
53
+ };
54
+ const stop = () => {
55
+ if (timer !== undefined) {
56
+ clearTimeout(timer);
57
+ timer = undefined;
58
+ }
59
+ };
60
+ return { trigger, stop };
61
+ }
@@ -29,6 +29,10 @@ export interface RenderState {
29
29
  * a descendant) is actively streaming. Holds the node id being confirmed; the
30
30
  * footer becomes the y/n prompt. null/undefined = no pending confirm. */
31
31
  pendingClose?: string | null;
32
+ /** Set when the source is a remote canvas: mutation/focus keys (`x`/`K`/Enter)
33
+ * are disabled, so the footer drops their hints instead of advertising a
34
+ * dead action. undefined/false = local, byte-identical footer. */
35
+ readOnly?: boolean;
32
36
  }
33
37
  /**
34
38
  * Render the whole frame. Returns a single string that, written as-is, repaints
@@ -479,7 +479,9 @@ export function renderFrame(state, size, caps = { color: false, color256: false
479
479
  const hangingHint = cursorRow?.hanging != null ? 'K revive now ' : '';
480
480
  const footer = state.search
481
481
  ? '⏎ commit Esc cancel ⌫ delete'
482
- : `${hangingHint}↑↓ move →/← tree ⏎ resume x close Tab tabs / search s sort c cwd r residents p preview q quit`;
482
+ : state.readOnly === true
483
+ ? '↑↓ move →/← tree Tab tabs / search s sort c cwd r residents p preview q quit — read-only remote canvas'
484
+ : `${hangingHint}↑↓ move →/← tree ⏎ resume x close Tab tabs / search s sort c cwd r residents p preview q quit`;
483
485
  lines.push(`${DIM}${clip(footer, width)}${RESET}`);
484
486
  }
485
487
  // Assemble: home, each line cleared to EOL, then clear below.
@@ -19,8 +19,10 @@ export declare const YELLOW = "\u001B[33m";
19
19
  export declare const CYAN = "\u001B[36m";
20
20
  export declare const GRAY = "\u001B[90m";
21
21
  /** Overlay-aware glyph for a node: hanging ⚠ outranks streaming ⟳, which
22
- * outranks the base status glyph. */
23
- export declare function nodeGlyph(node: NodeMeta | null): string;
22
+ * outranks the base status glyph. `remote` skips the local `readFault` /
23
+ * `isPidAlive` / `isBusy` reads entirely (a remote node id may collide with a
24
+ * local one) and renders the bare status glyph instead. */
25
+ export declare function nodeGlyph(node: NodeMeta | null, remote?: boolean): string;
24
26
  /** Visible width, ignoring ANSI escapes. */
25
27
  export declare function visibleWidth(s: string): number;
26
28
  /** Truncate to `max` VISIBLE columns: escape sequences are copied through
@@ -59,11 +61,14 @@ export interface Telemetry {
59
61
  export declare function readTelemetry(nodeId: string): Telemetry;
60
62
  export declare function fmtTokens(n: number): string;
61
63
  /** The context-window cell — live window fill rounded to the nearest 1k (the same
62
- * figure pi's footer shows), NOT a per-turn token delta. */
63
- export declare function tokensCell(id: string): string;
64
+ * figure pi's footer shows), NOT a per-turn token delta. `remote` skips the local
65
+ * `telemetry.json` read entirely and reports 0 (a remote node id may collide
66
+ * with a local one). */
67
+ export declare function tokensCell(id: string, remote?: boolean): string;
64
68
  /** Dimmed live "what is it doing" cue for an ACTIVE node — the last tool it ran.
65
- * Empty for non-active rows (stale once a node parks) and when none is recorded. */
66
- export declare function activityCell(id: string, node: NodeMeta | null): string;
69
+ * Empty for non-active rows (stale once a node parks) and when none is recorded.
70
+ * `remote` skips the local `telemetry.json` read entirely and reports empty. */
71
+ export declare function activityCell(id: string, node: NodeMeta | null, remote?: boolean): string;
67
72
  /** Revive-count badge (meta.cycles). Hidden on the first cycle (0) to cut noise. */
68
73
  export declare function cycleBadge(node: NodeMeta | null): string;
69
74
  /** Short on-screen label: the explicit handle when set, else the pi-generated
@@ -71,10 +76,14 @@ export declare function cycleBadge(node: NodeMeta | null): string;
71
76
  * the nav chrome shows just the first so rows stay compact. */
72
77
  export declare function navLabel(node: NodeMeta | null, id: string): string;
73
78
  export declare function shortId(id: string): string;
74
- /** Node ids currently shown in a tmux focus viewport. Built once per render. */
75
- export declare function focusedNodeIds(): Set<string>;
76
- /** True when a human is watching `id` right now (tmux focus or broker viewer). */
77
- export declare function isAttached(id: string, node: NodeMeta | null, focused: ReadonlySet<string>): boolean;
79
+ /** Node ids currently shown in a tmux focus viewport. Built once per render.
80
+ * `remote` skips the local `listFocuses()` SQLite read entirely and reports
81
+ * none focused (a remote node id may collide with a local one). */
82
+ export declare function focusedNodeIds(remote?: boolean): Set<string>;
83
+ /** True when a human is watching `id` right now (tmux focus or broker viewer).
84
+ * `remote` skips the local `attach.json` read entirely and reports false (a
85
+ * remote node id may collide with a local one). */
86
+ export declare function isAttached(id: string, node: NodeMeta | null, focused: ReadonlySet<string>, remote?: boolean): boolean;
78
87
  export declare function fetchAsksMap(rootId: string): Record<string, number>;
79
88
  /** First manager (by created) — the UP step for the ancestry spine. */
80
89
  export declare function managerOf(id: string): string | undefined;
@@ -63,11 +63,16 @@ function streamingFor(node) {
63
63
  return isBusy(node.node_id);
64
64
  }
65
65
  /** Overlay-aware glyph for a node: hanging ⚠ outranks streaming ⟳, which
66
- * outranks the base status glyph. */
67
- export function nodeGlyph(node) {
66
+ * outranks the base status glyph. `remote` skips the local `readFault` /
67
+ * `isPidAlive` / `isBusy` reads entirely (a remote node id may collide with a
68
+ * local one) and renders the bare status glyph instead. */
69
+ export function nodeGlyph(node, remote = false) {
68
70
  if (node === null)
69
71
  return '?';
70
- const v = resolveNodeVisual(node.status, { streaming: streamingFor(node), hanging: hangingFor(node) });
72
+ const v = resolveNodeVisual(node.status, {
73
+ streaming: remote ? false : streamingFor(node),
74
+ hanging: remote ? null : hangingFor(node),
75
+ });
71
76
  return `${ESC}${v.color}m${v.glyph}${RESET}`;
72
77
  }
73
78
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
@@ -205,13 +210,18 @@ export function fmtTokens(n) {
205
210
  return n < 1_000 ? `${n}` : `${Math.round(n / 1_000)}k`;
206
211
  }
207
212
  /** The context-window cell — live window fill rounded to the nearest 1k (the same
208
- * figure pi's footer shows), NOT a per-turn token delta. */
209
- export function tokensCell(id) {
210
- return fmtTokens(readTelemetry(id).context_tokens ?? 0);
213
+ * figure pi's footer shows), NOT a per-turn token delta. `remote` skips the local
214
+ * `telemetry.json` read entirely and reports 0 (a remote node id may collide
215
+ * with a local one). */
216
+ export function tokensCell(id, remote = false) {
217
+ return fmtTokens(remote ? 0 : (readTelemetry(id).context_tokens ?? 0));
211
218
  }
212
219
  /** Dimmed live "what is it doing" cue for an ACTIVE node — the last tool it ran.
213
- * Empty for non-active rows (stale once a node parks) and when none is recorded. */
214
- export function activityCell(id, node) {
220
+ * Empty for non-active rows (stale once a node parks) and when none is recorded.
221
+ * `remote` skips the local `telemetry.json` read entirely and reports empty. */
222
+ export function activityCell(id, node, remote = false) {
223
+ if (remote)
224
+ return '';
215
225
  if (node?.status !== 'active')
216
226
  return '';
217
227
  const a = (readTelemetry(id).last_activity ?? '').trim();
@@ -248,8 +258,12 @@ export function shortId(id) {
248
258
  // (src/core/runtime/broker.ts). Trusted only while the node is
249
259
  // 'active': a broker crash can leave a stale file.
250
260
  // ---------------------------------------------------------------------------
251
- /** Node ids currently shown in a tmux focus viewport. Built once per render. */
252
- export function focusedNodeIds() {
261
+ /** Node ids currently shown in a tmux focus viewport. Built once per render.
262
+ * `remote` skips the local `listFocuses()` SQLite read entirely and reports
263
+ * none focused (a remote node id may collide with a local one). */
264
+ export function focusedNodeIds(remote = false) {
265
+ if (remote)
266
+ return new Set();
253
267
  try {
254
268
  return new Set(listFocuses().map((f) => f.node_id));
255
269
  }
@@ -257,8 +271,12 @@ export function focusedNodeIds() {
257
271
  return new Set();
258
272
  }
259
273
  }
260
- /** True when a human is watching `id` right now (tmux focus or broker viewer). */
261
- export function isAttached(id, node, focused) {
274
+ /** True when a human is watching `id` right now (tmux focus or broker viewer).
275
+ * `remote` skips the local `attach.json` read entirely and reports false (a
276
+ * remote node id may collide with a local one). */
277
+ export function isAttached(id, node, focused, remote = false) {
278
+ if (remote)
279
+ return false;
262
280
  if (focused.has(id))
263
281
  return true;
264
282
  if (node?.status !== 'active')
@@ -13,19 +13,77 @@
13
13
  * out under a long-lived spawning process (see `isZombie`'s doc) never comes
14
14
  * back on its own. */
15
15
  export declare function isPidAlive(pid: number | null | undefined): boolean;
16
- /** Like `isPidAlive`, but guards a RECORDED node pid against PID REUSE using the
17
- * launch-time identity baseline (`pi_pid_identity`, captured by `recordPid`).
18
- * On a heavily-forking host — esp. Linux, where low pids recycle fast — a
19
- * broker's recorded pid can be reused by an unrelated process after the broker
20
- * dies; a bare `isPidAlive` then reports the STRANGER as "our broker alive",
21
- * wedging every daemon supervise/revive decision (the daemon never revives a
22
- * row that looks alive; `reviveNode`'s double-launch guard never relaunches).
23
- * Returns false the instant the pid's CURRENT identity is POSITIVELY known to
24
- * differ from `expectedIdentity` (the recycle case). Fail-open everywhere else
25
- * — no baseline (legacy row / capture failed at launch), `ps` probe failure, or
26
- * a pid present-but-unreadable exactly matching `captureTeardownSnapshot`'s
27
- * discipline: a MISMATCH is the ONLY thing that reads dead, never an absence of
28
- * evidence. */
16
+ /** THE single precision-aware identity compare, used EVERYWHERE two
17
+ * `composeIdentity` fingerprints are compared (this module's own
18
+ * `recordedPidLiveness`, `captureTeardownSnapshot`'s reuse check, and
19
+ * `killProcessTreePids`'s reuse check) centralized so no call site can
20
+ * drift from this rule (review Major #1, Round 2 of the pid-reuse-liveness
21
+ * fix).
22
+ *
23
+ * The hazard this closes: `composeIdentity` appends the `#<ticks>` Linux
24
+ * discriminator only when `/proc/<pid>/stat` happens to be readable at
25
+ * capture time so the SAME live process can be recorded plain `lstart` on
26
+ * one capture and `lstart#ticks` on another (a transient `/proc` read
27
+ * failure, or capturing on a platform/container without procfs). A naive
28
+ * `===` then reads that as a reuse (different string) and reports `dead` for
29
+ * a broker that never actually died — the false-DEAD direction, which can
30
+ * make `reviveNode`'s double-launch guard wrongly relaunch a SECOND broker
31
+ * onto a still-live session.
32
+ *
33
+ * Rule: split both on `#`. Bases (`lstart`) differ → definitely NOT a match
34
+ * (different process, or the same pid reused in a different second). Bases
35
+ * match and BOTH carry a `#ticks` suffix → match iff the suffixes are equal
36
+ * (this still catches genuine same-second reuse, the case `procStartTicks`
37
+ * exists for). Bases match and EITHER lacks the suffix → coarse-vs-fine,
38
+ * can't distinguish → MATCH (fail-open to same-process, matching this
39
+ * module's universal discipline: only a POSITIVE mismatch counts as reuse,
40
+ * never an absence of evidence). */
41
+ export declare function identitiesMatch(a: string, b: string): boolean;
42
+ /** Three-valued liveness verdict for a RECORDED node pid, identity-guarded
43
+ * against PID REUSE using the launch-time baseline (`pi_pid_identity`,
44
+ * captured by `recordPid`). On a heavily-forking host — esp. Linux, where
45
+ * low pids recycle fast — a broker's recorded pid can be reused by an
46
+ * unrelated process (or the `ps` probe confirming it can itself
47
+ * intermittently fail) within milliseconds of the broker dying; collapsing
48
+ * "confirmed alive", "confirmed dead/reused", and "couldn't confirm either
49
+ * way" into one boolean forces every caller to fail open to ALIVE on the
50
+ * third case — which, upstream in the daemon's grace-clock bookkeeping, is
51
+ * worse than misreporting one tick: a single such read WIPES the daemon's
52
+ * memory that the node had already been observed dead, restarting the whole
53
+ * grace window from scratch (the confirmed Round-2 crash-revive wedge — see
54
+ * `crtrd.ts`'s `handleNodeLiveness`). Exposing the third state lets callers
55
+ * decide for themselves whether "can't confirm" should act like alive or
56
+ * dead for their own direction of risk.
57
+ *
58
+ * Reuses the existing primitives — this is a classification layered on top
59
+ * of `isPidAlive` + `capturePidIdentities`, never a new `ps` parse:
60
+ * - `!isPidAlive(pid)` → `'dead'`. Load-bearing: this is what preserves
61
+ * zombie handling — `isPidAlive` already treats a gone pid AND a zombie
62
+ * (SIGKILLed detached broker awaiting reap) as dead, which is the entire
63
+ * reason a wedged zombie broker still gets revived. Do not bypass it.
64
+ * - `expectedIdentity == null` (legacy row / launch capture failed) →
65
+ * `'alive'` (preserve pre-identity behavior — no baseline to check
66
+ * against).
67
+ * - `ps` probe itself failed (`capturePidIdentities` → `null`) →
68
+ * `'indeterminate'`. THIS is the branch the Round-2 fix changes: it used
69
+ * to fail open to `true` (alive) here, which is the confirmed root cause
70
+ * of the wedge.
71
+ * - probe succeeded but has no row for this pid (alive per signal-0 a
72
+ * moment ago, gone now) → `'dead'`.
73
+ * - probe succeeded and the identity is present → `'alive'` iff
74
+ * `identitiesMatch` says so, else `'dead'` (positively reused). */
75
+ export type RecordedPidLiveness = 'alive' | 'dead' | 'indeterminate';
76
+ export declare function recordedPidLiveness(pid: number | null | undefined, expectedIdentity: string | null | undefined): RecordedPidLiveness;
77
+ /** Boolean teardown-direction adapter over `recordedPidLiveness`, kept so
78
+ * every pre-existing signalling caller stays behavior-identical: `dead` is
79
+ * the ONLY thing that reads false. `indeterminate` (can't confirm either
80
+ * way) reads true alongside `alive` — never signal a maybe-ours pid on mere
81
+ * uncertainty, matching this module's universal discipline (a POSITIVE
82
+ * mismatch is the only thing that changes behavior, never an absence of
83
+ * evidence). Callers that need to distinguish "confirmed alive" from
84
+ * "can't confirm" (e.g. a double-launch guard, where treating uncertainty as
85
+ * alive risks silently skipping a needed relaunch) must call
86
+ * `recordedPidLiveness` directly instead of this adapter. */
29
87
  export declare function isRecordedPidAlive(pid: number | null | undefined, expectedIdentity: string | null | undefined): boolean;
30
88
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
31
89
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of