@crouton-kit/crouter 0.3.66 → 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 (72) 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/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  4. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  5. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  7. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  8. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  9. package/dist/clients/attach/attach-cmd.js +622 -622
  10. package/dist/clients/attach/canvas-panels.js +9 -3
  11. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  12. package/dist/clients/attach/graph-overlay.js +40 -7
  13. package/dist/clients/attach/input-controller.d.ts +9 -0
  14. package/dist/clients/attach/input-controller.js +47 -8
  15. package/dist/clients/attach/slash-commands.d.ts +11 -4
  16. package/dist/clients/attach/slash-commands.js +44 -9
  17. package/dist/clients/attach/transport-relay.d.ts +5 -5
  18. package/dist/clients/attach/transport-relay.js +3 -30
  19. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  20. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  21. package/dist/commands/canvas-browse.js +26 -1
  22. package/dist/commands/canvas-config.d.ts +2 -0
  23. package/dist/commands/canvas-config.js +210 -0
  24. package/dist/commands/canvas-use.d.ts +1 -0
  25. package/dist/commands/canvas-use.js +63 -0
  26. package/dist/commands/canvas.js +4 -2
  27. package/dist/commands/node.js +41 -11
  28. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  29. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  30. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  31. package/dist/core/__tests__/secrets.test.js +55 -0
  32. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  33. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  34. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  35. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  36. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  37. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  38. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  39. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  40. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  41. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  42. package/dist/core/canvas/browse/app.d.ts +14 -0
  43. package/dist/core/canvas/browse/app.js +97 -15
  44. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  45. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  46. package/dist/core/canvas/browse/render.d.ts +4 -0
  47. package/dist/core/canvas/browse/render.js +3 -1
  48. package/dist/core/canvas/nav-model.d.ts +19 -10
  49. package/dist/core/canvas/nav-model.js +30 -12
  50. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  51. package/dist/core/canvas/remote-canvas-source.js +222 -0
  52. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  53. package/dist/core/canvas/remote-event-stream.js +94 -0
  54. package/dist/core/canvas/render.d.ts +13 -1
  55. package/dist/core/canvas/render.js +56 -37
  56. package/dist/core/canvas/source.d.ts +9 -0
  57. package/dist/core/canvas/source.js +15 -0
  58. package/dist/core/config.js +4 -3
  59. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  60. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  61. package/dist/core/runtime/nodes.js +10 -0
  62. package/dist/core/secrets.d.ts +25 -0
  63. package/dist/core/secrets.js +55 -0
  64. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  65. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  66. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  67. package/dist/core/view/remote-canvas-target.js +12 -7
  68. package/dist/core/view/transport-remote.d.ts +2 -0
  69. package/dist/core/view/transport-remote.js +53 -0
  70. package/dist/types.d.ts +19 -11
  71. package/dist/types.js +1 -1
  72. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,222 @@
