@north-light/crouter 0.3.157 → 0.3.159
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/api/client.d.ts +4 -1
- package/dist/api/client.js +5 -0
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +15 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/internal/storage-tiers.md +2 -0
- package/dist/builtin-views/chat/core.mjs +6 -51
- package/dist/builtin-views/chat/tui.mjs +6 -14
- package/dist/builtin-views/chat/web.jsx +2 -7
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
- package/dist/clients/attach/chrome/bash-jobs.d.ts +0 -5
- package/dist/clients/attach/chrome/bash-jobs.js +5 -24
- package/dist/clients/attach/chrome/roster.d.ts +4 -3
- package/dist/clients/attach/chrome/roster.js +20 -36
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +23 -8
- package/dist/clients/attach/input/controller.js +59 -29
- package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
- package/dist/clients/attach/overlays/graph.d.ts +4 -1
- package/dist/clients/attach/overlays/graph.js +25 -7
- package/dist/clients/attach/session/context.d.ts +7 -0
- package/dist/clients/attach/session/frames.js +1 -8
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +21 -11
- package/dist/clients/attach/session/mode.d.ts +4 -3
- package/dist/clients/attach/session/mode.js +6 -1
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +6 -7
- package/dist/clients/attach/session/state-sync.d.ts +1 -1
- package/dist/clients/attach/session/state-sync.js +2 -2
- package/dist/clients/attach/slash/dispatch.d.ts +13 -1
- package/dist/clients/attach/slash/dispatch.js +65 -17
- package/dist/clients/attach/viewer.js +497 -499
- package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
- package/dist/commands/node.js +142 -4
- package/dist/commands/surface-inspect.js +4 -3
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
- package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
- package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +15 -26
- package/dist/core/bash-jobs.d.ts +11 -0
- package/dist/core/bash-jobs.js +41 -1
- package/dist/core/feed/inbox.d.ts +3 -3
- package/dist/core/feed/inbox.js +10 -6
- package/dist/core/inspector/core.d.ts +17 -2
- package/dist/core/inspector/core.js +172 -23
- package/dist/core/inspector/model.d.ts +30 -1
- package/dist/core/inspector/model.js +39 -0
- package/dist/core/inspector/text.js +14 -1
- package/dist/core/inspector/tui.js +71 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +3 -4
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +9 -13
- package/dist/core/runtime/boot-root.d.ts +2 -2
- package/dist/core/runtime/boot-root.js +7 -7
- package/dist/core/runtime/broker-protocol.d.ts +23 -27
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +5 -11
- package/dist/core/runtime/broker.d.ts +21 -13
- package/dist/core/runtime/broker.js +151 -186
- package/dist/core/runtime/interactive-deliver.js +4 -5
- package/dist/core/runtime/model-swap.d.ts +2 -3
- package/dist/core/runtime/model-swap.js +3 -4
- package/dist/core/runtime/node-read.d.ts +20 -0
- package/dist/core/runtime/node-read.js +34 -1
- package/dist/core/runtime/resume-root.d.ts +1 -1
- package/dist/core/runtime/resume-root.js +6 -6
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/session-model/session-state.d.ts +8 -6
- package/dist/core/session-model/session-state.js +6 -16
- package/dist/core/tui/host.js +21 -6
- package/dist/daemon/api/handlers/nodes.js +18 -1
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/pi-extensions/canvas-bash-valve.js +9 -3
- package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
- package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v5.md +175 -0
- package/docs/public-api.md +2 -2
- package/package.json +2 -2
- package/runtime.lock.json +6 -6
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-control-preempt.test.js +0 -61
|
@@ -19,7 +19,7 @@ import { join } from 'node:path';
|
|
|
19
19
|
import { BROKER_READ_CAPS, encodeFrame, } from '../../../core/runtime/broker-protocol.js';
|
|
20
20
|
import { isBlockingDialog, renderDialog } from '../overlays/dialogs.js';
|
|
21
21
|
import { readClipboardImage, writeClipboardImageToFile } from './clipboard-image.js';
|
|
22
|
-
import { dispatchSlashCommand, isSlashCommand } from '../slash/dispatch.js';
|
|
22
|
+
import { dispatchSlashCommand, isSlashCommand, readOnlySlashRefusal } from '../slash/dispatch.js';
|
|
23
23
|
import { buildForkPicker, buildModelPicker, buildScopedModelsPicker, buildSessionPicker, buildSettingsPicker, buildTreePicker, } from '../overlays/pickers.js';
|
|
24
24
|
import { buildMcpPicker, readMcpServers, startMcpServerAction, MCP_EMPTY_STATE_HINT } from '../overlays/mcp.js';
|
|
25
25
|
import { buildFileReviewPicker } from '../overlays/file-review.js';
|
|
@@ -36,28 +36,28 @@ const DOUBLE_ESCAPE_MS = 500;
|
|
|
36
36
|
* as inline base64, so a paste can no longer push a frame toward this ceiling. */
|
|
37
37
|
const MAX_FRAME_BYTES = BROKER_READ_CAPS.maxLineBytes - 4 * 1024 * 1024;
|
|
38
38
|
/** ClientToBroker frame types that mutate the engine/session — everything the
|
|
39
|
-
* broker gates behind
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* a strictly VIEW-ONLY surface
|
|
46
|
-
*
|
|
47
|
-
*
|
|
39
|
+
* broker gates behind a writable role (`read_only` in broker.ts), plus
|
|
40
|
+
* `reload_auth`
|
|
41
|
+
* (open to any role server-side, but viewer-local login/logout are unwired for
|
|
42
|
+
* a read-only viewer — this entry is belt-and-suspenders in case a
|
|
43
|
+
* future path routes it through onCommand). A read-only viewer — an observer
|
|
44
|
+
* (`--observer`) or a REMOTE attach (`--canvas`, always an observer) — is
|
|
45
|
+
* a strictly VIEW-ONLY surface: even though the broker would itself reject
|
|
46
|
+
* these from an observer, the input layer must never even emit one —
|
|
47
|
+
* server-side rejection alone is not the contract.
|
|
48
48
|
* `dequeue` is listed for documentation completeness even though it never
|
|
49
49
|
* reaches `emitCommand` (it rides the separate `onRequest` read-op channel —
|
|
50
|
-
* see `handleDequeue`'s own
|
|
50
|
+
* see `handleDequeue`'s own capability guard). Read-only frames (get_commands,
|
|
51
51
|
* list_models, list_sessions, get_tree, get_settings, list_scoped_models)
|
|
52
52
|
* and hello/bye/shutdown/extension_ui_response are deliberately absent —
|
|
53
53
|
* reads stay live for remote (pickers just never get the chance to select,
|
|
54
|
-
* see slash/dispatch.ts's
|
|
54
|
+
* see slash/dispatch.ts's READ_ONLY_SAFE_BUILTIN_NAMES gate). */
|
|
55
55
|
const DRIVE_FRAME_TYPES = new Set([
|
|
56
56
|
'prompt', 'steer', 'follow_up', 'abort', 'bash',
|
|
57
57
|
'set_model', 'cycle_model', 'cycle_ladder', 'cycle_thinking', 'set_thinking_level', 'dequeue',
|
|
58
58
|
'set_auto_retry', 'set_auto_compaction', 'compact', 'new_session',
|
|
59
59
|
'switch_session', 'fork', 'set_session_name', 'navigate_tree', 'reload',
|
|
60
|
-
'export', 'clone', 'share', '
|
|
60
|
+
'export', 'clone', 'share', 'reload_auth',
|
|
61
61
|
]);
|
|
62
62
|
export class InputController {
|
|
63
63
|
tui;
|
|
@@ -93,22 +93,42 @@ export class InputController {
|
|
|
93
93
|
this.hooks = hooks;
|
|
94
94
|
this.wire();
|
|
95
95
|
}
|
|
96
|
+
/** Why this viewer may not mutate the engine, or `undefined` when it may. The
|
|
97
|
+
* ONE capability gate: a remote attach and an `--observer` attach are both
|
|
98
|
+
* read-only, and the role is fixed at hello, so this answer cannot change
|
|
99
|
+
* mid-session. */
|
|
100
|
+
readOnlyReason() {
|
|
101
|
+
if (this.hooks.remote === true)
|
|
102
|
+
return 'Read-only remote attach';
|
|
103
|
+
if ((this.hooks.role ?? 'controller') !== 'controller')
|
|
104
|
+
return 'Read-only observer attach';
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
96
107
|
/** Central choke point for every command frame the input layer emits (direct
|
|
97
|
-
* keybindings, emitDrive, and the slash-command/picker `send` sink).
|
|
98
|
-
*
|
|
108
|
+
* keybindings, emitDrive, and the slash-command/picker `send` sink). Returns
|
|
109
|
+
* false when the gate refused the frame, so callers stop without emitting
|
|
110
|
+
* it. A writable local attach is byte-for-byte unchanged. */
|
|
99
111
|
emitCommand(frame) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
const reason = this.readOnlyReason();
|
|
113
|
+
if (reason !== undefined && DRIVE_FRAME_TYPES.has(frame.type)) {
|
|
114
|
+
this.notify(`${reason} — cannot drive the engine`);
|
|
115
|
+
return false;
|
|
103
116
|
}
|
|
104
117
|
this.hooks.onCommand(frame);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
/** The model-ladder keybinding (alt+m / alt+shift+m), which viewer.ts's global
|
|
121
|
+
* key listener owns rather than the editor's action table. Public so that
|
|
122
|
+
* binding rides the gate above instead of going straight to the socket. */
|
|
123
|
+
cycleLadder(direction) {
|
|
124
|
+
this.emitCommand({ type: 'cycle_ladder', direction });
|
|
105
125
|
}
|
|
106
126
|
/** Render extension UI requests from the broker. Blocking dialogs route their
|
|
107
127
|
* answer back to the broker; non-blocking notify requests become the viewer's
|
|
108
128
|
* normal notice line and NOTHING else — a notify must never tear down an
|
|
109
129
|
* unrelated blocking dialog whose broker request is still pending. A dialog is
|
|
110
|
-
* torn down only by (a) a NEW blocking dialog superseding it (
|
|
111
|
-
*
|
|
130
|
+
* torn down only by (a) a NEW blocking dialog superseding it, (b) its own
|
|
131
|
+
* answer, or (c) a correlated {@link dismissDialog}
|
|
112
132
|
* from the broker (the request was aborted/timed out out-of-band). */
|
|
113
133
|
attachDialog(req) {
|
|
114
134
|
if (req.method === 'notify') {
|
|
@@ -218,6 +238,7 @@ export class InputController {
|
|
|
218
238
|
nodeId: this.hooks.nodeId,
|
|
219
239
|
profileId: this.hooks.profileId,
|
|
220
240
|
remote: this.hooks.remote,
|
|
241
|
+
role: this.hooks.role,
|
|
221
242
|
onGraph: this.hooks.onGraph,
|
|
222
243
|
onQuit: this.hooks.onQuit,
|
|
223
244
|
onCopy: this.hooks.onCopy,
|
|
@@ -241,7 +262,9 @@ export class InputController {
|
|
|
241
262
|
// (via slashContext) AND by keybinding (onAction, below).
|
|
242
263
|
// -------------------------------------------------------------------------
|
|
243
264
|
/** Send a command frame to the broker (picker selection sink). */
|
|
244
|
-
send = (frame) =>
|
|
265
|
+
send = (frame) => {
|
|
266
|
+
this.emitCommand(frame);
|
|
267
|
+
};
|
|
245
268
|
/** Fetch a picker payload (narrowed on `kind`), then build + show its overlay
|
|
246
269
|
* iff this open is still the latest (m4: a stale slow reply is dropped). Any
|
|
247
270
|
* failure — unwired channel, wrong kind, request error, or a throwing builder
|
|
@@ -426,9 +449,17 @@ export class InputController {
|
|
|
426
449
|
if (!trimmed)
|
|
427
450
|
return;
|
|
428
451
|
if (isSlashCommand(trimmed)) {
|
|
452
|
+
const slashCtx = this.slashContext();
|
|
453
|
+
// A read-only viewer's refusal is surfaced BEFORE dispatch, so no command
|
|
454
|
+
// or engine frame is emitted.
|
|
455
|
+
const refusal = readOnlySlashRefusal(trimmed, slashCtx);
|
|
456
|
+
if (refusal !== undefined) {
|
|
457
|
+
this.notify(refusal);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
429
460
|
// Recognized builtin/scoped-out → handled here; unrecognized falls through
|
|
430
461
|
// and is sent to the engine as a prompt (extension command).
|
|
431
|
-
if (dispatchSlashCommand(trimmed,
|
|
462
|
+
if (dispatchSlashCommand(trimmed, slashCtx)) {
|
|
432
463
|
this.editor.setText('');
|
|
433
464
|
return;
|
|
434
465
|
}
|
|
@@ -479,7 +510,7 @@ export class InputController {
|
|
|
479
510
|
? { type: 'steer', text: expanded }
|
|
480
511
|
: { type: 'prompt', text: expanded };
|
|
481
512
|
if (!this.emitDrive(frame))
|
|
482
|
-
return; // too large —
|
|
513
|
+
return; // refused or too large — no frame emitted
|
|
483
514
|
this.editor.addToHistory(trimmed);
|
|
484
515
|
this.editor.setText('');
|
|
485
516
|
this.clearPastedImages();
|
|
@@ -567,8 +598,9 @@ export class InputController {
|
|
|
567
598
|
* (pi's `restoreQueuedMessagesToEditor`: queued text first, joined by blank
|
|
568
599
|
* lines). A read-AND-mutate op over the correlated request channel. */
|
|
569
600
|
async handleDequeue() {
|
|
570
|
-
|
|
571
|
-
|
|
601
|
+
const reason = this.readOnlyReason();
|
|
602
|
+
if (reason !== undefined) {
|
|
603
|
+
this.notify(`${reason} — cannot restore queued messages`);
|
|
572
604
|
return;
|
|
573
605
|
}
|
|
574
606
|
if (this.hooks.onRequest === undefined) {
|
|
@@ -599,8 +631,7 @@ export class InputController {
|
|
|
599
631
|
}
|
|
600
632
|
/** Send a drive frame iff the WHOLE encoded frame fits under MAX_FRAME_BYTES
|
|
601
633
|
* (the broker destroys the socket on any line over its 24 MiB read cap). Over
|
|
602
|
-
* the ceiling → notify + refuse
|
|
603
|
-
* images intact for the user to trim, never a socket-destroying overflow. */
|
|
634
|
+
* the ceiling → notify + refuse without sending a socket-destroying overflow. */
|
|
604
635
|
emitDrive(frame) {
|
|
605
636
|
let bytes;
|
|
606
637
|
try {
|
|
@@ -615,8 +646,7 @@ export class InputController {
|
|
|
615
646
|
this.notify(`Message too large to send (${mib} MiB) — shorten the text or remove an attached image`);
|
|
616
647
|
return false;
|
|
617
648
|
}
|
|
618
|
-
this.emitCommand(frame);
|
|
619
|
-
return true;
|
|
649
|
+
return this.emitCommand(frame);
|
|
620
650
|
}
|
|
621
651
|
async handlePaste() {
|
|
622
652
|
try {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { KeybindingsManager, type TUI } from '@earendil-works/pi-tui';
|
|
2
2
|
import type { RpcExtensionUIRequest, RpcExtensionUIResponse } from '../../../core/runtime/broker-protocol.js';
|
|
3
3
|
/** Handle for a rendered dialog: tear it down (without responding) when the
|
|
4
|
-
* request is superseded
|
|
5
|
-
* control handoff re-routes it. */
|
|
4
|
+
* request is superseded, answered, or dismissed by the broker. */
|
|
6
5
|
export interface DialogHandle {
|
|
7
6
|
dismiss(): void;
|
|
8
7
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Component, type TUI } from '@earendil-works/pi-tui';
|
|
2
2
|
import { type BindingResolution, type BindingId } from '../../../core/keybindings/index.js';
|
|
3
3
|
import type { CanvasSource } from '../../../core/canvas/source.js';
|
|
4
|
+
import type { ClientRole } from '../../../core/runtime/broker-protocol.js';
|
|
4
5
|
import type { AttachPalette } from '../config.js';
|
|
5
6
|
export declare class GraphOverlay implements Component {
|
|
6
7
|
private readonly tui;
|
|
@@ -9,7 +10,9 @@ export declare class GraphOverlay implements Component {
|
|
|
9
10
|
private readonly palette;
|
|
10
11
|
private bindings;
|
|
11
12
|
private readonly source;
|
|
13
|
+
private readonly role;
|
|
12
14
|
private readonly onRefreshFailure;
|
|
15
|
+
private readonly onNotice;
|
|
13
16
|
private handle;
|
|
14
17
|
/** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
|
|
15
18
|
* both override the default activity-driven policy and survive open/close. */
|
|
@@ -29,7 +32,7 @@ export declare class GraphOverlay implements Component {
|
|
|
29
32
|
* default and makes Enter/r/m/x no-op instead of shelling local lifecycle
|
|
30
33
|
* commands against a remote graph row. See the file header. */
|
|
31
34
|
private readonly remote;
|
|
32
|
-
constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, bindings: BindingResolution<BindingId>, source: CanvasSource, onRefreshFailure?: (cause?: unknown) => void);
|
|
35
|
+
constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, bindings: BindingResolution<BindingId>, source: CanvasSource, role?: ClientRole, onRefreshFailure?: (cause?: unknown) => void, onNotice?: (message: string) => void);
|
|
33
36
|
/** The tmux-focus set for `isAttached`, fetched via crtrd (was nav-model's
|
|
34
37
|
* focusedNodeIds → listFocuses db read). Empty for a remote source (a remote
|
|
35
38
|
* node id may collide with a local focus row); a failed API call degrades to
|
|
@@ -59,7 +59,7 @@ const GRAPH_PRIMARY_LOCAL_HINTS = [
|
|
|
59
59
|
['crtr.graph.close-subtree', 'close'],
|
|
60
60
|
['crtr.graph.focus-manager', 'manager'],
|
|
61
61
|
];
|
|
62
|
-
const
|
|
62
|
+
const GRAPH_PRIMARY_READ_ONLY_HINTS = [
|
|
63
63
|
['crtr.graph.dismiss', 'dismiss'],
|
|
64
64
|
];
|
|
65
65
|
const GRAPH_NAV_HINTS = [
|
|
@@ -77,7 +77,9 @@ export class GraphOverlay {
|
|
|
77
77
|
palette;
|
|
78
78
|
bindings;
|
|
79
79
|
source;
|
|
80
|
+
role;
|
|
80
81
|
onRefreshFailure;
|
|
82
|
+
onNotice;
|
|
81
83
|
handle;
|
|
82
84
|
/** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
|
|
83
85
|
* both override the default activity-driven policy and survive open/close. */
|
|
@@ -97,14 +99,16 @@ export class GraphOverlay {
|
|
|
97
99
|
* default and makes Enter/r/m/x no-op instead of shelling local lifecycle
|
|
98
100
|
* commands against a remote graph row. See the file header. */
|
|
99
101
|
remote;
|
|
100
|
-
constructor(tui, self, getAsks, palette, bindings, source, onRefreshFailure = () => { }) {
|
|
102
|
+
constructor(tui, self, getAsks, palette, bindings, source, role = 'controller', onRefreshFailure = () => { }, onNotice = () => { }) {
|
|
101
103
|
this.tui = tui;
|
|
102
104
|
this.self = self;
|
|
103
105
|
this.getAsks = getAsks;
|
|
104
106
|
this.palette = palette;
|
|
105
107
|
this.bindings = bindings;
|
|
106
108
|
this.source = source;
|
|
109
|
+
this.role = role;
|
|
107
110
|
this.onRefreshFailure = onRefreshFailure;
|
|
111
|
+
this.onNotice = onNotice;
|
|
108
112
|
this.remote = source instanceof RemoteCanvasSource;
|
|
109
113
|
}
|
|
110
114
|
/** The tmux-focus set for `isAttached`, fetched via crtrd (was nav-model's
|
|
@@ -415,7 +419,7 @@ export class GraphOverlay {
|
|
|
415
419
|
['crtr.graph.confirm.cancel', 'cancel'],
|
|
416
420
|
]).join(' · ')}${RESET}`
|
|
417
421
|
: `${DIM}${this.fitHints([
|
|
418
|
-
...(this.remote ?
|
|
422
|
+
...(this.remote || this.role !== 'controller' ? GRAPH_PRIMARY_READ_ONLY_HINTS : GRAPH_PRIMARY_LOCAL_HINTS),
|
|
419
423
|
...GRAPH_NAV_HINTS,
|
|
420
424
|
], Math.max(1, width - 5))}${RESET}`; // budget matches borderRow's span
|
|
421
425
|
// Frame the panel: title in the top border, hint in the bottom border, tree
|
|
@@ -555,13 +559,15 @@ export class GraphOverlay {
|
|
|
555
559
|
}
|
|
556
560
|
// Focus/revive/manager/close actions drive local lifecycle/focus (`crtr surface node focus` /
|
|
557
561
|
// `crtr node lifecycle revive` / `crtr node lifecycle close`) against
|
|
558
|
-
// `this.cursorId
|
|
559
|
-
//
|
|
560
|
-
// no-op in remote mode instead of shelling a local mutation command against
|
|
561
|
-
// a remote id. Their hints are omitted remotely.
|
|
562
|
+
// `this.cursorId`. A remote graph row has no relationship to this machine's
|
|
563
|
+
// canvas, and an observer is read-only, so neither may shell a local action.
|
|
562
564
|
if (this.matches('crtr.graph.focus', data)) {
|
|
563
565
|
if (this.remote)
|
|
564
566
|
return;
|
|
567
|
+
if (this.role !== 'controller') {
|
|
568
|
+
this.onNotice('Read-only observer attach — cannot focus nodes');
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
565
571
|
if (this.cursorId !== undefined)
|
|
566
572
|
this.focusTarget(this.cursorId);
|
|
567
573
|
this.close();
|
|
@@ -570,6 +576,10 @@ export class GraphOverlay {
|
|
|
570
576
|
if (this.matches('crtr.graph.revive', data)) {
|
|
571
577
|
if (this.remote)
|
|
572
578
|
return;
|
|
579
|
+
if (this.role !== 'controller') {
|
|
580
|
+
this.onNotice('Read-only observer attach — cannot revive nodes');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
573
583
|
const target = this.cursorId;
|
|
574
584
|
const node = target === undefined ? null : snapshot?.nodes.get(target) ?? null;
|
|
575
585
|
if (target !== undefined && node !== null && node.status !== 'active' && node.status !== 'idle') {
|
|
@@ -581,6 +591,10 @@ export class GraphOverlay {
|
|
|
581
591
|
if (this.matches('crtr.graph.focus-manager', data)) {
|
|
582
592
|
if (this.remote)
|
|
583
593
|
return;
|
|
594
|
+
if (this.role !== 'controller') {
|
|
595
|
+
this.onNotice('Read-only observer attach — cannot focus nodes');
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
584
598
|
const mgr = snapshot?.managerOf.get(this.self);
|
|
585
599
|
if (mgr !== undefined) {
|
|
586
600
|
this.focusTarget(mgr);
|
|
@@ -591,6 +605,10 @@ export class GraphOverlay {
|
|
|
591
605
|
if (this.matches('crtr.graph.close-subtree', data)) {
|
|
592
606
|
if (this.remote)
|
|
593
607
|
return;
|
|
608
|
+
if (this.role !== 'controller') {
|
|
609
|
+
this.onNotice('Read-only observer attach — cannot close nodes');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
594
612
|
const target = this.cursorId ?? this.self;
|
|
595
613
|
const n = snapshot?.nodes.get(target) ?? null;
|
|
596
614
|
const nm = n !== null ? fullName(n) : shortId(target);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Container, TUI } from '@earendil-works/pi-tui';
|
|
2
|
+
import type { ClientRole } from '../../../core/runtime/broker-protocol.js';
|
|
2
3
|
import type { CanvasSource } from '../../../core/canvas/source.js';
|
|
3
4
|
import type { NodeMeta } from '../../../core/canvas/types.js';
|
|
4
5
|
import type { BrokerClient } from '../../../core/broker-client/index.js';
|
|
@@ -31,6 +32,12 @@ export interface AttachSession {
|
|
|
31
32
|
/** THE single remote/read-only fact, derived once in `runAttach` from
|
|
32
33
|
* `--canvas` and threaded from here. No collaborator may re-derive it. */
|
|
33
34
|
readonly remote: boolean;
|
|
35
|
+
/** THE single capability fact, resolved once in `runAttach` by
|
|
36
|
+
* `resolveHelloRole(remote, observer)` and claimed in every hello (including
|
|
37
|
+
* reconnects), so it can never be renegotiated mid-session. `'observer'`
|
|
38
|
+
* makes this viewer read-only: no collaborator may emit a mutating frame or
|
|
39
|
+
* drive the node out of band. */
|
|
40
|
+
readonly role: ClientRole;
|
|
34
41
|
/** Viewer-LOCAL concerns only: `cwd` drives theming and this pane's own git
|
|
35
42
|
* status, and is the CLI's own cwd for a remote attach — never the remote
|
|
36
43
|
* node's directory. */
|
|
@@ -127,11 +127,6 @@ export function createFrameHandler(ctx, fx) {
|
|
|
127
127
|
fx.attachDialog(frame.pending_dialog);
|
|
128
128
|
break;
|
|
129
129
|
}
|
|
130
|
-
case 'control_changed':
|
|
131
|
-
// Our own role follows from matching the broker's controller_id to the
|
|
132
|
-
// client_id we sent in `hello` — the reducer does that, and the footer
|
|
133
|
-
// repaint rides the role slice.
|
|
134
|
-
break;
|
|
135
130
|
case 'model_changed': {
|
|
136
131
|
// The broker's own post-set_model/cycle_model announcement (pi emits no
|
|
137
132
|
// engine event for a model switch) already moved the footer's model
|
|
@@ -142,9 +137,7 @@ export function createFrameHandler(ctx, fx) {
|
|
|
142
137
|
break;
|
|
143
138
|
}
|
|
144
139
|
case 'error':
|
|
145
|
-
fx.setNotice(frame.code === '
|
|
146
|
-
? 'read-only — another viewer is the controller'
|
|
147
|
-
: `error: ${frame.message}`);
|
|
140
|
+
fx.setNotice(frame.code === 'read_only' ? 'read-only' : `error: ${frame.message}`);
|
|
148
141
|
break;
|
|
149
142
|
case 'ack':
|
|
150
143
|
handleAck(frame);
|
|
@@ -28,4 +28,4 @@ export interface InputWiringHooks {
|
|
|
28
28
|
setChipColor: (color: string | null) => void;
|
|
29
29
|
}
|
|
30
30
|
/** Build the viewer's InputController with every hook answered. */
|
|
31
|
-
export declare function createInputController(ctx: Pick<AttachSession, 'nodeId' | 'remote' | 'meta' | 'socket' | 'tui' | 'editor' | 'containers'>, km: KeybindingsManager, hooks: InputWiringHooks): InputController;
|
|
31
|
+
export declare function createInputController(ctx: Pick<AttachSession, 'nodeId' | 'remote' | 'role' | 'meta' | 'socket' | 'tui' | 'editor' | 'containers'>, km: KeybindingsManager, hooks: InputWiringHooks): InputController;
|
|
@@ -18,8 +18,13 @@ import { buildLoginPicker, buildLogoutPicker } from '../overlays/auth.js';
|
|
|
18
18
|
import { defaultAgentDir } from '../config.js';
|
|
19
19
|
/** Build the viewer's InputController with every hook answered. */
|
|
20
20
|
export function createInputController(ctx, km, hooks) {
|
|
21
|
-
const { nodeId, remote, socket, tui, editor, containers } = ctx;
|
|
21
|
+
const { nodeId, remote, role, socket, tui, editor, containers } = ctx;
|
|
22
22
|
const { setNotice } = hooks;
|
|
23
|
+
// The viewer's fixed capability, resolved once at hello: an `--observer`
|
|
24
|
+
// attach (and a remote one, which is always an observer) may read but never
|
|
25
|
+
// mutate. Every mutating hook below is wired only when this is true; the
|
|
26
|
+
// read-op channel (`onRequest`) and the read-only getters stay wired for all.
|
|
27
|
+
const writable = !remote && role === 'controller';
|
|
23
28
|
// `/copy` — the last assistant message (ChatView keeps it current from both
|
|
24
29
|
// the welcome snapshot and the live stream) → the system clipboard.
|
|
25
30
|
const copyLastAssistant = () => {
|
|
@@ -35,14 +40,15 @@ export function createInputController(ctx, km, hooks) {
|
|
|
35
40
|
// model), so they run entirely in this process and then tell the broker to
|
|
36
41
|
// reload with a `reload_auth` frame.
|
|
37
42
|
//
|
|
38
|
-
// A REMOTE attach (`--canvas`) disables both
|
|
39
|
-
//
|
|
40
|
-
// host for a remote node
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
43
|
+
// A read-only viewer wires NEITHER. A REMOTE attach (`--canvas`) disables both
|
|
44
|
+
// because they target the LOCAL `auth.json` via `defaultAgentDir()`, the wrong
|
|
45
|
+
// host for a remote node (Phase 3 review Major 1, 1d); an OBSERVER because
|
|
46
|
+
// switching credentials and telling the broker to reload them mutates the
|
|
47
|
+
// engine's auth, which a read-only viewer must not do. Unwired here means the
|
|
48
|
+
// slash context carries no picker at all, so `/login` and `/logout` report
|
|
49
|
+
// themselves unavailable rather than reaching a `reload_auth` frame.
|
|
44
50
|
const reloadAuth = () => socket.send({ type: 'reload_auth' });
|
|
45
|
-
const authPickers =
|
|
51
|
+
const authPickers = !writable
|
|
46
52
|
? {}
|
|
47
53
|
: {
|
|
48
54
|
openLoginPicker: () => {
|
|
@@ -64,6 +70,7 @@ export function createInputController(ctx, km, hooks) {
|
|
|
64
70
|
nodeId,
|
|
65
71
|
profileId: ctx.meta.profile_id ?? undefined,
|
|
66
72
|
remote,
|
|
73
|
+
role,
|
|
67
74
|
onGraph: hooks.toggleGraph,
|
|
68
75
|
onNodeMetadata: hooks.openNodeMetadata,
|
|
69
76
|
onQuit: hooks.quit,
|
|
@@ -93,10 +100,13 @@ export function createInputController(ctx, km, hooks) {
|
|
|
93
100
|
// Dormant-node input: the node has no live broker, so a submitted prompt
|
|
94
101
|
// cannot reach a socket. The reconnect supervisor reports dormancy and the
|
|
95
102
|
// text is delivered as a wake message (revive + deliver) instead, so the
|
|
96
|
-
// still-open input box just works.
|
|
97
|
-
// drive, leaves `onDormantSubmit` unwired, and
|
|
103
|
+
// still-open input box just works. Writable viewers only — a remote or
|
|
104
|
+
// observer attach cannot drive, leaves `onDormantSubmit` unwired, and
|
|
105
|
+
// prompts fall through. This is the one mutating path that never touches the
|
|
106
|
+
// broker (it goes to crtrd via `cliClient`), so leaving it wired for an
|
|
107
|
+
// observer would let a read-only viewer wake and message the node.
|
|
98
108
|
isDormant: hooks.isDormant,
|
|
99
|
-
onDormantSubmit:
|
|
109
|
+
onDormantSubmit: !writable ? undefined : (msgText) => {
|
|
100
110
|
void (async () => {
|
|
101
111
|
try {
|
|
102
112
|
// Text typed into a node's box IS the human talking to it — mark it
|
|
@@ -13,12 +13,13 @@ export declare function requestModeSwitch(cwd: string, nodeId: string, remote: b
|
|
|
13
13
|
* Owning both here is what keeps `mode` from being a mutable the key handler
|
|
14
14
|
* and the editor painter both reach into. */
|
|
15
15
|
export interface ModeBadge {
|
|
16
|
-
/** Advance to the next mode and repaint. A
|
|
17
|
-
*
|
|
16
|
+
/** Advance to the next mode and repaint. A read-only viewer no-ops entirely,
|
|
17
|
+
* badge included: a REMOTE attach has no remote-safe mode-switch protocol,
|
|
18
|
+
* and an OBSERVER may not change the node's mode at all. */
|
|
18
19
|
cycle: () => void;
|
|
19
20
|
}
|
|
20
21
|
/** Resolve the initial mode, paint the badge, and return the cycle verb. */
|
|
21
|
-
export declare function createModeBadge(ctx: Pick<AttachSession, 'nodeId' | 'remote' | 'meta' | 'tui' | 'editor'>): ModeBadge;
|
|
22
|
+
export declare function createModeBadge(ctx: Pick<AttachSession, 'nodeId' | 'remote' | 'role' | 'meta' | 'tui' | 'editor'>): ModeBadge;
|
|
22
23
|
/** The badge chip painted for each mode: spec is blue, plan is amber, normal is
|
|
23
24
|
* unstyled (no badge chrome when there is nothing to say). */
|
|
24
25
|
export declare function modeBadgeStyle(mode: Mode): (s: string) => string;
|
|
@@ -61,7 +61,7 @@ export function requestModeSwitch(cwd, nodeId, remote, mode, writeRequest = writ
|
|
|
61
61
|
}
|
|
62
62
|
/** Resolve the initial mode, paint the badge, and return the cycle verb. */
|
|
63
63
|
export function createModeBadge(ctx) {
|
|
64
|
-
const { nodeId, remote, meta, tui, editor } = ctx;
|
|
64
|
+
const { nodeId, remote, role, meta, tui, editor } = ctx;
|
|
65
65
|
let mode = resolveInitialMode(meta.cwd, nodeId, remote);
|
|
66
66
|
const paint = () => {
|
|
67
67
|
editor.mode = mode === 'normal' ? '' : mode;
|
|
@@ -70,6 +70,11 @@ export function createModeBadge(ctx) {
|
|
|
70
70
|
paint();
|
|
71
71
|
return {
|
|
72
72
|
cycle: () => {
|
|
73
|
+
// The mode request rewrites how the node's engine behaves, so it rides the
|
|
74
|
+
// session's fixed capability like every other mutation: an observer reads
|
|
75
|
+
// the badge the broker publishes and changes nothing.
|
|
76
|
+
if (role !== 'controller')
|
|
77
|
+
return;
|
|
73
78
|
const next = nextMode(mode);
|
|
74
79
|
if (!requestModeSwitch(meta.cwd, nodeId, remote, next))
|
|
75
80
|
return;
|
|
@@ -2,9 +2,9 @@ import type { ClientRole } from '../../../core/runtime/broker-protocol.js';
|
|
|
2
2
|
import type { BrokerToClient } from '../../../core/runtime/broker-protocol.js';
|
|
3
3
|
import type { AttachSession } from './context.js';
|
|
4
4
|
export interface ReconnectHooks {
|
|
5
|
-
/** This viewer's
|
|
6
|
-
role:
|
|
7
|
-
/** This viewer's stable client id, echoed by the broker
|
|
5
|
+
/** This viewer's fixed role, resolved from the original attach flags. */
|
|
6
|
+
role: ClientRole;
|
|
7
|
+
/** This viewer's stable client id, echoed by the broker in the hello. */
|
|
8
8
|
clientId: string;
|
|
9
9
|
/** Every broker frame, in wire order. */
|
|
10
10
|
onFrame: (frame: BrokerToClient) => void;
|
|
@@ -23,16 +23,15 @@ export function createReconnect(ctx, hooks) {
|
|
|
23
23
|
// `dead`/`canceled` keep the corpse.
|
|
24
24
|
let finalizedDone = false;
|
|
25
25
|
// The handshake the viewer sends on connect AND on every successful redial.
|
|
26
|
-
// After a broker refresh / crash-revive the fresh process has no
|
|
27
|
-
//
|
|
28
|
-
// snapshot repaints full scrollback/state.
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// new controller after a refresh — the demotion must survive the reconnect.
|
|
26
|
+
// After a broker refresh / crash-revive the fresh process has no memory of
|
|
27
|
+
// this client, so re-`hello` resumes the role chosen at the original attach
|
|
28
|
+
// and the `welcome` snapshot repaints full scrollback/state. The role is
|
|
29
|
+
// deliberately fixed for the lifetime of this viewer: local writable attaches
|
|
30
|
+
// stay writable, while observers stay observers across every redial.
|
|
32
31
|
const sendHello = () => {
|
|
33
32
|
socket.send({
|
|
34
33
|
type: 'hello',
|
|
35
|
-
role: hooks.role
|
|
34
|
+
role: hooks.role,
|
|
36
35
|
client_id: hooks.clientId,
|
|
37
36
|
term: { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 },
|
|
38
37
|
});
|
|
@@ -35,4 +35,4 @@ export interface SessionStateSync {
|
|
|
35
35
|
* callers fold first, then notice. `off` collapses to the bare model name. */
|
|
36
36
|
modelThinkingLabel: () => string | undefined;
|
|
37
37
|
}
|
|
38
|
-
export declare function createSessionStateSync(role: ClientRole,
|
|
38
|
+
export declare function createSessionStateSync(role: ClientRole, fx: StateSyncEffects): SessionStateSync;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// slice it leaves alone, so each repaint below is gated on a `!==` against the
|
|
13
13
|
// previous value. No change flags, no repaint-everything.
|
|
14
14
|
import { initialSessionState, reduce } from '../../../core/session-model/session-state.js';
|
|
15
|
-
export function createSessionStateSync(role,
|
|
15
|
+
export function createSessionStateSync(role, fx) {
|
|
16
16
|
let session = initialSessionState(role);
|
|
17
17
|
/** Repaint exactly the chrome whose SLICE of session state changed. */
|
|
18
18
|
const renderDelta = (prev) => {
|
|
@@ -46,7 +46,7 @@ export function createSessionStateSync(role, clientId, fx) {
|
|
|
46
46
|
state: () => session,
|
|
47
47
|
fold: (frame) => {
|
|
48
48
|
const prev = session;
|
|
49
|
-
session = reduce(session, frame
|
|
49
|
+
session = reduce(session, frame);
|
|
50
50
|
renderDelta(prev);
|
|
51
51
|
},
|
|
52
52
|
patchLocal: (patch) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AutocompleteItem, SlashCommand } from '@earendil-works/pi-tui';
|
|
2
|
-
import type { BrokerSnapshot, ClientToBroker } from '../../../core/runtime/broker-protocol.js';
|
|
2
|
+
import type { BrokerSnapshot, ClientRole, ClientToBroker } from '../../../core/runtime/broker-protocol.js';
|
|
3
3
|
/** Everything a slash handler needs: the frame sink, a notice sink, the latest
|
|
4
4
|
* engine state (for read-only commands like `/session`), and the cwd (for the
|
|
5
5
|
* default `/export` path). */
|
|
@@ -19,6 +19,13 @@ export interface SlashContext {
|
|
|
19
19
|
* notifies + no-ops them instead of executing (see `REMOTE_UNSAFE_CANVAS_NAMES`
|
|
20
20
|
* below). Defaults to `false` so every existing local caller is unaffected. */
|
|
21
21
|
remote?: boolean;
|
|
22
|
+
/** This viewer's FIXED capability, resolved once for the hello handshake
|
|
23
|
+
* (`resolveHelloRole`) and never renegotiated. `'observer'` (`--observer`,
|
|
24
|
+
* and every remote attach) makes the viewer read-only: the commands that
|
|
25
|
+
* drive the engine or mutate the node through the crtr CLI are refused with
|
|
26
|
+
* a notice, while the read-only/viewer-local ones stay live. Absent →
|
|
27
|
+
* `'controller'`, so every existing writable caller is unaffected. */
|
|
28
|
+
role?: ClientRole;
|
|
22
29
|
/** The canvas node this viewer is attached to — used by `/promote` (passed
|
|
23
30
|
* as `--node`). Falls back to `CRTR_NODE_ID` when absent; if neither is set
|
|
24
31
|
* `/promote` notifies and no-ops. (Unit Q wires this from `runAttach`.) */
|
|
@@ -76,6 +83,11 @@ export declare const CANVAS_SLASH_COMMANDS: ReadonlyArray<{
|
|
|
76
83
|
name: string;
|
|
77
84
|
description: string;
|
|
78
85
|
}>;
|
|
86
|
+
/** The same gate, entered from raw editor text: the InputController asks BEFORE
|
|
87
|
+
* dispatching so a refused command is reported without reaching command
|
|
88
|
+
* dispatch. Unrecognized (extension) commands answer `undefined` here — they
|
|
89
|
+
* ride the normal `prompt` path, which the controller's own drive gate covers. */
|
|
90
|
+
export declare function readOnlySlashRefusal(text: string, ctx: SlashContext): string | undefined;
|
|
79
91
|
/** Every leading-command name this viewer consumes ENTIRELY locally — the
|
|
80
92
|
* message never reaches the engine as prose, whether dispatched or blocked
|
|
81
93
|
* with a notice (a remote-unsafe canvas/builtin command still returns
|