@crouton-kit/crouter 0.3.66 → 0.3.68

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 (86) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
  3. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
  4. package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  6. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  7. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  8. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  9. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  10. package/dist/clients/attach/attach-cmd.js +624 -624
  11. package/dist/clients/attach/canvas-panels.js +9 -3
  12. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  13. package/dist/clients/attach/graph-overlay.js +40 -7
  14. package/dist/clients/attach/input-controller.d.ts +9 -0
  15. package/dist/clients/attach/input-controller.js +47 -8
  16. package/dist/clients/attach/slash-commands.d.ts +11 -4
  17. package/dist/clients/attach/slash-commands.js +44 -9
  18. package/dist/clients/attach/transport-relay.d.ts +5 -5
  19. package/dist/clients/attach/transport-relay.js +3 -30
  20. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  21. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  22. package/dist/commands/canvas-browse.js +26 -1
  23. package/dist/commands/canvas-config.d.ts +2 -0
  24. package/dist/commands/canvas-config.js +210 -0
  25. package/dist/commands/canvas-use.d.ts +1 -0
  26. package/dist/commands/canvas-use.js +63 -0
  27. package/dist/commands/canvas.js +4 -2
  28. package/dist/commands/capture.d.ts +2 -0
  29. package/dist/commands/capture.js +28 -0
  30. package/dist/commands/node.js +41 -11
  31. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  32. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  33. package/dist/core/__tests__/revive.test.js +70 -1
  34. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  35. package/dist/core/__tests__/secrets.test.js +55 -0
  36. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  38. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  39. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  40. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  41. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  42. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  43. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  44. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  45. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  46. package/dist/core/canvas/browse/app.d.ts +14 -0
  47. package/dist/core/canvas/browse/app.js +97 -15
  48. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  49. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  50. package/dist/core/canvas/browse/render.d.ts +4 -0
  51. package/dist/core/canvas/browse/render.js +3 -1
  52. package/dist/core/canvas/nav-model.d.ts +19 -10
  53. package/dist/core/canvas/nav-model.js +30 -12
  54. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  55. package/dist/core/canvas/remote-canvas-source.js +222 -0
  56. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  57. package/dist/core/canvas/remote-event-stream.js +94 -0
  58. package/dist/core/canvas/render.d.ts +13 -1
  59. package/dist/core/canvas/render.js +56 -37
  60. package/dist/core/canvas/source.d.ts +9 -0
  61. package/dist/core/canvas/source.js +15 -0
  62. package/dist/core/command.d.ts +15 -0
  63. package/dist/core/command.js +72 -0
  64. package/dist/core/config.js +4 -3
  65. package/dist/core/profiles/select.d.ts +4 -1
  66. package/dist/core/profiles/select.js +356 -81
  67. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  68. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  69. package/dist/core/runtime/nodes.js +10 -0
  70. package/dist/core/runtime/revive.js +176 -139
  71. package/dist/core/runtime/tmux.js +4 -1
  72. package/dist/core/secrets.d.ts +25 -0
  73. package/dist/core/secrets.js +55 -0
  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-local.js +12 -3
  79. package/dist/core/view/transport-remote.d.ts +2 -0
  80. package/dist/core/view/transport-remote.js +53 -0
  81. package/dist/types.d.ts +19 -11
  82. package/dist/types.js +1 -1
  83. package/dist/web-client/assets/{index-BvzxXXGU.js → index-CnF5r8ky.js} +18 -18
  84. package/dist/web-client/index.html +1 -1
  85. package/dist/web-client/sw.js +1 -1
  86. 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')
@@ -0,0 +1,62 @@
1
+ import type { CanvasSource } from './source.js';
2
+ import type { NodeMeta, NodeRow, NodeStatus, SubscriptionRef } from './types.js';
3
+ /** The exact JSON shape `crtr --json node inspect show <id>` emits (see
4
+ * `nodeShow`'s `run`, `src/commands/node.ts`) — mirrored here, not imported,
5
+ * to avoid a commands→canvas value dependency. */
6
+ export interface RemoteShowResponse {
7
+ node: NodeMeta;
8
+ reports: {
9
+ node_id: string;
10
+ name: string;
11
+ kind: string;
12
+ status: string;
13
+ active: boolean;
14
+ }[];
15
+ managers: {
16
+ node_id: string;
17
+ name: string;
18
+ kind: string;
19
+ status: string;
20
+ active: boolean;
21
+ }[];
22
+ artifacts: {
23
+ report: number;
24
+ doc: number;
25
+ roadmap: number;
26
+ };
27
+ pending_triggers: number;
28
+ paths: {
29
+ context_dir: string;
30
+ reports_dir: string;
31
+ session_file: string | null;
32
+ };
33
+ follow_up: string;
34
+ }
35
+ export declare class RemoteCanvasSource implements CanvasSource {
36
+ private readonly previewEndpoint;
37
+ private readonly relayToken;
38
+ private readonly exec;
39
+ private nodeCache;
40
+ private rosterCache;
41
+ private viewCache;
42
+ constructor(previewEndpoint: string, relayToken: string);
43
+ /** Drops every cached entry — called on a `RemoteEventStream` `'nodes'` (or
44
+ * `'inbox'`) event so the next read re-fetches fresh state. */
45
+ invalidate(): void;
46
+ private execJson;
47
+ private fetchShow;
48
+ /** The full passthrough object `node inspect show` produces — used by
49
+ * `nodeShow`'s remote branch (`src/commands/node.ts`) to return the exact
50
+ * same shape a local read would, without re-deriving it. */
51
+ rawShow(id: string): Promise<RemoteShowResponse | null>;
52
+ getNode(id: string): Promise<NodeMeta | null>;
53
+ getRow(id: string): Promise<NodeRow | null>;
54
+ listNodes(filter?: {
55
+ status?: NodeStatus | NodeStatus[];
56
+ }): Promise<NodeRow[]>;
57
+ subscriptionsOf(id: string): Promise<SubscriptionRef[]>;
58
+ subscribersOf(id: string): Promise<SubscriptionRef[]>;
59
+ view(root: string): Promise<string[]>;
60
+ askCountsForView(root: string): Promise<Record<string, number>>;
61
+ hasActiveLiveSubscription(id: string): Promise<boolean>;
62
+ }