@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
@@ -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>;
@@ -15,6 +15,7 @@
15
15
  import { existsSync, readFileSync, statSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { getNode, listNodes, subscriptionsOf, view } from './canvas.js';
18
+ import { RemoteCanvasSource } from './remote-canvas-source.js';
18
19
  import { fullName } from './labels.js';
19
20
  import { jobDir, contextDir } from './paths.js';
20
21
  import { countAsks, asksForNodes } from './attention.js';
@@ -174,25 +175,24 @@ export function renderForest() {
174
175
  }
175
176
  return parts.join('\n\n');
176
177
  }
177
- async function nodeLineFromSource(source, nodeId, indent, connector) {
178
+ async function nodeLineFromSource(source, nodeId, indent, connector, remote) {
178
179
  const node = await source.getNode(nodeId);
179
180
  if (node === null)
180
181
  return `${indent}${connector}? <missing meta: ${nodeId}>`;
181
- const hanging = hangingFor(node);
182
+ const hanging = remote ? null : hangingFor(node);
182
183
  const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
183
- const tel = readNodeTelemetry(nodeId);
184
- const ctx = fmtCtx(tel.tokens_in);
185
- const asks = countAsks(nodeId);
184
+ const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(nodeId).tokens_in);
185
+ const asks = remote ? 0 : countAsks(nodeId);
186
186
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
187
187
  const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
188
188
  return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
189
189
  }
190
- async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out) {
190
+ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out, remote) {
191
191
  if (visited.has(nodeId))
192
192
  return;
193
193
  visited.add(nodeId);
194
194
  const connector = isLast ? '└─ ' : '├─ ';
195
- out.push(await nodeLineFromSource(source, nodeId, indent, connector));
195
+ out.push(await nodeLineFromSource(source, nodeId, indent, connector, remote));
196
196
  const children = await source.subscriptionsOf(nodeId).catch(() => []);
197
197
  const childIndent = indent + (isLast ? ' ' : '│ ');
198
198
  for (let i = 0; i < children.length; i++) {
@@ -203,18 +203,18 @@ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out)
203
203
  out.push(`${childIndent}${cycleConnector}↺ <cycle: ${child.node_id}>`);
204
204
  continue;
205
205
  }
206
- await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out);
206
+ await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out, remote);
207
207
  }
208
208
  }
209
209
  export async function renderTreeFromSource(source, rootId) {
210
+ const remote = source instanceof RemoteCanvasSource;
210
211
  const node = await source.getNode(rootId);
211
212
  if (node === null)
212
213
  return `? <missing node: ${rootId}>`;
213
- const tel = readNodeTelemetry(rootId);
214
- const ctx = fmtCtx(tel.tokens_in);
215
- const asks = countAsks(rootId);
214
+ const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(rootId).tokens_in);
215
+ const asks = remote ? 0 : countAsks(rootId);
216
216
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
217
- const hanging = hangingFor(node);
217
+ const hanging = remote ? null : hangingFor(node);
218
218
  const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
219
219
  const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
220
220
  const out = [];
@@ -224,7 +224,7 @@ export async function renderTreeFromSource(source, rootId) {
224
224
  for (let i = 0; i < children.length; i++) {
225
225
  const child = children[i];
226
226
  const isLast = i === children.length - 1;
227
- await walkTreeFromSource(source, child.node_id, '', isLast, visited, out);
227
+ await walkTreeFromSource(source, child.node_id, '', isLast, visited, out, remote);
228
228
  }
229
229
  return out.join('\n');
230
230
  }
