@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 @@
1
+ export {};
@@ -0,0 +1,221 @@
1
+ // render-remote.test.ts — the safe-defaults contract for `render.ts`'s
2
+ // `*FromSource` functions when given a `RemoteCanvasSource`: every field that
3
+ // would require a local disk/SQLite read (hanging, streaming, viewed, ctx
4
+ // tokens, asks, goal, prompts, lastAssistant) must come back suppressed to
5
+ // its safe default instead of touching this process's local state. Reuses
6
+ // the mock-server harness shape from `remote-canvas-source.test.ts`.
7
+ //
8
+ // This is adversarial, not merely a clean-machine assertion: BEFORE calling
9
+ // the render functions, the test seeds real, CONFLICTING local state for the
10
+ // same node id `a` (a local focus, an active fault, telemetry, a goal file, a
11
+ // session transcript, and a pending human ask) — every historically-broken
12
+ // local read this suppression covers. If the production suppression ever
13
+ // regressed back to consulting local disk/SQLite for a remote source, the
14
+ // assertions below would observe the poisoned values instead of the safe
15
+ // defaults, so a clean-machine run could never mask the regression.
16
+ import { test, before, beforeEach, after } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { createServer } from 'node:http';
19
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { atomicWriteJson, deckPath } from '@crouton-kit/humanloop';
23
+ import { RemoteCanvasSource } from '../remote-canvas-source.js';
24
+ import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render.js';
25
+ import { createNode } from '../canvas.js';
26
+ import { closeDb } from '../db.js';
27
+ import { openFocusRow } from '../focuses.js';
28
+ import { jobDir, contextDir } from '../paths.js';
29
+ import { recordFault } from '../../runtime/fault.js';
30
+ import { interactionsRoot } from '../../artifact.js';
31
+ function startMockCrtrServer(route) {
32
+ return new Promise((resolve) => {
33
+ const server = createServer((req, res) => {
34
+ const chunks = [];
35
+ req.on('data', (c) => chunks.push(c));
36
+ req.on('end', () => {
37
+ const bodyText = Buffer.concat(chunks).toString('utf8');
38
+ const parsed = JSON.parse(bodyText);
39
+ const result = route(parsed.args);
40
+ res.writeHead(result.status ?? 200, { 'content-type': 'application/json' });
41
+ res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify(result.body), stderr: '' }));
42
+ });
43
+ });
44
+ server.listen(0, '127.0.0.1', () => {
45
+ const addr = server.address();
46
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
47
+ resolve({
48
+ url: `http://127.0.0.1:${port}`,
49
+ close: () => new Promise((res) => server.close(() => res())),
50
+ });
51
+ });
52
+ });
53
+ }
54
+ function meta(id, extra) {
55
+ return {
56
+ node_id: id,
57
+ name: id,
58
+ created: new Date().toISOString(),
59
+ cwd: '/tmp/work',
60
+ kind: 'general',
61
+ mode: 'base',
62
+ lifecycle: 'terminal',
63
+ status: 'active',
64
+ ...extra,
65
+ };
66
+ }
67
+ function row(id) {
68
+ return {
69
+ node_id: id,
70
+ name: id,
71
+ kind: 'general',
72
+ mode: 'base',
73
+ lifecycle: 'terminal',
74
+ status: 'active',
75
+ cwd: '/tmp/work',
76
+ host_kind: null,
77
+ profile_id: null,
78
+ parent: null,
79
+ created: new Date().toISOString(),
80
+ intent: null,
81
+ pi_pid: null,
82
+ pi_pid_identity: null,
83
+ window: null,
84
+ tmux_session: null,
85
+ pane: null,
86
+ };
87
+ }
88
+ let home;
89
+ let localCwd;
90
+ let sessionFile;
91
+ before(() => {
92
+ home = mkdtempSync(join(tmpdir(), 'crtr-render-remote-'));
93
+ localCwd = mkdtempSync(join(tmpdir(), 'crtr-render-remote-cwd-'));
94
+ process.env['CRTR_HOME'] = home;
95
+ });
96
+ beforeEach(() => {
97
+ closeDb();
98
+ rmSync(home, { recursive: true, force: true });
99
+ rmSync(interactionsRoot(localCwd), { recursive: true, force: true });
100
+ });
101
+ after(() => {
102
+ closeDb();
103
+ rmSync(home, { recursive: true, force: true });
104
+ rmSync(interactionsRoot(localCwd), { recursive: true, force: true });
105
+ rmSync(localCwd, { recursive: true, force: true });
106
+ delete process.env['CRTR_HOME'];
107
+ });
108
+ /** Seed conflicting local state for node id `a` — every local read
109
+ * `*FromSource`'s remote suppression is supposed to bypass. Returns the
110
+ * poisoned session-file path so the mocked `node inspect show` response can
111
+ * point `pi_session_file` at it (the vector a regression would follow). */
112
+ function seedPoisonedLocalState() {
113
+ // A local node row + a pending ask under its cwd — poisons `asksForNodes`.
114
+ createNode(meta('a', { cwd: localCwd }));
115
+ const askDir = join(interactionsRoot(localCwd), 'poison-ask');
116
+ mkdirSync(askDir, { recursive: true });
117
+ atomicWriteJson(deckPath(askDir), {
118
+ source: { nodeId: 'a' },
119
+ interactions: [{ id: 'q', kind: 'ask', title: 'poison' }],
120
+ });
121
+ // A local focus — poisons `viewed`.
122
+ openFocusRow('poison-focus', null, null, 'a');
123
+ // A local active fault — poisons `hanging`.
124
+ recordFault('a', {
125
+ link: 'viewer↔broker',
126
+ op: 'poison',
127
+ kind: 'other',
128
+ retry: { disposition: 'manual' },
129
+ message: 'POISON FAULT — local read leaked into remote render',
130
+ });
131
+ // Local telemetry — poisons `ctx_tokens`.
132
+ writeFileSync(join(jobDir('a'), 'telemetry.json'), JSON.stringify({ tokens_in: 999999 }));
133
+ // A local goal file — poisons `goal`.
134
+ writeFileSync(join(contextDir('a'), 'initial-prompt.md'), 'POISON GOAL — local read leaked into remote render');
135
+ // A local session transcript — poisons `prompts`/`lastAssistant`.
136
+ const sf = join(home, 'poison-session.jsonl');
137
+ writeFileSync(sf, [
138
+ JSON.stringify({ type: 'message', message: { role: 'user', content: 'POISON PROMPT' } }),
139
+ JSON.stringify({ type: 'message', message: { role: 'assistant', content: 'POISON ASSISTANT REPLY' } }),
140
+ ].join('\n'));
141
+ return sf;
142
+ }
143
+ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
144
+ sessionFile = seedPoisonedLocalState();
145
+ const server = await startMockCrtrServer((args) => {
146
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
147
+ return { body: { nodes: [row('a')] } };
148
+ }
149
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
150
+ return {
151
+ body: {
152
+ // pi_session_file points at the poisoned local transcript above — if
153
+ // the remote suppression regressed and read it anyway, `prompts`/
154
+ // `lastAssistant` below would come back non-empty.
155
+ node: meta(args[4], { pi_session_file: sessionFile }),
156
+ reports: [],
157
+ managers: [],
158
+ artifacts: { report: 0, doc: 0, roadmap: 0 },
159
+ pending_triggers: 0,
160
+ paths: { context_dir: '/tmp/context', reports_dir: '/tmp/reports', session_file: null },
161
+ follow_up: '',
162
+ },
163
+ };
164
+ }
165
+ return { status: 500, body: {} };
166
+ });
167
+ const source = new RemoteCanvasSource(server.url, 'tok');
168
+ const rows = await dashboardRowsAllFromSource(source);
169
+ assert.equal(rows.length, 1);
170
+ const r = rows[0];
171
+ assert.equal(r.hanging, null, 'local active fault must not leak into a remote row');
172
+ assert.equal(r.streaming, false);
173
+ assert.equal(r.viewed, false, 'local focus must not leak into a remote row');
174
+ await enrichRowsFromSource(source, rows);
175
+ assert.equal(r.ctx_tokens, 0, 'local telemetry must not leak into a remote row');
176
+ assert.equal(r.asks, 0, 'a local pending ask for the same node id must not leak into a remote row');
177
+ assert.equal(r.enriched, true);
178
+ const previewed = await loadPreviewFromSource(source, r);
179
+ assert.equal(previewed.goal, undefined, 'a local goal file must not leak into a remote preview');
180
+ assert.equal(previewed.prompts, undefined, 'a local session transcript must not leak into a remote preview');
181
+ assert.equal(previewed.lastAssistant, undefined, 'a local session transcript must not leak into a remote preview');
182
+ assert.equal(previewed.previewLoaded, true);
183
+ await server.close();
184
+ });
185
+ // The non-TTY `canvas browse` path (browse/app.ts's `if (!process.stdin.isTTY)`
186
+ // branch) calls `renderForestFromSource(source)` directly — a SEPARATE code
187
+ // path from `dashboardRowsAllFromSource`/`enrichRowsFromSource`/
188
+ // `loadPreviewFromSource` above, with its own remote suppression in
189
+ // `renderTreeFromSource`/`nodeLineFromSource` (render.ts). The poisoned-state
190
+ // test above never exercises it, so a regression that reintroduced local
191
+ // telemetry/ask/fault reads into the static forest renderer specifically would
192
+ // pass that test while still leaking poisoned local state here.
193
+ test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
194
+ const sf = seedPoisonedLocalState();
195
+ const server = await startMockCrtrServer((args) => {
196
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
197
+ return { body: { nodes: [row('a')] } };
198
+ }
199
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
200
+ return {
201
+ body: {
202
+ node: meta(args[4], { pi_session_file: sf }),
203
+ reports: [],
204
+ managers: [],
205
+ artifacts: { report: 0, doc: 0, roadmap: 0 },
206
+ pending_triggers: 0,
207
+ paths: { context_dir: '/tmp/context', reports_dir: '/tmp/reports', session_file: null },
208
+ follow_up: '',
209
+ },
210
+ };
211
+ }
212
+ return { status: 500, body: {} };
213
+ });
214
+ const source = new RemoteCanvasSource(server.url, 'tok');
215
+ const out = await renderForestFromSource(source);
216
+ assert.match(out, /ctx 0k/, 'the remote node must render with the suppressed ctx default, not a real token read');
217
+ assert.doesNotMatch(out, /⚑/, 'no ask badge — local asks must not leak into the static forest render');
218
+ assert.doesNotMatch(out, /POISON FAULT/, 'a local active fault must not leak into the static forest render');
219
+ assert.doesNotMatch(out, /POISON/, 'no poisoned local telemetry/goal/session text may appear anywhere in the output');
220
+ await server.close();
221
+ });
@@ -0,0 +1,77 @@
1
+ // source-resolve.test.ts — `resolveCanvasSource` (`../source.ts`), the
2
+ // --canvas > activeCanvas > local precedence a command's read path goes
3
+ // through. The one invariant worth pinning: the local case returns the SAME
4
+ // `localCanvasSource` singleton by identity, not a structurally-equal copy —
5
+ // callers that hold onto a source reference (e.g. `canvas browse`'s
6
+ // `instanceof RemoteCanvasSource` checks) depend on that.
7
+ import { test, before, after, beforeEach } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { resolveCanvasSource, localCanvasSource } from '../source.js';
13
+ import { RemoteCanvasSource } from '../remote-canvas-source.js';
14
+ import { updateConfig, updateState } from '../../config.js';
15
+ import { setRelayToken } from '../../secrets.js';
16
+ let home;
17
+ let realHome;
18
+ before(() => {
19
+ realHome = process.env['HOME'];
20
+ home = mkdtempSync(join(tmpdir(), 'crtr-source-resolve-'));
21
+ process.env['HOME'] = home;
22
+ });
23
+ beforeEach(() => {
24
+ rmSync(home, { recursive: true, force: true });
25
+ });
26
+ after(() => {
27
+ rmSync(home, { recursive: true, force: true });
28
+ if (realHome !== undefined)
29
+ process.env['HOME'] = realHome;
30
+ else
31
+ delete process.env['HOME'];
32
+ });
33
+ test('no --canvas and no activeCanvas resolves to the local singleton by identity', () => {
34
+ const source = resolveCanvasSource();
35
+ assert.equal(source, localCanvasSource);
36
+ });
37
+ test('an explicit canvasName wins over local, even with no activeCanvas set', () => {
38
+ updateConfig('user', (cfg) => {
39
+ cfg.remoteCanvas.targets['cloud'] = { previewEndpoint: 'https://x.preview.bl.run', relayTokenRef: 'cloud' };
40
+ });
41
+ setRelayToken('cloud', 'tok', 'user');
42
+ const source = resolveCanvasSource({ canvasName: 'cloud' });
43
+ assert.ok(source instanceof RemoteCanvasSource);
44
+ assert.notEqual(source, localCanvasSource);
45
+ });
46
+ test('the durable activeCanvas selection is honored when no explicit canvasName is passed', () => {
47
+ updateConfig('user', (cfg) => {
48
+ cfg.remoteCanvas.targets['cloud'] = { previewEndpoint: 'https://x.preview.bl.run', relayTokenRef: 'cloud' };
49
+ });
50
+ setRelayToken('cloud', 'tok', 'user');
51
+ updateState('user', (s) => {
52
+ s.activeCanvas = 'cloud';
53
+ });
54
+ const source = resolveCanvasSource();
55
+ assert.ok(source instanceof RemoteCanvasSource);
56
+ });
57
+ test('an explicit --canvas wins over a different durable activeCanvas selection', () => {
58
+ updateConfig('user', (cfg) => {
59
+ cfg.remoteCanvas.targets['cloud-a'] = { previewEndpoint: 'https://a.preview.bl.run', relayTokenRef: 'cloud-a' };
60
+ cfg.remoteCanvas.targets['cloud-b'] = { previewEndpoint: 'https://b.preview.bl.run', relayTokenRef: 'cloud-b' };
61
+ });
62
+ setRelayToken('cloud-a', 'tok-a', 'user');
63
+ setRelayToken('cloud-b', 'tok-b', 'user');
64
+ updateState('user', (s) => {
65
+ s.activeCanvas = 'cloud-a';
66
+ });
67
+ const source = resolveCanvasSource({ canvasName: 'cloud-b' });
68
+ assert.ok(source instanceof RemoteCanvasSource);
69
+ assert.equal(source.previewEndpoint, 'https://b.preview.bl.run');
70
+ });
71
+ test('activeCanvas set to null (explicit local) falls back to the local singleton', () => {
72
+ updateState('user', (s) => {
73
+ s.activeCanvas = null;
74
+ });
75
+ const source = resolveCanvasSource();
76
+ assert.equal(source, localCanvasSource);
77
+ });
@@ -0,0 +1,117 @@
1
+ // rebuild-coalescer.test.ts — proves `createCoalescedRebuilder` (`../rebuild-coalescer.ts`)
2
+ // collapses a burst of triggers into ONE rebuild call, and single-flights a
3
+ // trigger that lands while a rebuild is already in flight into exactly one
4
+ // trailing rebuild rather than a second overlapping one. This is the fix for
5
+ // the N+1-per-event amplification `canvas browse`'s remote SSE path used to
6
+ // have (see app.ts) — WITHOUT the coalescer (i.e. calling `rebuild()` directly
7
+ // on every trigger), these assertions fail: a burst of 5 triggers would count
8
+ // 5 rebuild calls, not 1.
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createCoalescedRebuilder } from '../rebuild-coalescer.js';
12
+ import { wireEventStreamRebuild } from '../app.js';
13
+ function deferred() {
14
+ let resolve;
15
+ const promise = new Promise((r) => { resolve = r; });
16
+ return { promise, resolve };
17
+ }
18
+ test('a burst of triggers within the debounce window collapses into ONE rebuild call', async () => {
19
+ let calls = 0;
20
+ const rebuilder = createCoalescedRebuilder(async () => { calls += 1; }, 30);
21
+ // Five triggers fired synchronously, well inside the debounce window.
22
+ for (let i = 0; i < 5; i++)
23
+ rebuilder.trigger();
24
+ await new Promise((r) => setTimeout(r, 100));
25
+ assert.equal(calls, 1, 'a burst of 5 triggers must produce exactly 1 rebuild, not 5');
26
+ });
27
+ test('a trigger landing while a rebuild is in flight coalesces into exactly one trailing rebuild', async () => {
28
+ let calls = 0;
29
+ const gate = deferred();
30
+ const rebuilder = createCoalescedRebuilder(async () => {
31
+ calls += 1;
32
+ if (calls === 1)
33
+ await gate.promise; // hold the first rebuild "in flight"
34
+ }, 10);
35
+ rebuilder.trigger();
36
+ await new Promise((r) => setTimeout(r, 30)); // let the debounce fire and the first rebuild start
37
+ assert.equal(calls, 1, 'the first rebuild should now be in flight');
38
+ // A burst of events lands WHILE the rebuild is running — must coalesce into
39
+ // exactly one trailing rebuild, never one overlapping rebuild per event.
40
+ rebuilder.trigger();
41
+ rebuilder.trigger();
42
+ rebuilder.trigger();
43
+ await new Promise((r) => setTimeout(r, 30));
44
+ assert.equal(calls, 1, 'events during an in-flight rebuild must not start an overlapping rebuild yet');
45
+ gate.resolve(); // let the first rebuild finish
46
+ await new Promise((r) => setTimeout(r, 30));
47
+ assert.equal(calls, 2, 'exactly one trailing rebuild must run after the in-flight one finished — not 3');
48
+ });
49
+ test('stop() cancels a pending debounce timer so no rebuild fires', async () => {
50
+ let calls = 0;
51
+ const rebuilder = createCoalescedRebuilder(async () => { calls += 1; }, 20);
52
+ rebuilder.trigger();
53
+ rebuilder.stop();
54
+ await new Promise((r) => setTimeout(r, 60));
55
+ assert.equal(calls, 0, 'stop() must cancel the debounced rebuild before it fires');
56
+ });
57
+ // Integration-shaped proof: drive the coalescer with a rebuild function that
58
+ // actually hits a remote-canvas-shaped mock server (the same shelling contract
59
+ // RemoteCanvasSource uses), and count SERVER request rounds — not just JS call
60
+ // counts — across a burst of SSE-style triggers.
61
+ test('a burst of SSE-style triggers produces ONE round of remote requests, not N', async () => {
62
+ const { createServer } = await import('node:http');
63
+ const { RemoteCanvasSource } = await import('../../remote-canvas-source.js');
64
+ let hits = 0;
65
+ const server = createServer((req, res) => {
66
+ const chunks = [];
67
+ req.on('data', (c) => chunks.push(c));
68
+ req.on('end', () => {
69
+ hits += 1;
70
+ res.writeHead(200, { 'content-type': 'application/json' });
71
+ res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
72
+ });
73
+ });
74
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
75
+ const addr = server.address();
76
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
77
+ const source = new RemoteCanvasSource(`http://127.0.0.1:${port}`, 'tok');
78
+ const rebuilder = createCoalescedRebuilder(async () => {
79
+ source.invalidate();
80
+ await source.listNodes(); // one round of remote requests, mirroring app.ts's rebuildSnapshot
81
+ }, 20);
82
+ for (let i = 0; i < 6; i++)
83
+ rebuilder.trigger();
84
+ await new Promise((r) => setTimeout(r, 80));
85
+ assert.equal(hits, 1, 'a burst of 6 SSE events must produce ONE round of remote requests, not 6');
86
+ await new Promise((r) => server.close(() => r()));
87
+ });
88
+ // Integration proof for browse/app.ts's ACTUAL wiring — not the coalescer
89
+ // helper in isolation. Every test above instantiates `createCoalescedRebuilder`
90
+ // directly, which would stay green even if `app.ts` invoked a per-event
91
+ // rebuild without ever calling the coalescer. This test instead drives
92
+ // `wireEventStreamRebuild`, the exported function `runBrowse` calls to set up
93
+ // its SSE handler, with a fake event stream shaped like the real
94
+ // `RemoteEventStream` (subscribe/start only, no network) — if
95
+ // `wireEventStreamRebuild` is ever changed to call `rebuild` directly per
96
+ // event instead of routing through the coalescer, this test goes red.
97
+ test('wireEventStreamRebuild (the exported app.ts SSE handler) routes a burst of events through the coalescer, not one rebuild per event', async () => {
98
+ let calls = 0;
99
+ const listeners = new Set();
100
+ const fakeEventStream = {
101
+ subscribe: (fn) => {
102
+ listeners.add(fn);
103
+ return () => listeners.delete(fn);
104
+ },
105
+ start: () => { },
106
+ };
107
+ const { unsubscribe } = wireEventStreamRebuild(fakeEventStream, async () => { calls += 1; });
108
+ // A burst of 6 simulated SSE events, well inside the coalescer's debounce
109
+ // window (default 200ms) — must collapse into ONE rebuild round.
110
+ for (let i = 0; i < 6; i++) {
111
+ for (const fn of listeners)
112
+ fn('nodes');
113
+ }
114
+ await new Promise((r) => setTimeout(r, 260));
115
+ assert.equal(calls, 1, 'a burst of 6 events through the app.ts wiring must produce exactly 1 rebuild, not 6 — if this fails, app.ts stopped routing SSE events through the coalescer');
116
+ unsubscribe();
117
+ });
@@ -1,6 +1,20 @@
1
1
  import { type CanvasSource } from '../source.js';
2
+ import type { RemoteEventStream } from '../remote-event-stream.js';
3
+ import { type CoalescedRebuilder } from './rebuild-coalescer.js';
4
+ /** Wires a RemoteEventStream's `nodes`/`inbox` events through the coalescer
5
+ * (`rebuild-coalescer.ts`) so a burst of SSE events collapses to ONE rebuild
6
+ * round, never one N+1 remote-subprocess rebuild per event. Exported (rather
7
+ * than left inline in `runBrowse`) so the coalescer integration test can
8
+ * drive this ACTUAL wiring, not just `createCoalescedRebuilder` in isolation
9
+ * — the test asserts this function routes through the coalescer rather than
10
+ * calling `rebuild()` directly per event. */
11
+ export declare function wireEventStreamRebuild(eventStream: Pick<RemoteEventStream, 'subscribe' | 'start'>, rebuild: () => Promise<void>): {
12
+ rebuilder: CoalescedRebuilder;
13
+ unsubscribe: () => void;
14
+ };
2
15
  export declare function runBrowse(opts?: {
3
16
  returnPane?: string;
4
17
  cwd?: string;
5
18
  source?: CanvasSource;
19
+ eventStream?: RemoteEventStream;
6
20
  }): Promise<void>;
@@ -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;