1
+ // remote-canvas-source.ts — a CanvasSource backed by a remote canvas over the
2
+ // relay-token-authed `/__crtr/source` transport (see `transport-remote.ts`).
3
+ // Every method shells out to an EXISTING `crtr --json <subcommand>` in-guest —
4
+ // no new in-guest command, no remote SQLite read, no drive-plane verb. A
5
+ // short-TTL cache coalesces the fan-out a single browse/inspect frame would
6
+ // otherwise make into the same node.
7
+ import { createRemoteExec } from '../view/transport-remote.js';
8
+ const CACHE_TTL_MS = 1000;
9
+ // ---------------------------------------------------------------------------
10
+ // Remote JSON shape validation.
11
+ //
12
+ // `execJson<T>` parses stdout and casts it to T with NO structural check — a
13
+ // remote subprocess that exits 0 with a wrong shape (e.g. `{}`, `{node:null}`)
14
+ // would otherwise pass through as a "valid" T. `fetchShow`/`listNodes`/
15
+ // `askCountsForView` validate before caching/returning; on a shape mismatch
16
+ // they degrade exactly like the existing network-failure path (null/[]/{}),
17
+ // never a malformed object a caller might dereference.
18
+ // ---------------------------------------------------------------------------
19
+ function isPlainObject(x) {
20
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
21
+ }
22
+ function isValidShowResponse(x) {
23
+ if (!isPlainObject(x))
24
+ return false;
25
+ const node = x['node'];
26
+ if (!isPlainObject(node) || typeof node['node_id'] !== 'string')
27
+ return false;
28
+ if (!Array.isArray(x['reports']) || !Array.isArray(x['managers']))
29
+ return false;
30
+ const artifacts = x['artifacts'];
31
+ if (!isPlainObject(artifacts) ||
32
+ typeof artifacts['report'] !== 'number' ||
33
+ typeof artifacts['doc'] !== 'number' ||
34
+ typeof artifacts['roadmap'] !== 'number')
35
+ return false;
36
+ if (typeof x['pending_triggers'] !== 'number')
37
+ return false;
38
+ const paths = x['paths'];
39
+ if (!isPlainObject(paths) ||
40
+ typeof paths['context_dir'] !== 'string' ||
41
+ typeof paths['reports_dir'] !== 'string' ||
42
+ (paths['session_file'] !== null && typeof paths['session_file'] !== 'string'))
43
+ return false;
44
+ if (typeof x['follow_up'] !== 'string')
45
+ return false;
46
+ return true;
47
+ }
48
+ /** A `reports`/`managers` entry (`RemoteShowResponse`) — validated per-element
49
+ * because `isValidShowResponse` only checks the array wrapper. Without this,
50
+ * a malformed neighbor (`null`, `{}`) survives into `subscriptionsOf`/
51
+ * `subscribersOf`'s `.map()` and node.ts's remote `show` passthrough render,
52
+ * both of which dereference `.node_id`/`.active` unguarded. */
53
+ function isValidNeighborEntry(x) {
54
+ return (isPlainObject(x) &&
55
+ typeof x['node_id'] === 'string' &&
56
+ typeof x['name'] === 'string' &&
57
+ typeof x['kind'] === 'string' &&
58
+ typeof x['status'] === 'string' &&
59
+ typeof x['active'] === 'boolean');
60
+ }
61
+ /** A `NodeRow` element from `node inspect list`'s `nodes` array — validated
62
+ * per-element because a remote success payload can be `{nodes:[null]}` (the
63
+ * outer array check alone lets that through). Requires the string identity/
64
+ * lifecycle fields browse's tree-building code dereferences unguarded
65
+ * (`browse/app.ts`'s `rebuildSnapshot`); the remaining nullable runtime
66
+ * fields (host_kind, parent, pi_pid, ...) are left to cast through as-is. */
67
+ function isValidNodeRow(x) {
68
+ if (!isPlainObject(x))
69
+ return false;
70
+ return (typeof x['node_id'] === 'string' &&
71
+ typeof x['name'] === 'string' &&
72
+ typeof x['kind'] === 'string' &&
73
+ typeof x['mode'] === 'string' &&
74
+ typeof x['lifecycle'] === 'string' &&
75
+ typeof x['status'] === 'string' &&
76
+ typeof x['cwd'] === 'string' &&
77
+ typeof x['created'] === 'string');
78
+ }
79
+ export class RemoteCanvasSource {
80
+ previewEndpoint;
81
+ relayToken;
82
+ exec;
83
+ nodeCache = new Map();
84
+ rosterCache = new Map();
85
+ viewCache = new Map();
86
+ constructor(previewEndpoint, relayToken) {
87
+ this.previewEndpoint = previewEndpoint;
88
+ this.relayToken = relayToken;
89
+ this.exec = createRemoteExec(previewEndpoint, relayToken);
90
+ }
91
+ /** Drops every cached entry — called on a `RemoteEventStream` `'nodes'` (or
92
+ * `'inbox'`) event so the next read re-fetches fresh state. */
93
+ invalidate() {
94
+ this.nodeCache.clear();
95
+ this.rosterCache.clear();
96
+ this.viewCache.clear();
97
+ }
98
+ async execJson(args) {
99
+ let raw;
100
+ try {
101
+ raw = await this.exec('crtr', ['--json', ...args]);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ if (!raw.ok)
107
+ return null;
108
+ if (raw.exitCode !== undefined && raw.exitCode !== 0)
109
+ return null;
110
+ try {
111
+ return JSON.parse(raw.stdout.trim());
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ async fetchShow(id) {
118
+ const cached = this.nodeCache.get(id);
119
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS)
120
+ return cached.value;
121
+ const raw = await this.execJson(['node', 'inspect', 'show', id]);
122
+ // Sanitize reports/managers elements here (not just the outer array shape)
123
+ // so both consumers — subscriptionsOf/subscribersOf's `.map()` below AND
124
+ // node.ts's remote `show` passthrough render — see only well-formed
125
+ // neighbor entries, never a malformed one they'd dereference unguarded.
126
+ const res = isValidShowResponse(raw)
127
+ ? { ...raw, reports: raw.reports.filter(isValidNeighborEntry), managers: raw.managers.filter(isValidNeighborEntry) }
128
+ : null;
129
+ this.nodeCache.set(id, { value: res, at: Date.now() });
130
+ return res;
131
+ }
132
+ /** The full passthrough object `node inspect show` produces — used by
133
+ * `nodeShow`'s remote branch (`src/commands/node.ts`) to return the exact
134
+ * same shape a local read would, without re-deriving it. */
135
+ async rawShow(id) {
136
+ return this.fetchShow(id);
137
+ }
138
+ async getNode(id) {
139
+ return (await this.fetchShow(id))?.node ?? null;
140
+ }
141
+ async getRow(id) {
142
+ const n = (await this.fetchShow(id))?.node;
143
+ if (!n)
144
+ return null;
145
+ return {
146
+ node_id: n.node_id,
147
+ name: n.name,
148
+ kind: n.kind,
149
+ mode: n.mode,
150
+ lifecycle: n.lifecycle,
151
+ status: n.status,
152
+ cwd: n.cwd,
153
+ host_kind: n.host_kind ?? null,
154
+ profile_id: n.profile_id ?? null,
155
+ parent: n.parent ?? null,
156
+ created: n.created,
157
+ intent: n.intent ?? null,
158
+ pi_pid: n.pi_pid ?? null,
159
+ pi_pid_identity: n.pi_pid_identity ?? null,
160
+ window: n.window ?? null,
161
+ tmux_session: n.tmux_session ?? null,
162
+ pane: n.pane ?? null,
163
+ };
164
+ }
165
+ async listNodes(filter) {
166
+ const key = JSON.stringify(filter?.status ?? null);
167
+ const cached = this.rosterCache.get(key);
168
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS)
169
+ return cached.value;
170
+ const args = ['node', 'inspect', 'list'];
171
+ if (filter?.status) {
172
+ args.push('--status', (Array.isArray(filter.status) ? filter.status : [filter.status]).join(','));
173
+ }
174
+ const res = await this.execJson(args);
175
+ const rawNodes = isPlainObject(res) && Array.isArray(res['nodes']) ? res['nodes'] : [];
176
+ const nodes = rawNodes.filter(isValidNodeRow);
177
+ this.rosterCache.set(key, { value: nodes, at: Date.now() });
178
+ return nodes;
179
+ }
180
+ async subscriptionsOf(id) {
181
+ const reports = (await this.fetchShow(id))?.reports ?? [];
182
+ return reports.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
183
+ }
184
+ async subscribersOf(id) {
185
+ const managers = (await this.fetchShow(id))?.managers ?? [];
186
+ return managers.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
187
+ }
188
+ async view(root) {
189
+ const cached = this.viewCache.get(root);
190
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS)
191
+ return cached.value;
192
+ const seen = new Set([root]);
193
+ const out = [];
194
+ const queue = (await this.subscriptionsOf(root)).map((s) => s.node_id);
195
+ while (queue.length > 0) {
196
+ const id = queue.shift();
197
+ if (seen.has(id))
198
+ continue;
199
+ seen.add(id);
200
+ out.push(id);
201
+ for (const s of await this.subscriptionsOf(id)) {
202
+ if (!seen.has(s.node_id))
203
+ queue.push(s.node_id);
204
+ }
205
+ }
206
+ this.viewCache.set(root, { value: out, at: Date.now() });
207
+ return out;
208
+ }
209
+ async askCountsForView(root) {
210
+ const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
211
+ return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
212
+ }
213
+ async hasActiveLiveSubscription(id) {
214
+ const subs = (await this.subscriptionsOf(id)).filter((s) => s.active);
215
+ for (const s of subs) {
216
+ const row = await this.getRow(s.node_id);
217
+ if (row && (row.status === 'active' || row.status === 'idle'))
218
+ return true;
219
+ }
220
+ return false;
221
+ }
222
+ }
@@ -0,0 +1,24 @@
1
+ export type RemoteEventKind = 'nodes' | 'inbox';
2
+ export interface RemoteEventStreamOptions {
3
+ /** Initial reconnect backoff (ms). Default 1000; injectable so tests don't
4
+ * have to wait out the production backoff window. */
5
+ initialBackoffMs?: number;
6
+ /** Reconnect backoff cap (ms). Default 30_000. */
7
+ backoffCapMs?: number;
8
+ }
9
+ export declare class RemoteEventStream {
10
+ private readonly previewEndpoint;
11
+ private readonly relayToken;
12
+ private controller;
13
+ private stopped;
14
+ private readonly listeners;
15
+ private readonly initialBackoffMs;
16
+ private backoffMs;
17
+ private readonly BACKOFF_CAP_MS;
18
+ private reconnectTimer;
19
+ constructor(previewEndpoint: string, relayToken: string, opts?: RemoteEventStreamOptions);
20
+ subscribe(fn: (kind: RemoteEventKind) => void): () => void;
21
+ start(): void;
22
+ stop(): void;
23
+ private loop;
24
+ }
@@ -0,0 +1,94 @@
1
+ // remote-event-stream.ts — a Node SSE client over a remote canvas's
2
+ // `/__crtr/events` endpoint, driving cache invalidation + re-render for a
3
+ // remote-backed `canvas browse`/viewer. No new npm dependency: Node 18+
4
+ // `fetch` returns a web-standard `ReadableStream` body, read manually with
5
+ // `getReader()` + `TextDecoder` and split on the SSE `\n\n` event delimiter.
6
+ export class RemoteEventStream {
7
+ previewEndpoint;
8
+ relayToken;
9
+ controller;
10
+ stopped = false;
11
+ listeners = new Set();
12
+ initialBackoffMs;
13
+ backoffMs;
14
+ BACKOFF_CAP_MS;
15
+ reconnectTimer;
16
+ constructor(previewEndpoint, relayToken, opts = {}) {
17
+ this.previewEndpoint = previewEndpoint;
18
+ this.relayToken = relayToken;
19
+ this.initialBackoffMs = opts.initialBackoffMs ?? 1000;
20
+ this.backoffMs = this.initialBackoffMs;
21
+ this.BACKOFF_CAP_MS = opts.backoffCapMs ?? 30_000;
22
+ }
23
+ subscribe(fn) {
24
+ this.listeners.add(fn);
25
+ return () => this.listeners.delete(fn);
26
+ }
27
+ start() {
28
+ this.stopped = false;
29
+ void this.loop();
30
+ }
31
+ stop() {
32
+ this.stopped = true;
33
+ this.controller?.abort();
34
+ if (this.reconnectTimer !== undefined) {
35
+ clearTimeout(this.reconnectTimer);
36
+ this.reconnectTimer = undefined;
37
+ }
38
+ }
39
+ async loop() {
40
+ while (!this.stopped) {
41
+ this.controller = new AbortController();
42
+ try {
43
+ const res = await fetch(new URL('/__crtr/events', this.previewEndpoint), {
44
+ headers: { authorization: `Bearer ${this.relayToken}` },
45
+ signal: this.controller.signal,
46
+ });
47
+ if (!res.ok || !res.body)
48
+ throw new Error(`events ${res.status}`);
49
+ this.backoffMs = this.initialBackoffMs; // reset on a successful connect
50
+ const reader = res.body.getReader();
51
+ const decoder = new TextDecoder();
52
+ let buf = '';
53
+ while (true) {
54
+ const { done, value } = await reader.read();
55
+ if (done)
56
+ break;
57
+ buf += decoder.decode(value, { stream: true });
58
+ let idx;
59
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
60
+ const rawEvent = buf.slice(0, idx);
61
+ buf = buf.slice(idx + 2);
62
+ for (const line of rawEvent.split('\n')) {
63
+ if (!line.startsWith('data:'))
64
+ continue; // skip comments (: ping, : connected)
65
+ try {
66
+ const payload = JSON.parse(line.slice(5).trim());
67
+ if (payload.kind === 'nodes' || payload.kind === 'inbox') {
68
+ for (const l of this.listeners)
69
+ l(payload.kind);
70
+ }
71
+ // unknown kind → silently ignored (forward-compat)
72
+ }
73
+ catch {
74
+ // malformed data line — ignore
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
80
+ catch {
81
+ // connection error/abort — fall through to backoff+retry unless stopped
82
+ }
83
+ if (this.stopped)
84
+ return;
85
+ await new Promise((resolve) => {
86
+ this.reconnectTimer = setTimeout(() => {
87
+ this.reconnectTimer = undefined;
88
+ resolve();
89
+ }, this.backoffMs);
90
+ });
91
+ this.backoffMs = Math.min(this.backoffMs * 2, this.BACKOFF_CAP_MS);
92
+ }
93
+ }
94
+ }
@@ -119,6 +119,10 @@ export declare function dashboardRows(rootId: string): DashboardRow[];
119
119
  * viewport on demand. See {@link enrichRow}, {@link enrichRows}, {@link loadPreview}.
120
120
  */
121
121
  export declare function dashboardRowsAll(): DashboardRow[];
122
+ /** A remote source provides only graph structure (the 8 CanvasSource methods);
123
+ * every other per-node field below is a local disk/SQLite read and is suppressed
124
+ * to a safe default for a remote source — `listFocuses()` (SQLite) is skipped
125
+ * entirely rather than queried and discarded. */
122
126
  export declare function dashboardRowsAllFromSource(source: CanvasSource): Promise<DashboardRow[]>;
123
127
  /** Fold the cheap-boot row's deferred fields in for ONE row: the full label
124
128
  * (meta.description → fullName), ctx tokens (telemetry.json), and ⚑ asks. One
@@ -129,11 +133,19 @@ export declare function enrichRow(row: DashboardRow): DashboardRow;
129
133
  * distinct cwd's ask inbox exactly ONCE (via `asksForNodes`) instead of per row.
130
134
  * Use this for a whole viewport / the full forest; mutates each row in place. */
131
135
  export declare function enrichRows(rows: DashboardRow[]): void;
132
- export declare function enrichRowsFromSource(source: CanvasSource, rows: DashboardRow[]): Promise<void>;
136
+ /** Telemetry (ctx tokens) is a local disk read, so it is suppressed to 0 for a
137
+ * remote source. Ask counts are suppressed to 0 UNLESS the caller supplies
138
+ * `remoteAsks` — a pre-fetched `askCountsForView` map (one call per forest
139
+ * root; the caller's job, since the right root set is a browse/snapshot
140
+ * concern, not this per-row enrichment loop's). See `runBrowse`'s
141
+ * `remoteAskCounts`, fetched once per snapshot rebuild, not per flush. */
142
+ export declare function enrichRowsFromSource(source: CanvasSource, rows: DashboardRow[], remoteAsks?: Record<string, number>): Promise<void>;
133
143
  /** Load the SELECTED row's preview text: the spawn `goal` (initial-prompt.md) plus
134
144
  * the whole-conversation `prompts` and the `lastAssistant` reply — the latter two
135
145
  * folded into ONE session-file read (see {@link readSessionParts}). Mutates the row
136
146
  * in place; idempotent. Call only for the cursor row (and lazily/in-background to
137
147
  * warm the prompt super-search corpus), never on the boot path. */
138
148
  export declare function loadPreview(row: DashboardRow): DashboardRow;
149
+ /** goal (initial-prompt.md) and session parts are local disk reads — suppressed
150
+ * to undefined for a remote source. */
139
151
  export declare function loadPreviewFromSource(source: CanvasSource, row: DashboardRow): Promise<DashboardRow>;