@@ -468,14 +468,16 @@ export function dashboardRows(rootId) {
468
468
  * a deterministic canvas-home path, so it tracks last-message recency without a meta
469
469
  * read. Falls back to `created`'s epoch when a node never ran a turn (no telemetry).
470
470
  * Never throws. */
471
- function sessionMtime(nodeId, created) {
472
- try {
473
- const st = statSync(join(jobDir(nodeId), 'telemetry.json'), { throwIfNoEntry: false });
474
- if (st !== undefined)
475
- return st.mtimeMs;
476
- }
477
- catch {
478
- /* fall through to created */
471
+ function sessionMtime(nodeId, created, remote = false) {
472
+ if (!remote) {
473
+ try {
474
+ const st = statSync(join(jobDir(nodeId), 'telemetry.json'), { throwIfNoEntry: false });
475
+ if (st !== undefined)
476
+ return st.mtimeMs;
477
+ }
478
+ catch {
479
+ /* fall through to created */
480
+ }
479
481
  }
480
482
  const t = Date.parse(created);
481
483
  return Number.isNaN(t) ? 0 : t;
@@ -500,11 +502,11 @@ function sessionMtime(nodeId, created) {
500
502
  */
501
503
  export function dashboardRowsAll() {
502
504
  const focusedNodeIds = new Set(listFocuses().map((f) => f.node_id));
503
- return listNodes().map((row) => dashboardRowFromNodeRow(row, focusedNodeIds));
505
+ return listNodes().map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, false));
504
506
  }
505
- function dashboardRowFromNodeRow(row, focusedNodeIds) {
507
+ function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
506
508
  const live = row.status === 'active' || row.status === 'idle';
507
- const hanging = activeFaultForDisplay(row, readFault(row.node_id));
509
+ const hanging = remote ? null : activeFaultForDisplay(row, readFault(row.node_id));
508
510
  return {
509
511
  node_id: row.node_id,
510
512
  name: row.name, // handle only; enrichRow upgrades to fullName (meta.description)
@@ -516,15 +518,20 @@ function dashboardRowFromNodeRow(row, focusedNodeIds) {
516
518
  cwd: row.cwd,
517
519
  created: row.created,
518
520
  lifecycle: row.lifecycle,
519
- mtimeMs: sessionMtime(row.node_id, row.created),
520
- streaming: hanging === null && live ? isStreaming(row.node_id, row.pi_pid) : false,
521
+ mtimeMs: sessionMtime(row.node_id, row.created, remote),
522
+ streaming: !remote && hanging === null && live ? isStreaming(row.node_id, row.pi_pid) : false,
521
523
  hanging,
522
- viewed: focusedNodeIds.has(row.node_id),
524
+ viewed: remote ? false : focusedNodeIds.has(row.node_id),
523
525
  };
524
526
  }
527
+ /** A remote source provides only graph structure (the 8 CanvasSource methods);
528
+ * every other per-node field below is a local disk/SQLite read and is suppressed
529
+ * to a safe default for a remote source — `listFocuses()` (SQLite) is skipped
530
+ * entirely rather than queried and discarded. */
525
531
  export async function dashboardRowsAllFromSource(source) {
526
- const focusedNodeIds = new Set(listFocuses().map((f) => f.node_id));
527
- return (await source.listNodes()).map((row) => dashboardRowFromNodeRow(row, focusedNodeIds));
532
+ const remote = source instanceof RemoteCanvasSource;
533
+ const focusedNodeIds = remote ? new Set() : new Set(listFocuses().map((f) => f.node_id));
534
+ return (await source.listNodes()).map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, remote));
528
535
  }
529
536
  // ---------------------------------------------------------------------------
530
537
  // Lazy enrichment API — fold the expensive per-node reads into rows on demand,
@@ -565,16 +572,23 @@ export function enrichRows(rows) {
565
572
  row.enriched = true;
566
573
  }
567
574
  }
568
- export async function enrichRowsFromSource(source, rows) {
575
+ /** Telemetry (ctx tokens) is a local disk read, so it is suppressed to 0 for a
576
+ * remote source. Ask counts are suppressed to 0 UNLESS the caller supplies
577
+ * `remoteAsks` — a pre-fetched `askCountsForView` map (one call per forest
578
+ * root; the caller's job, since the right root set is a browse/snapshot
579
+ * concern, not this per-row enrichment loop's). See `runBrowse`'s
580
+ * `remoteAskCounts`, fetched once per snapshot rebuild, not per flush. */
581
+ export async function enrichRowsFromSource(source, rows, remoteAsks) {
569
582
  const todo = rows.filter((r) => r.enriched !== true);
570
583
  if (todo.length === 0)
571
584
  return;
572
- const asks = asksForNodes(todo.map((r) => r.node_id));
585
+ const remote = source instanceof RemoteCanvasSource;
586
+ const asks = remote ? (remoteAsks ?? {}) : asksForNodes(todo.map((r) => r.node_id));
573
587
  await Promise.all(todo.map(async (row) => {
574
588
  const meta = await source.getNode(row.node_id);
575
589
  if (meta !== null)
576
590
  row.name = fullName(meta);
577
- row.ctx_tokens = readNodeTelemetry(row.node_id).tokens_in ?? 0;
591
+ row.ctx_tokens = remote ? 0 : (readNodeTelemetry(row.node_id).tokens_in ?? 0);
578
592
  row.asks = asks[row.node_id] ?? 0;
579
593
  row.enriched = true;
580
594
  }));
@@ -595,14 +609,19 @@ export function loadPreview(row) {
595
609
  row.previewLoaded = true;
596
610
  return row;
597
611
  }
612
+ /** goal (initial-prompt.md) and session parts are local disk reads — suppressed
613
+ * to undefined for a remote source. */
598
614
  export async function loadPreviewFromSource(source, row) {
599
615
  if (row.previewLoaded === true)
600
616
  return row;
601
- row.goal = readGoalText(row.node_id);
602
- const meta = await source.getNode(row.node_id);
603
- const { prompts, lastAssistant } = readSessionParts(meta?.pi_session_file);
604
- row.prompts = prompts;
605
- row.lastAssistant = lastAssistant;
617
+ const remote = source instanceof RemoteCanvasSource;
618
+ row.goal = remote ? undefined : readGoalText(row.node_id);
619
+ if (!remote) {
620
+ const meta = await source.getNode(row.node_id);
621
+ const { prompts, lastAssistant } = readSessionParts(meta?.pi_session_file);
622
+ row.prompts = prompts;
623
+ row.lastAssistant = lastAssistant;
624
+ }
606
625
  row.previewLoaded = true;
607
626
  return row;
608
627
  }
@@ -1,4 +1,5 @@
1
1
  import type { NodeMeta, NodeRow, NodeStatus, SubscriptionRef } from './types.js';
2
+ import type { Scope } from '../../types.js';
2
3
  export interface CanvasSource {
3
4
  getNode(nodeId: string): Promise<NodeMeta | null>;
4
5
  getRow(nodeId: string): Promise<NodeRow | null>;
@@ -24,3 +25,11 @@ export declare class LocalCanvasSource implements CanvasSource {
24
25
  hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
25
26
  }
26
27
  export declare const localCanvasSource: CanvasSource;
28
+ /** The active read source for a command: an explicit `--canvas <name>` wins,
29
+ * else the durable `crtr canvas use` selection (`ScopeState.activeCanvas`),
30
+ * else local — byte-for-byte the same `localCanvasSource` singleton so a
31
+ * caller with neither set sees zero behavior change. */
32
+ export declare function resolveCanvasSource(opts?: {
33
+ canvasName?: string;
34
+ scope?: Scope;
35
+ }): CanvasSource;
@@ -1,5 +1,8 @@
1
1
  import { getNode, getRow, listNodes, subscriptionsOf, subscribersOf, view, hasActiveLiveSubscription, } from './canvas.js';
2
2
  import { asksForNodes } from './attention.js';
3
+ import { readState } from '../config.js';
4
+ import { resolveRemoteCanvasTarget } from '../view/remote-canvas-target.js';
5
+ import { RemoteCanvasSource } from './remote-canvas-source.js';
3
6
  export class LocalCanvasSource {
4
7
  async getNode(nodeId) {
5
8
  return getNode(nodeId);
@@ -27,3 +30,15 @@ export class LocalCanvasSource {
27
30
  }
28
31
  }
29
32
  export const localCanvasSource = new LocalCanvasSource();
33
+ /** The active read source for a command: an explicit `--canvas <name>` wins,
34
+ * else the durable `crtr canvas use` selection (`ScopeState.activeCanvas`),
35
+ * else local — byte-for-byte the same `localCanvasSource` singleton so a
36
+ * caller with neither set sees zero behavior change. */
37
+ export function resolveCanvasSource(opts) {
38
+ const scope = opts?.scope ?? 'user';
39
+ const name = opts?.canvasName ?? readState(scope).activeCanvas ?? null;
40
+ if (!name)
41
+ return localCanvasSource;
42
+ const { previewEndpoint, relayToken } = resolveRemoteCanvasTarget(name, scope);
43
+ return new RemoteCanvasSource(previewEndpoint, relayToken);
44
+ }
@@ -56,6 +56,17 @@ export interface BranchDef {
56
56
  rootEntry?: RootEntry;
57
57
  /** Opt into editor slash-command exposure (see SlashSpec). */
58
58
  slash?: SlashSpec;
59
+ /** Opt this branch out of the tree model entirely: every token after this
60
+ * branch's name is forwarded VERBATIM (raw argv, not the `--json`-filtered
61
+ * tokens) to an external binary via spawn, with stdio inherited and the
62
+ * child's exit code propagated. A deliberate, documented exception for
63
+ * wrapping an external CLI whose own schema crtr cannot and must not
64
+ * duplicate (see `crtr capture`) — not a general escape hatch. A
65
+ * passthrough branch should declare no children. */
66
+ passthrough?: {
67
+ bin: string;
68
+ installHint: string;
69
+ };
59
70
  children: (LeafDef | BranchDef)[];
60
71
  }
61
72
  export interface RootDef {
@@ -81,6 +92,10 @@ export declare function defineBranch(opts: {
81
92
  help: BranchHelp;
82
93
  rootEntry?: RootEntry;
83
94
  slash?: SlashSpec;
95
+ passthrough?: {
96
+ bin: string;
97
+ installHint: string;
98
+ };
84
99
  children: (LeafDef | BranchDef)[];
85
100
  }): BranchDef;
86
101
  /** Walk the whole tree and collect every node's SlashSpec (depth-first). Used