@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.
- package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
- package/dist/clients/attach/attach-cmd.d.ts +30 -2
- package/dist/clients/attach/attach-cmd.js +622 -622
- package/dist/clients/attach/canvas-panels.js +9 -3
- package/dist/clients/attach/graph-overlay.d.ts +5 -0
- package/dist/clients/attach/graph-overlay.js +40 -7
- package/dist/clients/attach/input-controller.d.ts +9 -0
- package/dist/clients/attach/input-controller.js +47 -8
- package/dist/clients/attach/slash-commands.d.ts +11 -4
- package/dist/clients/attach/slash-commands.js +44 -9
- package/dist/clients/attach/transport-relay.d.ts +5 -5
- package/dist/clients/attach/transport-relay.js +3 -30
- package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
- package/dist/commands/__tests__/canvas-config.test.js +107 -0
- package/dist/commands/canvas-browse.js +26 -1
- package/dist/commands/canvas-config.d.ts +2 -0
- package/dist/commands/canvas-config.js +210 -0
- package/dist/commands/canvas-use.d.ts +1 -0
- package/dist/commands/canvas-use.js +63 -0
- package/dist/commands/canvas.js +4 -2
- package/dist/commands/node.js +41 -11
- package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
- package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
- package/dist/core/__tests__/secrets.test.d.ts +1 -0
- package/dist/core/__tests__/secrets.test.js +55 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
- package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
- package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
- package/dist/core/canvas/browse/app.d.ts +14 -0
- package/dist/core/canvas/browse/app.js +97 -15
- package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
- package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +3 -1
- package/dist/core/canvas/nav-model.d.ts +19 -10
- package/dist/core/canvas/nav-model.js +30 -12
- package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
- package/dist/core/canvas/remote-canvas-source.js +222 -0
- package/dist/core/canvas/remote-event-stream.d.ts +24 -0
- package/dist/core/canvas/remote-event-stream.js +94 -0
- package/dist/core/canvas/render.d.ts +13 -1
- package/dist/core/canvas/render.js +56 -37
- package/dist/core/canvas/source.d.ts +9 -0
- package/dist/core/canvas/source.js +15 -0
- package/dist/core/config.js +4 -3
- package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/node-env.test.js +91 -0
- package/dist/core/runtime/nodes.js +10 -0
- package/dist/core/secrets.d.ts +25 -0
- package/dist/core/secrets.js +55 -0
- package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
- package/dist/core/view/__tests__/transport-remote.test.js +95 -0
- package/dist/core/view/remote-canvas-target.d.ts +2 -1
- package/dist/core/view/remote-canvas-target.js +12 -7
- package/dist/core/view/transport-remote.d.ts +2 -0
- package/dist/core/view/transport-remote.js +53 -0
- package/dist/types.d.ts +19 -11
- package/dist/types.js +1 -1
- 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
|
-
|
|
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.
|
|
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.
|
|
114
|
-
this.editor.onAction('app.model.cycleForward', () => this.
|
|
115
|
-
this.editor.onAction('app.model.cycleBackward', () => this.
|
|
116
|
-
this.editor.onAction('app.thinking.cycle', () => this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {};
|