@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
@@ -14,6 +14,7 @@
14
14
  // read fresh for every chrome frame.
15
15
  import { beginFrame, navLabel, nodeGlyph, truncate, tokensCell, cycleBadge, askBadge, activityCell, YELLOW, RESET, DIM, GREEN, } from '../../core/canvas/nav-model.js';
16
16
  import { localCanvasSource } from '../../core/canvas/source.js';
17
+ import { RemoteCanvasSource } from '../../core/canvas/remote-canvas-source.js';
17
18
  /** Column widths for the aligned panel rows (visible columns). name is sized to
18
19
  * the widest label across BOTH panels (capped); kind is wide enough for the
19
20
  * longest persona kind (`orchestrator` = 12) so real kinds never truncate;
@@ -33,6 +34,11 @@ function padStartVisible(s, w) {
33
34
  * pending-ask map the caller polls; pass `{}` to omit the ⚑ badges. `palette`
34
35
  * drives the headers + dim cells; omit it and the panels render uncolored. */
35
36
  export async function buildCanvasPanelLines(nodeId, asks, palette, source = localCanvasSource) {
37
+ // A remote source provides only graph structure + node metadata; tokens/
38
+ // activity/status-glyph adornments are local disk reads (telemetry.json,
39
+ // fault state, pid liveness) and are suppressed to safe defaults below —
40
+ // never read for a remote id, which may collide with a local one.
41
+ const remote = source instanceof RemoteCanvasSource;
36
42
  beginFrame();
37
43
  const nodes = new Map();
38
44
  const children = new Map();
@@ -132,9 +138,9 @@ export async function buildCanvasPanelLines(nodeId, asks, palette, source = loca
132
138
  const n = await node(id);
133
139
  const name = nameCell(navLabel(n, id), nameW);
134
140
  const kind = faint(nameCell(n?.kind ?? '', KIND_W));
135
- const tokens = muted(padStartVisible(tokensCell(id), TOK_W));
136
- const badges = `${cycleBadge(n)}${await childBadgeFor(n)}${liveBelowBadgeFor(n)}${askBadge(id, asks)}${activityCell(id, n)}`;
137
- return truncate(` ${nodeGlyph(n)} ${name} ${kind} ${tokens}${badges}`);
141
+ const tokens = muted(padStartVisible(tokensCell(id, remote), TOK_W));
142
+ const badges = `${cycleBadge(n)}${await childBadgeFor(n)}${liveBelowBadgeFor(n)}${askBadge(id, asks)}${activityCell(id, n, remote)}`;
143
+ return truncate(` ${nodeGlyph(n, remote)} ${name} ${kind} ${tokens}${badges}`);
138
144
  };
139
145
  const managers = [];
140
146
  if (mgr !== undefined)
@@ -21,6 +21,11 @@ export declare class GraphOverlay implements Component {
21
21
  private pendingConfirm;
22
22
  private snapshot;
23
23
  private snapshotSeq;
24
+ /** True when `source` is a `RemoteCanvasSource` — gates every local disk/
25
+ * SQLite adornment (tokens, activity, fault, attach, focus) to a safe
26
+ * default and makes Enter/m/x no-op instead of shelling local focus/kill
27
+ * commands against a remote graph row. See the file header. */
28
+ private readonly remote;
24
29
  constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, source?: CanvasSource);
25
30
  isOpen(): boolean;
26
31
  open(): void;
@@ -18,6 +18,16 @@
18
18
  // passes its own pane as `crtr node focus --pane <anchor>`: under the
19
19
  // one-viewer-per-node model the target's existing viewer is navigated in place,
20
20
  // or a fresh viewer opens BESIDE this pane — never a second pane for the same node.
21
+ //
22
+ // REMOTE mode (source instanceof RemoteCanvasSource, i.e. `attach --canvas`):
23
+ // this overlay is READ-ONLY. Focus/kill are local-drive-plane actions and a
24
+ // remote node id has no relationship to this machine's canvas.db, so Enter/m/x
25
+ // no-op instead of shelling `crtr node focus`/`crtr node lifecycle close` on an
26
+ // id that either doesn't exist locally or, worse, collides with one that does.
27
+ // The footer hint drops the focus/kill/mgr hints accordingly, and every
28
+ // telemetry/fault/attach/focus adornment below is suppressed to a safe default
29
+ // (see nav-model's `remote` parameters) — the remote CanvasSource supplies
30
+ // graph structure + node metadata ONLY.
21
31
  import { execFile } from 'node:child_process';
22
32
  import { matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
23
33
  import { fullName } from '../../core/canvas/index.js';
@@ -25,6 +35,7 @@ import { activeFaultForDisplay } from '../../core/canvas/render.js';
25
35
  import { readFault } from '../../core/runtime/fault.js';
26
36
  import { faultSummary } from '../../core/canvas/status-glyph.js';
27
37
  import { localCanvasSource } from '../../core/canvas/source.js';
38
+ import { RemoteCanvasSource } from '../../core/canvas/remote-canvas-source.js';
28
39
  import { beginFrame, navLabel, nodeGlyph, tokensCell, cycleBadge, askBadge, activityCell, focusedNodeIds, shortId, visibleWidth, fillBar, fillWidth, truncate, isAttached, VIEWPORT_FALLBACK_ROWS, DIM, RESET, BOLD, YELLOW, REVERSE, BG_ATTACHED, GREEN, } from '../../core/canvas/nav-model.js';
29
40
  // A BOUNDED, centered modal (CTO ruling: not full-bleed). Width/height are capped
30
41
  // at ~72% of the terminal; the overlay clips our lines to maxHeight, and render()
@@ -37,6 +48,8 @@ const OVERLAY_OPTIONS = { anchor: 'center', width: '72%', minWidth: 48, maxHeigh
37
48
  // host pane — the attach overlay has no such bind and swallows `e`, so the key
38
49
  // would do nothing here. Drop it.
39
50
  const GRAPH_HINT = `${DIM}jk move · hl fold · ↵ focus · x kill · m mgr · esc${RESET}`;
51
+ // REMOTE mode drops focus/kill/mgr — Enter/m/x no-op there (see the file header).
52
+ const GRAPH_HINT_REMOTE = `${DIM}jk move · hl fold · esc${RESET}`;
40
53
  export class GraphOverlay {
41
54
  tui;
42
55
  self;
@@ -57,12 +70,18 @@ export class GraphOverlay {
57
70
  pendingConfirm;
58
71
  snapshot;
59
72
  snapshotSeq = 0;
73
+ /** True when `source` is a `RemoteCanvasSource` — gates every local disk/
74
+ * SQLite adornment (tokens, activity, fault, attach, focus) to a safe
75
+ * default and makes Enter/m/x no-op instead of shelling local focus/kill
76
+ * commands against a remote graph row. See the file header. */
77
+ remote;
60
78
  constructor(tui, self, getAsks, palette, source = localCanvasSource) {
61
79
  this.tui = tui;
62
80
  this.self = self;
63
81
  this.getAsks = getAsks;
64
82
  this.palette = palette;
65
83
  this.source = source;
84
+ this.remote = source instanceof RemoteCanvasSource;
66
85
  }
67
86
  isOpen() {
68
87
  return this.handle !== undefined;
@@ -237,7 +256,7 @@ export class GraphOverlay {
237
256
  await walk(kids[i], childPrefix, false, i === kids.length - 1);
238
257
  };
239
258
  await walk(await climbRoot(this.self), '', true, true);
240
- return { rows, activeBelow, nodes, childIds, sortedChildIds, managerOf, focused: focusedNodeIds() };
259
+ return { rows, activeBelow, nodes, childIds, sortedChildIds, managerOf, focused: focusedNodeIds(this.remote) };
241
260
  }
242
261
  renderSnapshotRow(snapshot, r, isCursor, asks) {
243
262
  const wrap = (line, attached) => isCursor ? fillBar(line, fillWidth(), REVERSE)
@@ -248,11 +267,11 @@ export class GraphOverlay {
248
267
  return wrap(line, false);
249
268
  }
250
269
  const node = snapshot.nodes.get(r.id) ?? null;
251
- const dot = nodeGlyph(node);
270
+ const dot = nodeGlyph(node, this.remote);
252
271
  const rawName = navLabel(node, r.id);
253
272
  const name = r.isSelf ? `${BOLD}${rawName}${RESET}` : rawName;
254
273
  const kind = `${DIM}${node?.kind ?? ''}${RESET}`;
255
- const tokens = `${DIM}${tokensCell(r.id)}${RESET}`;
274
+ const tokens = `${DIM}${tokensCell(r.id, this.remote)}${RESET}`;
256
275
  const expandable = r.hasKids && r.collapsed;
257
276
  const caret = !isCursor && expandable ? `${DIM}▸${RESET} ` : ' ';
258
277
  const cycle = cycleBadge(node);
@@ -261,10 +280,13 @@ export class GraphOverlay {
261
280
  const below = node !== null && node.status !== 'active' && (snapshot.activeBelow.get(node.node_id) ?? 0) > 0
262
281
  ? ` ${GREEN}⇣${snapshot.activeBelow.get(node.node_id) ?? 0}${RESET}`
263
282
  : '';
264
- const fault = node !== null ? activeFaultForDisplay(node, readFault(r.id)) : null;
283
+ // A remote fault is a local disk read (readFault) keyed on a node id that
284
+ // may collide with a local one — skip it entirely rather than read-then-
285
+ // discard.
286
+ const fault = node !== null && !this.remote ? activeFaultForDisplay(node, readFault(r.id)) : null;
265
287
  const faultSuffix = fault !== null ? ` · ${faultSummary(fault)}` : '';
266
- const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${cycle}${child}${below}${askBadge(r.id, asks)}${activityCell(r.id, node)}${faultSuffix}`;
267
- return wrap(line, isAttached(r.id, node, snapshot.focused));
288
+ const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${cycle}${child}${below}${askBadge(r.id, asks)}${activityCell(r.id, node, this.remote)}${faultSuffix}`;
289
+ return wrap(line, isAttached(r.id, node, snapshot.focused, this.remote));
268
290
  }
269
291
  render(width) {
270
292
  beginFrame();
@@ -320,7 +342,7 @@ export class GraphOverlay {
320
342
  : `${BOLD}⌗ canvas graph${RESET} ${DIM}(loading)${RESET}`;
321
343
  const hint = this.pendingConfirm !== undefined
322
344
  ? `${YELLOW}${this.pendingConfirm.label} ${BOLD}y/n${RESET}`
323
- : GRAPH_HINT;
345
+ : this.remote ? GRAPH_HINT_REMOTE : GRAPH_HINT;
324
346
  // Frame the panel: title in the top border, hint in the bottom border, tree
325
347
  // rows boxed with themed side bars (truncate/pad to the inner width so the
326
348
  // right edge stays flush and opaque).
@@ -455,13 +477,22 @@ export class GraphOverlay {
455
477
  }
456
478
  return;
457
479
  }
480
+ // Enter/m/x drive local lifecycle/focus (`crtr node focus` / `crtr node
481
+ // lifecycle close`) against `this.cursorId` — a REMOTE graph row's id has no
482
+ // relationship to this machine's canvas.db (or, worse, collides with one
483
+ // that does), so all three no-op in remote mode instead of shelling a local
484
+ // mutation command against a remote id. GRAPH_HINT_REMOTE drops their hints.
458
485
  if (matchesKey(data, 'enter')) {
486
+ if (this.remote)
487
+ return;
459
488
  if (this.cursorId !== undefined)
460
489
  this.focusTarget(this.cursorId);
461
490
  this.close();
462
491
  return;
463
492
  }
464
493
  if (matchesKey(data, 'm')) {
494
+ if (this.remote)
495
+ return;
465
496
  const mgr = snapshot?.managerOf.get(this.self);
466
497
  if (mgr !== undefined) {
467
498
  this.focusTarget(mgr);
@@ -470,6 +501,8 @@ export class GraphOverlay {
470
501
  return;
471
502
  }
472
503
  if (matchesKey(data, 'x')) {
504
+ if (this.remote)
505
+ return;
473
506
  const target = this.cursorId ?? this.self;
474
507
  const n = snapshot?.nodes.get(target) ?? null;
475
508
  const nm = n !== null ? fullName(n) : shortId(target);
@@ -16,6 +16,11 @@ export interface InputControllerHooks {
16
16
  /** OPTIONAL: the canvas node this viewer is attached to — forwarded to the
17
17
  * slash context so `/promote` targets it (Unit Q wires it from runAttach). */
18
18
  nodeId?: string;
19
+ /** OPTIONAL: true for a REMOTE attach (`crtr surface attach --canvas`) —
20
+ * forwarded to the slash context so it can structurally omit/no-op the
21
+ * local-only native canvas commands (`/promote`, `/resume-node`, `/context`).
22
+ * Defaults to `false` (local, unchanged) when absent. */
23
+ remote?: boolean;
19
24
  /** OPTIONAL: toggle the GRAPH overlay — forwarded to the slash context so
20
25
  * `/graph` opens/closes it (Unit Q wires it from runAttach). */
21
26
  onGraph?: () => void;
@@ -64,6 +69,10 @@ export declare class InputController {
64
69
  /** Timestamp of the last lone Esc, for double-tap → tree detection (0 = none). */
65
70
  private lastEscapeAt;
66
71
  constructor(tui: TUI, editor: CustomEditor, keybindings: KeybindingsManager, hooks: InputControllerHooks);
72
+ /** Central choke point for every command frame the input layer emits (direct
73
+ * keybindings, emitDrive, and the slash-command/picker `send` sink). Local
74
+ * attach (`hooks.remote` false/absent) is byte-for-byte unchanged. */
75
+ private emitCommand;
67
76
  /** Render extension UI requests from the broker. Blocking dialogs route their
68
77
  * answer back to the broker; non-blocking notify requests become the viewer's
69
78
  * normal notice line. Supersedes any dialog already on screen (e.g. a re-route
@@ -33,6 +33,30 @@ const DOUBLE_ESCAPE_MS = 500;
33
33
  * split. Pasted images go to a temp file and enter the prompt as a path, never
34
34
  * as inline base64, so a paste can no longer push a frame toward this ceiling. */
35
35
  const MAX_FRAME_BYTES = BROKER_READ_CAPS.maxLineBytes - 4 * 1024 * 1024;
36
+ /** ClientToBroker frame types that mutate the engine/session — everything the
37
+ * broker gates behind controller-only (`notController` in broker.ts) PLUS
38
+ * `request_control`/`release_control` (control-claim frames; no current UI
39
+ * path sends them, gated anyway for defense-in-depth) and `reload_auth`
40
+ * (open to any role server-side, but viewer-local login/logout are disabled
41
+ * entirely for remote below — this entry is belt-and-suspenders in case a
42
+ * future path routes it through onCommand). A REMOTE attach (`--canvas`) is
43
+ * a strictly VIEW-ONLY surface (Phase 3 review Major 1): even though the
44
+ * broker would itself reject these from an observer, the input layer must
45
+ * never even emit one — server-side rejection alone is not the contract.
46
+ * `dequeue` is listed for documentation completeness even though it never
47
+ * reaches `emitCommand` (it rides the separate `onRequest` read-op channel —
48
+ * see `handleDequeue`'s own remote guard). Read-only frames (get_commands,
49
+ * list_models, list_sessions, get_tree, get_settings, list_scoped_models)
50
+ * and hello/bye/shutdown/extension_ui_response are deliberately absent —
51
+ * reads stay live for remote (pickers just never get the chance to select,
52
+ * see slash-commands.ts's REMOTE_SAFE_BUILTIN_NAMES gate). */
53
+ const DRIVE_FRAME_TYPES = new Set([
54
+ 'prompt', 'steer', 'follow_up', 'abort', 'bash',
55
+ 'set_model', 'cycle_model', 'cycle_thinking', 'set_thinking_level', 'dequeue',
56
+ 'set_auto_retry', 'set_auto_compaction', 'compact', 'new_session',
57
+ 'switch_session', 'fork', 'set_session_name', 'navigate_tree', 'reload',
58
+ 'export', 'clone', 'share', 'request_control', 'release_control', 'reload_auth',
59
+ ]);
36
60
  export class InputController {
37
61
  tui;
38
62
  editor;
@@ -56,6 +80,16 @@ export class InputController {
56
80
  this.hooks = hooks;
57
81
  this.wire();
58
82
  }
83
+ /** Central choke point for every command frame the input layer emits (direct
84
+ * keybindings, emitDrive, and the slash-command/picker `send` sink). Local
85
+ * attach (`hooks.remote` false/absent) is byte-for-byte unchanged. */
86
+ emitCommand(frame) {
87
+ if (this.hooks.remote === true && DRIVE_FRAME_TYPES.has(frame.type)) {
88
+ this.notify('Read-only remote attach — cannot drive the engine');
89
+ return;
90
+ }
91
+ this.hooks.onCommand(frame);
92
+ }
59
93
  /** Render extension UI requests from the broker. Blocking dialogs route their
60
94
  * answer back to the broker; non-blocking notify requests become the viewer's
61
95
  * normal notice line. Supersedes any dialog already on screen (e.g. a re-route
@@ -90,7 +124,7 @@ export class InputController {
90
124
  // navigate_tree path that `/tree` and app.session.tree use.
91
125
  this.editor.onEscape = () => {
92
126
  if (this.state?.isStreaming) {
93
- this.hooks.onCommand({ type: 'abort' });
127
+ this.emitCommand({ type: 'abort' });
94
128
  return;
95
129
  }
96
130
  if (this.editor.getText().trim())
@@ -110,10 +144,10 @@ export class InputController {
110
144
  void this.handlePaste();
111
145
  };
112
146
  // Keyboard shortcuts that map 1:1 to a frame needing no engine-side data.
113
- this.editor.onAction('app.session.new', () => this.hooks.onCommand({ type: 'new_session' }));
114
- this.editor.onAction('app.model.cycleForward', () => this.hooks.onCommand({ type: 'cycle_model', direction: 'forward' }));
115
- this.editor.onAction('app.model.cycleBackward', () => this.hooks.onCommand({ type: 'cycle_model', direction: 'backward' }));
116
- this.editor.onAction('app.thinking.cycle', () => this.hooks.onCommand({ type: 'cycle_thinking' }));
147
+ this.editor.onAction('app.session.new', () => this.emitCommand({ type: 'new_session' }));
148
+ this.editor.onAction('app.model.cycleForward', () => this.emitCommand({ type: 'cycle_model', direction: 'forward' }));
149
+ this.editor.onAction('app.model.cycleBackward', () => this.emitCommand({ type: 'cycle_model', direction: 'backward' }));
150
+ this.editor.onAction('app.thinking.cycle', () => this.emitCommand({ type: 'cycle_thinking' }));
117
151
  this.editor.onAction('app.message.followUp', () => this.handleFollowUp());
118
152
  this.editor.onAction('app.message.dequeue', () => void this.handleDequeue());
119
153
  // Pure client-side render toggles (ChatView owns the components).
@@ -131,11 +165,12 @@ export class InputController {
131
165
  }
132
166
  slashContext() {
133
167
  return {
134
- send: (frame) => this.hooks.onCommand(frame),
168
+ send: (frame) => this.emitCommand(frame),
135
169
  notify: (message) => this.notify(message),
136
170
  state: this.state,
137
171
  cwd: process.cwd(),
138
172
  nodeId: this.hooks.nodeId,
173
+ remote: this.hooks.remote,
139
174
  onGraph: this.hooks.onGraph,
140
175
  onQuit: this.hooks.onQuit,
141
176
  onCopy: this.hooks.onCopy,
@@ -156,7 +191,7 @@ export class InputController {
156
191
  // (via slashContext) AND by keybinding (onAction, below).
157
192
  // -------------------------------------------------------------------------
158
193
  /** Send a command frame to the broker (picker selection sink). */
159
- send = (frame) => this.hooks.onCommand(frame);
194
+ send = (frame) => this.emitCommand(frame);
160
195
  /** Fetch a picker payload (narrowed on `kind`), then build + show its overlay
161
196
  * iff this open is still the latest (m4: a stale slow reply is dropped). Any
162
197
  * failure — unwired channel, wrong kind, request error, or a throwing builder
@@ -386,6 +421,10 @@ export class InputController {
386
421
  * (pi's `restoreQueuedMessagesToEditor`: queued text first, joined by blank
387
422
  * lines). A read-AND-mutate op over the correlated request channel. */
388
423
  async handleDequeue() {
424
+ if (this.hooks.remote === true) {
425
+ this.notify('Read-only remote attach — cannot restore queued messages');
426
+ return;
427
+ }
389
428
  if (this.hooks.onRequest === undefined) {
390
429
  this.notify("Restoring queued messages isn't available in this viewer");
391
430
  return;
@@ -430,7 +469,7 @@ export class InputController {
430
469
  this.notify(`Message too large to send (${mib} MiB) — shorten the text or remove an attached image`);
431
470
  return false;
432
471
  }
433
- this.hooks.onCommand(frame);
472
+ this.emitCommand(frame);
434
473
  return true;
435
474
  }
436
475
  async handlePaste() {
@@ -12,6 +12,13 @@ export interface SlashContext {
12
12
  state?: BrokerSnapshot['state'];
13
13
  /** cwd for the default `/export` path; defaults to `process.cwd()`. */
14
14
  cwd?: string;
15
+ /** True for a REMOTE attach (`crtr surface attach --canvas <name>`). The
16
+ * native canvas commands that shell a LOCAL `crtr` mutation/read against
17
+ * `nodeId` (`/promote`, `/resume-node`, `/context`) are meaningless — or
18
+ * worse, id-colliding — for a remote node id, so `dispatchCanvasCommand`
19
+ * notifies + no-ops them instead of executing (see `REMOTE_UNSAFE_CANVAS_NAMES`
20
+ * below). Defaults to `false` so every existing local caller is unaffected. */
21
+ remote?: boolean;
15
22
  /** The canvas node this viewer is attached to — used by `/promote` (passed
16
23
  * as `--node`). Falls back to `CRTR_NODE_ID` when absent; if neither is set
17
24
  * `/promote` notifies and no-ops. (Unit Q wires this from `runAttach`.) */
@@ -66,14 +73,14 @@ export declare function isSlashCommand(text: string): boolean;
66
73
  export declare function dispatchSlashCommand(text: string, ctx: SlashContext): boolean;
67
74
  /** Build slash-command autocomplete entries from the merged command list (the
68
75
  * broker's `get_commands` result, which T7 may inject) — defaults to the
69
- * vendored builtins. The native canvas commands are always appended. Shaped for
70
- * pi-tui's `CombinedAutocompleteProvider`. */
76
+ * vendored builtins. The native canvas commands are always appended (minus the
77
+ * remote-unsafe ones when `remote`). Shaped for pi-tui's `CombinedAutocompleteProvider`. */
71
78
  export declare function slashCommandList(commands?: ReadonlyArray<{
72
79
  name: string;
73
80
  description?: string;
74
- }>): SlashCommand[];
81
+ }>, remote?: boolean): SlashCommand[];
75
82
  /** Same list as flat autocomplete items (value/label/description). */
76
83
  export declare function commandAutocompleteItems(commands?: ReadonlyArray<{
77
84
  name: string;
78
85
  description?: string;
79
- }>): AutocompleteItem[];
86
+ }>, remote?: boolean): AutocompleteItem[];
@@ -39,6 +39,14 @@ export const CANVAS_SLASH_COMMANDS = [
39
39
  { name: 'context', description: 'Browse this node + its subscriptions’ context dirs in a read-only nvim popup' },
40
40
  ];
41
41
  const CANVAS_NAMES = new Set(CANVAS_SLASH_COMMANDS.map((c) => c.name));
42
+ /** Native canvas commands that shell a LOCAL `crtr` mutation or read against
43
+ * `ctx.nodeId` — unsafe for a remote attach (Phase 3 review 2, findings-fix-
44
+ * review.md §1): `/promote` shells `crtr node promote --node` locally,
45
+ * `/resume-node` opens local `crtr canvas browse` whose Enter path focuses a
46
+ * local node, `/context` opens local `crtr node inspect context`. `/graph`
47
+ * (the in-process GRAPH overlay, already remote-safe — see graph-overlay.ts)
48
+ * and `/view` (a self-contained popup unrelated to `nodeId`) stay available. */
49
+ const REMOTE_UNSAFE_CANVAS_NAMES = new Set(['promote', 'resume-node', 'context']);
42
50
  /** Builtins with no Phase-4 engine method — scoped out (review m2). */
43
51
  const SCOPED_OUT = new Set(['trust']);
44
52
  /** Valid `/settings thinking` levels — the `SetThinkingLevelFrame['level']`
@@ -56,6 +64,17 @@ const THINKING_LEVELS = new Set(Object.keys({
56
64
  xhigh: true,
57
65
  }));
58
66
  const BUILTIN_NAMES = new Set(BUILTIN_SLASH_COMMANDS.map((c) => c.name));
67
+ /** Builtin slash commands safe to run against a REMOTE attach (`--canvas`) —
68
+ * everything else in `BUILTIN_SLASH_COMMANDS` either drives the engine
69
+ * (`/model`, `/new`, `/compact`, `/name`, `/resume`, `/fork`, `/tree`,
70
+ * `/settings`, `/share`, `/clone`, `/import`, `/reload`, `/scoped-models`
71
+ * selection) or is viewer-local but operates on the WRONG host for a remote
72
+ * node (`/login`, `/logout` — LOCAL `auth.json` via `defaultAgentDir()`, not
73
+ * the remote broker's). Blocked here BEFORE the switch so a remote attach
74
+ * never even opens the picker only to have the eventual selection frame
75
+ * swallowed by InputController's `emitCommand` gate (defense-in-depth #2,
76
+ * not the only defense — see input-controller.ts DRIVE_FRAME_TYPES). */
77
+ const REMOTE_SAFE_BUILTIN_NAMES = new Set(['copy', 'session', 'changelog', 'hotkeys', 'quit']);
59
78
  /** True if `text` is a leading-slash command (vs. a normal prompt). */
60
79
  export function isSlashCommand(text) {
61
80
  return text.trimStart().startsWith('/');
@@ -83,6 +102,10 @@ export function dispatchSlashCommand(text, ctx) {
83
102
  // Unknown to us AND not a builtin → let the engine's extension runner have it.
84
103
  if (!BUILTIN_NAMES.has(name))
85
104
  return false;
105
+ if (ctx.remote === true && !REMOTE_SAFE_BUILTIN_NAMES.has(name)) {
106
+ ctx.notify(`/${name} is not available for a remote attach (--canvas) — view-only`);
107
+ return true;
108
+ }
86
109
  switch (name) {
87
110
  // --- direct frames -----------------------------------------------------
88
111
  case 'new':
@@ -209,21 +232,25 @@ export function dispatchSlashCommand(text, ctx) {
209
232
  /** Append the native canvas commands to a command list, skipping any already
210
233
  * present (so they survive whatever list Unit Q feeds in — builtins, or the
211
234
  * broker's `get_commands` result). The four canvas commands are viewer-owned,
212
- * so they are always surfaced in autocomplete regardless of the source list. */
213
- function withCanvasCommands(commands) {
235
+ * so they are always surfaced in autocomplete regardless of the source list
236
+ * EXCEPT `REMOTE_UNSAFE_CANVAS_NAMES` when `remote`, which are structurally
237
+ * omitted rather than offered-then-blocked, so a remote attach's autocomplete
238
+ * never advertises a command `dispatchCanvasCommand` would refuse to run. */
239
+ function withCanvasCommands(commands, remote) {
214
240
  const seen = new Set(commands.map((c) => c.name));
215
- return [...commands, ...CANVAS_SLASH_COMMANDS.filter((c) => !seen.has(c.name))];
241
+ const offerable = remote ? CANVAS_SLASH_COMMANDS.filter((c) => !REMOTE_UNSAFE_CANVAS_NAMES.has(c.name)) : CANVAS_SLASH_COMMANDS;
242
+ return [...commands, ...offerable.filter((c) => !seen.has(c.name))];
216
243
  }
217
244
  /** Build slash-command autocomplete entries from the merged command list (the
218
245
  * broker's `get_commands` result, which T7 may inject) — defaults to the
219
- * vendored builtins. The native canvas commands are always appended. Shaped for
220
- * pi-tui's `CombinedAutocompleteProvider`. */
221
- export function slashCommandList(commands = BUILTIN_SLASH_COMMANDS) {
222
- return withCanvasCommands(commands).map((c) => ({ name: c.name, description: c.description }));
246
+ * vendored builtins. The native canvas commands are always appended (minus the
247
+ * remote-unsafe ones when `remote`). Shaped for pi-tui's `CombinedAutocompleteProvider`. */
248
+ export function slashCommandList(commands = BUILTIN_SLASH_COMMANDS, remote = false) {
249
+ return withCanvasCommands(commands, remote).map((c) => ({ name: c.name, description: c.description }));
223
250
  }
224
251
  /** Same list as flat autocomplete items (value/label/description). */
225
- export function commandAutocompleteItems(commands = BUILTIN_SLASH_COMMANDS) {
226
- return withCanvasCommands(commands).map((c) => ({ value: `/${c.name}`, label: `/${c.name}`, description: c.description }));
252
+ export function commandAutocompleteItems(commands = BUILTIN_SLASH_COMMANDS, remote = false) {
253
+ return withCanvasCommands(commands, remote).map((c) => ({ value: `/${c.name}`, label: `/${c.name}`, description: c.description }));
227
254
  }
228
255
  // ---------------------------------------------------------------------------
229
256
  // Canvas commands — native reimplementations of the canvas chrome extensions.
@@ -231,6 +258,14 @@ export function commandAutocompleteItems(commands = BUILTIN_SLASH_COMMANDS) {
231
258
  /** Dispatch one of the four native canvas commands. Always returns `true` (the
232
259
  * command is viewer-owned — never forwarded to the engine as a prompt). */
233
260
  function dispatchCanvasCommand(name, arg, ctx) {
261
+ // Structural omission (withCanvasCommands) keeps these out of remote-mode
262
+ // autocomplete; this gate is the belt-and-suspenders that also makes them
263
+ // NOT EXECUTABLE if typed by hand — a remote node id has no relationship to
264
+ // this machine's local canvas.db (or, worse, collides with one that does).
265
+ if (ctx.remote && REMOTE_UNSAFE_CANVAS_NAMES.has(name)) {
266
+ ctx.notify(`/${name} is not available for a remote attach (--canvas) — it acts on this machine's local canvas`);
267
+ return true;
268
+ }
234
269
  switch (name) {
235
270
  case 'graph':
236
271
  // `/graph` is inherently the in-process GRAPH overlay (canvas-nav.ts
@@ -2,7 +2,11 @@ import { EventEmitter } from 'node:events';
2
2
  import type { ClientToBroker } from '../../core/runtime/broker-protocol.js';
3
3
  import { type Transport } from './transport.js';
4
4
  /** A resolved remote canvas target — see `remote-canvas-target.ts` for how
5
- * this is assembled from config + the token's env var, never from argv. */
5
+ * this is assembled from config (`relayTokenRef`) plus the 0600 secrets store
6
+ * via `resolveRemoteCanvasTarget`. This transport itself never takes the
7
+ * token from argv — `canvas config add --relay-token -` is the only accepted
8
+ * form for writing a token to the store — stdin-only, never argv; that is a
9
+ * one-time config-write path, not this call. */
6
10
  export interface RelayTarget {
7
11
  /** The preview endpoint origin, e.g. `https://<hash>.preview.bl.run`. */
8
12
  previewEndpoint: string;
@@ -14,12 +18,8 @@ export declare class RelayTransport extends EventEmitter implements Transport {
14
18
  private readonly target;
15
19
  private ws;
16
20
  private closeEmitted;
17
- /** One redial fault line per reconnect EPISODE, not per attempt — mirrors
18
- * SocketTransport. */
19
- private redialFaultRecorded;
20
21
  constructor(nodeId: string, target: RelayTarget);
21
22
  private get url();
22
- private recordRelayFault;
23
23
  private dial;
24
24
  connect(): void;
25
25
  /** Re-dial the SAME `wss://<previewEndpoint>/node/<id>` — the preview
@@ -18,7 +18,6 @@
18
18
  import { EventEmitter } from 'node:events';
19
19
  import { WebSocket } from 'ws';
20
20
  import { classify } from '../../core/fault-classifier.js';
21
- import { clearFault, recordFault } from '../../core/runtime/fault.js';
22
21
  import { BrokerUnavailableError } from './transport.js';
23
22
  function relayUrl(target, nodeId) {
24
23
  const url = new URL(target.previewEndpoint);
@@ -33,9 +32,6 @@ export class RelayTransport extends EventEmitter {
33
32
  target;
34
33
  ws;
35
34
  closeEmitted = false;
36
- /** One redial fault line per reconnect EPISODE, not per attempt — mirrors
37
- * SocketTransport. */
38
- redialFaultRecorded = false;
39
35
  constructor(nodeId, target) {
40
36
  super();
41
37
  this.nodeId = nodeId;
@@ -44,23 +40,6 @@ export class RelayTransport extends EventEmitter {
44
40
  get url() {
45
41
  return relayUrl(this.target, this.nodeId);
46
42
  }
47
- recordRelayFault(op, err) {
48
- if (op === 'redial') {
49
- if (this.redialFaultRecorded)
50
- return;
51
- this.redialFaultRecorded = true;
52
- }
53
- const classified = classify('viewer↔broker', err);
54
- recordFault(this.nodeId, {
55
- link: 'viewer↔broker',
56
- op,
57
- kind: classified.kind,
58
- retry: op === 'redial'
59
- ? { disposition: 'auto', by: 'client' }
60
- : { disposition: 'manual' },
61
- message: err instanceof Error ? err.message : String(err),
62
- });
63
- }
64
43
  dial() {
65
44
  const ws = new WebSocket(this.url, {
66
45
  headers: { authorization: `Bearer ${this.target.relayToken}` },
@@ -72,12 +51,10 @@ export class RelayTransport extends EventEmitter {
72
51
  connect() {
73
52
  const ws = this.dial();
74
53
  ws.on('open', () => {
75
- this.redialFaultRecorded = false;
76
- clearFault(this.nodeId, { link: 'viewer↔broker' });
77
54
  this.emit('connect');
78
55
  });
79
56
  ws.on('message', (data) => this.onMessage(data));
80
- ws.on('error', (err) => this.onError(err, 'connect'));
57
+ ws.on('error', (err) => this.onError(err));
81
58
  ws.on('close', (code, reason) => this.onClose(code, reason.toString()));
82
59
  }
83
60
  /** Re-dial the SAME `wss://<previewEndpoint>/node/<id>` — the preview
@@ -92,19 +69,16 @@ export class RelayTransport extends EventEmitter {
92
69
  if (settled)
93
70
  return;
94
71
  settled = true;
95
- this.redialFaultRecorded = false;
96
- clearFault(this.nodeId, { link: 'viewer↔broker' });
97
72
  this.emit('connect');
98
73
  resolve();
99
74
  });
100
75
  ws.on('message', (data) => this.onMessage(data));
101
76
  ws.on('error', (err) => {
102
77
  if (settled) {
103
- this.onError(err, 'redial');
78
+ this.onError(err);
104
79
  return;
105
80
  }
106
81
  settled = true;
107
- this.recordRelayFault('redial', err);
108
82
  try {
109
83
  ws.terminate();
110
84
  }
@@ -154,8 +128,7 @@ export class RelayTransport extends EventEmitter {
154
128
  }
155
129
  this.emit('frame', frame);
156
130
  }
157
- onError(err, op) {
158
- this.recordRelayFault(op, err);
131
+ onError(err) {
159
132
  this.emitError(new Error(`relay transport error: ${err.message}`));
160
133
  // 'close' follows 'error'; teardown converges there.
161
134
  }
@@ -0,0 +1 @@
1
+ export {};