@north-light/crouter 0.3.154 → 0.3.157
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/README.md +2 -1
- package/dist/api/client.d.ts +1 -4
- package/dist/api/client.js +0 -5
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +0 -15
- package/dist/api/routes.d.ts +0 -1
- package/dist/api/routes.js +0 -1
- package/dist/builtin-views/chat/core.mjs +51 -6
- package/dist/builtin-views/chat/tui.mjs +14 -6
- package/dist/builtin-views/chat/web.jsx +7 -2
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
- package/dist/clients/attach/chrome/roster.d.ts +1 -1
- package/dist/clients/attach/chrome/roster.js +1 -9
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +8 -23
- package/dist/clients/attach/input/controller.js +29 -59
- package/dist/clients/attach/overlays/dialogs.d.ts +2 -1
- package/dist/clients/attach/overlays/graph.d.ts +1 -4
- package/dist/clients/attach/overlays/graph.js +7 -25
- package/dist/clients/attach/session/context.d.ts +0 -7
- package/dist/clients/attach/session/frames.js +8 -1
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +11 -21
- package/dist/clients/attach/session/mode.d.ts +3 -4
- package/dist/clients/attach/session/mode.js +1 -6
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +7 -6
- 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 +1 -13
- package/dist/clients/attach/slash/dispatch.js +17 -65
- package/dist/clients/attach/viewer.js +523 -523
- package/dist/clients/web/web-client/shared/protocol.d.ts +3 -5
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/chat-view-reconnect.test.js +23 -44
- package/dist/core/__tests__/full/broker-attach-limits.test.js +36 -60
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +62 -121
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +26 -15
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -0
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +13 -9
- 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 +27 -23
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +11 -5
- package/dist/core/runtime/broker.d.ts +13 -21
- package/dist/core/runtime/broker.js +186 -151
- package/dist/core/runtime/interactive-deliver.js +5 -4
- package/dist/core/runtime/model-swap.d.ts +3 -2
- package/dist/core/runtime/model-swap.js +4 -3
- package/dist/core/runtime/node-read.d.ts +0 -20
- package/dist/core/runtime/node-read.js +1 -34
- 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 +6 -8
- package/dist/core/session-model/session-state.js +16 -6
- package/dist/daemon/api/handlers/nodes.js +1 -18
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/web-client/assets/index--SsQYcKu.js +79 -0
- package/dist/web-client/assets/{index-CpEl9LTS.css → index-DJhQZoAj.css} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v1.md +1 -1
- package/docs/compat/hearth-crtr-v2.md +1 -1
- package/docs/compat/hearth-crtr-v3.md +1 -1
- package/docs/compat/hearth-crtr-v4.md +3 -1
- package/docs/public-api.md +2 -2
- package/package.json +4 -4
- package/runtime.lock.json +2 -2
- package/dist/web-client/assets/index-BpyZGBhI.js +0 -79
- package/docs/compat/hearth-crtr-v5.md +0 -175
|
@@ -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
|
|
22
|
+
import { dispatchSlashCommand, isSlashCommand } 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: even though the
|
|
46
|
-
* these from an observer, the input layer must
|
|
47
|
-
* server-side rejection alone is not the contract.
|
|
39
|
+
* broker gates behind controller-only (`notController` in broker.ts) PLUS
|
|
40
|
+
* `request_control`/`release_control` (control-claim frames; no current UI
|
|
41
|
+
* path sends them, gated anyway for defense-in-depth) and `reload_auth`
|
|
42
|
+
* (open to any role server-side, but viewer-local login/logout are disabled
|
|
43
|
+
* entirely for remote below — this entry is belt-and-suspenders in case a
|
|
44
|
+
* future path routes it through onCommand). A REMOTE attach (`--canvas`) is
|
|
45
|
+
* a strictly VIEW-ONLY surface (Phase 3 review Major 1): even though the
|
|
46
|
+
* broker would itself reject these from an observer, the input layer must
|
|
47
|
+
* never even emit one — 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 remote 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 REMOTE_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', 'reload_auth',
|
|
60
|
+
'export', 'clone', 'share', 'request_control', 'release_control', 'reload_auth',
|
|
61
61
|
]);
|
|
62
62
|
export class InputController {
|
|
63
63
|
tui;
|
|
@@ -93,42 +93,22 @@ 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
|
-
}
|
|
107
96
|
/** Central choke point for every command frame the input layer emits (direct
|
|
108
|
-
* keybindings, emitDrive, and the slash-command/picker `send` sink).
|
|
109
|
-
*
|
|
110
|
-
* it. A writable local attach is byte-for-byte unchanged. */
|
|
97
|
+
* keybindings, emitDrive, and the slash-command/picker `send` sink). Local
|
|
98
|
+
* attach (`hooks.remote` false/absent) is byte-for-byte unchanged. */
|
|
111
99
|
emitCommand(frame) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return false;
|
|
100
|
+
if (this.hooks.remote === true && DRIVE_FRAME_TYPES.has(frame.type)) {
|
|
101
|
+
this.notify('Read-only remote attach — cannot drive the engine');
|
|
102
|
+
return;
|
|
116
103
|
}
|
|
117
104
|
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 });
|
|
125
105
|
}
|
|
126
106
|
/** Render extension UI requests from the broker. Blocking dialogs route their
|
|
127
107
|
* answer back to the broker; non-blocking notify requests become the viewer's
|
|
128
108
|
* normal notice line and NOTHING else — a notify must never tear down an
|
|
129
109
|
* unrelated blocking dialog whose broker request is still pending. A dialog is
|
|
130
|
-
* torn down only by (a) a NEW blocking dialog superseding it
|
|
131
|
-
* answer, or (c) a correlated {@link dismissDialog}
|
|
110
|
+
* torn down only by (a) a NEW blocking dialog superseding it (control handoff
|
|
111
|
+
* re-route), (b) its own answer, or (c) a correlated {@link dismissDialog}
|
|
132
112
|
* from the broker (the request was aborted/timed out out-of-band). */
|
|
133
113
|
attachDialog(req) {
|
|
134
114
|
if (req.method === 'notify') {
|
|
@@ -238,7 +218,6 @@ export class InputController {
|
|
|
238
218
|
nodeId: this.hooks.nodeId,
|
|
239
219
|
profileId: this.hooks.profileId,
|
|
240
220
|
remote: this.hooks.remote,
|
|
241
|
-
role: this.hooks.role,
|
|
242
221
|
onGraph: this.hooks.onGraph,
|
|
243
222
|
onQuit: this.hooks.onQuit,
|
|
244
223
|
onCopy: this.hooks.onCopy,
|
|
@@ -262,9 +241,7 @@ export class InputController {
|
|
|
262
241
|
// (via slashContext) AND by keybinding (onAction, below).
|
|
263
242
|
// -------------------------------------------------------------------------
|
|
264
243
|
/** Send a command frame to the broker (picker selection sink). */
|
|
265
|
-
send = (frame) =>
|
|
266
|
-
this.emitCommand(frame);
|
|
267
|
-
};
|
|
244
|
+
send = (frame) => this.emitCommand(frame);
|
|
268
245
|
/** Fetch a picker payload (narrowed on `kind`), then build + show its overlay
|
|
269
246
|
* iff this open is still the latest (m4: a stale slow reply is dropped). Any
|
|
270
247
|
* failure — unwired channel, wrong kind, request error, or a throwing builder
|
|
@@ -449,17 +426,9 @@ export class InputController {
|
|
|
449
426
|
if (!trimmed)
|
|
450
427
|
return;
|
|
451
428
|
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
|
-
}
|
|
460
429
|
// Recognized builtin/scoped-out → handled here; unrecognized falls through
|
|
461
430
|
// and is sent to the engine as a prompt (extension command).
|
|
462
|
-
if (dispatchSlashCommand(trimmed,
|
|
431
|
+
if (dispatchSlashCommand(trimmed, this.slashContext())) {
|
|
463
432
|
this.editor.setText('');
|
|
464
433
|
return;
|
|
465
434
|
}
|
|
@@ -510,7 +479,7 @@ export class InputController {
|
|
|
510
479
|
? { type: 'steer', text: expanded }
|
|
511
480
|
: { type: 'prompt', text: expanded };
|
|
512
481
|
if (!this.emitDrive(frame))
|
|
513
|
-
return; //
|
|
482
|
+
return; // too large — keep editor to trim
|
|
514
483
|
this.editor.addToHistory(trimmed);
|
|
515
484
|
this.editor.setText('');
|
|
516
485
|
this.clearPastedImages();
|
|
@@ -598,9 +567,8 @@ export class InputController {
|
|
|
598
567
|
* (pi's `restoreQueuedMessagesToEditor`: queued text first, joined by blank
|
|
599
568
|
* lines). A read-AND-mutate op over the correlated request channel. */
|
|
600
569
|
async handleDequeue() {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
this.notify(`${reason} — cannot restore queued messages`);
|
|
570
|
+
if (this.hooks.remote === true) {
|
|
571
|
+
this.notify('Read-only remote attach — cannot restore queued messages');
|
|
604
572
|
return;
|
|
605
573
|
}
|
|
606
574
|
if (this.hooks.onRequest === undefined) {
|
|
@@ -631,7 +599,8 @@ export class InputController {
|
|
|
631
599
|
}
|
|
632
600
|
/** Send a drive frame iff the WHOLE encoded frame fits under MAX_FRAME_BYTES
|
|
633
601
|
* (the broker destroys the socket on any line over its 24 MiB read cap). Over
|
|
634
|
-
* the ceiling → notify + refuse
|
|
602
|
+
* the ceiling → notify + refuse so the caller leaves the editor + pending
|
|
603
|
+
* images intact for the user to trim, never a socket-destroying overflow. */
|
|
635
604
|
emitDrive(frame) {
|
|
636
605
|
let bytes;
|
|
637
606
|
try {
|
|
@@ -646,7 +615,8 @@ export class InputController {
|
|
|
646
615
|
this.notify(`Message too large to send (${mib} MiB) — shorten the text or remove an attached image`);
|
|
647
616
|
return false;
|
|
648
617
|
}
|
|
649
|
-
|
|
618
|
+
this.emitCommand(frame);
|
|
619
|
+
return true;
|
|
650
620
|
}
|
|
651
621
|
async handlePaste() {
|
|
652
622
|
try {
|
|
@@ -1,7 +1,8 @@
|
|
|
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
|
|
4
|
+
* request is superseded — e.g. the broker resolves it on its own timeout, or a
|
|
5
|
+
* control handoff re-routes it. */
|
|
5
6
|
export interface DialogHandle {
|
|
6
7
|
dismiss(): void;
|
|
7
8
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
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';
|
|
5
4
|
import type { AttachPalette } from '../config.js';
|
|
6
5
|
export declare class GraphOverlay implements Component {
|
|
7
6
|
private readonly tui;
|
|
@@ -10,9 +9,7 @@ export declare class GraphOverlay implements Component {
|
|
|
10
9
|
private readonly palette;
|
|
11
10
|
private bindings;
|
|
12
11
|
private readonly source;
|
|
13
|
-
private readonly role;
|
|
14
12
|
private readonly onRefreshFailure;
|
|
15
|
-
private readonly onNotice;
|
|
16
13
|
private handle;
|
|
17
14
|
/** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
|
|
18
15
|
* both override the default activity-driven policy and survive open/close. */
|
|
@@ -32,7 +29,7 @@ export declare class GraphOverlay implements Component {
|
|
|
32
29
|
* default and makes Enter/r/m/x no-op instead of shelling local lifecycle
|
|
33
30
|
* commands against a remote graph row. See the file header. */
|
|
34
31
|
private readonly remote;
|
|
35
|
-
constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, bindings: BindingResolution<BindingId>, source: CanvasSource,
|
|
32
|
+
constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, bindings: BindingResolution<BindingId>, source: CanvasSource, onRefreshFailure?: (cause?: unknown) => void);
|
|
36
33
|
/** The tmux-focus set for `isAttached`, fetched via crtrd (was nav-model's
|
|
37
34
|
* focusedNodeIds → listFocuses db read). Empty for a remote source (a remote
|
|
38
35
|
* 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_REMOTE_HINTS = [
|
|
63
63
|
['crtr.graph.dismiss', 'dismiss'],
|
|
64
64
|
];
|
|
65
65
|
const GRAPH_NAV_HINTS = [
|
|
@@ -77,9 +77,7 @@ export class GraphOverlay {
|
|
|
77
77
|
palette;
|
|
78
78
|
bindings;
|
|
79
79
|
source;
|
|
80
|
-
role;
|
|
81
80
|
onRefreshFailure;
|
|
82
|
-
onNotice;
|
|
83
81
|
handle;
|
|
84
82
|
/** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
|
|
85
83
|
* both override the default activity-driven policy and survive open/close. */
|
|
@@ -99,16 +97,14 @@ export class GraphOverlay {
|
|
|
99
97
|
* default and makes Enter/r/m/x no-op instead of shelling local lifecycle
|
|
100
98
|
* commands against a remote graph row. See the file header. */
|
|
101
99
|
remote;
|
|
102
|
-
constructor(tui, self, getAsks, palette, bindings, source,
|
|
100
|
+
constructor(tui, self, getAsks, palette, bindings, source, onRefreshFailure = () => { }) {
|
|
103
101
|
this.tui = tui;
|
|
104
102
|
this.self = self;
|
|
105
103
|
this.getAsks = getAsks;
|
|
106
104
|
this.palette = palette;
|
|
107
105
|
this.bindings = bindings;
|
|
108
106
|
this.source = source;
|
|
109
|
-
this.role = role;
|
|
110
107
|
this.onRefreshFailure = onRefreshFailure;
|
|
111
|
-
this.onNotice = onNotice;
|
|
112
108
|
this.remote = source instanceof RemoteCanvasSource;
|
|
113
109
|
}
|
|
114
110
|
/** The tmux-focus set for `isAttached`, fetched via crtrd (was nav-model's
|
|
@@ -419,7 +415,7 @@ export class GraphOverlay {
|
|
|
419
415
|
['crtr.graph.confirm.cancel', 'cancel'],
|
|
420
416
|
]).join(' · ')}${RESET}`
|
|
421
417
|
: `${DIM}${this.fitHints([
|
|
422
|
-
...(this.remote
|
|
418
|
+
...(this.remote ? GRAPH_PRIMARY_REMOTE_HINTS : GRAPH_PRIMARY_LOCAL_HINTS),
|
|
423
419
|
...GRAPH_NAV_HINTS,
|
|
424
420
|
], Math.max(1, width - 5))}${RESET}`; // budget matches borderRow's span
|
|
425
421
|
// Frame the panel: title in the top border, hint in the bottom border, tree
|
|
@@ -559,15 +555,13 @@ export class GraphOverlay {
|
|
|
559
555
|
}
|
|
560
556
|
// Focus/revive/manager/close actions drive local lifecycle/focus (`crtr surface node focus` /
|
|
561
557
|
// `crtr node lifecycle revive` / `crtr node lifecycle close`) against
|
|
562
|
-
// `this.cursorId
|
|
563
|
-
// canvas,
|
|
558
|
+
// `this.cursorId` — a REMOTE graph row's id has no relationship to this
|
|
559
|
+
// machine's canvas.db (or, worse, collides with one that does), so all four
|
|
560
|
+
// no-op in remote mode instead of shelling a local mutation command against
|
|
561
|
+
// a remote id. Their hints are omitted remotely.
|
|
564
562
|
if (this.matches('crtr.graph.focus', data)) {
|
|
565
563
|
if (this.remote)
|
|
566
564
|
return;
|
|
567
|
-
if (this.role !== 'controller') {
|
|
568
|
-
this.onNotice('Read-only observer attach — cannot focus nodes');
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
565
|
if (this.cursorId !== undefined)
|
|
572
566
|
this.focusTarget(this.cursorId);
|
|
573
567
|
this.close();
|
|
@@ -576,10 +570,6 @@ export class GraphOverlay {
|
|
|
576
570
|
if (this.matches('crtr.graph.revive', data)) {
|
|
577
571
|
if (this.remote)
|
|
578
572
|
return;
|
|
579
|
-
if (this.role !== 'controller') {
|
|
580
|
-
this.onNotice('Read-only observer attach — cannot revive nodes');
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
573
|
const target = this.cursorId;
|
|
584
574
|
const node = target === undefined ? null : snapshot?.nodes.get(target) ?? null;
|
|
585
575
|
if (target !== undefined && node !== null && node.status !== 'active' && node.status !== 'idle') {
|
|
@@ -591,10 +581,6 @@ export class GraphOverlay {
|
|
|
591
581
|
if (this.matches('crtr.graph.focus-manager', data)) {
|
|
592
582
|
if (this.remote)
|
|
593
583
|
return;
|
|
594
|
-
if (this.role !== 'controller') {
|
|
595
|
-
this.onNotice('Read-only observer attach — cannot focus nodes');
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
584
|
const mgr = snapshot?.managerOf.get(this.self);
|
|
599
585
|
if (mgr !== undefined) {
|
|
600
586
|
this.focusTarget(mgr);
|
|
@@ -605,10 +591,6 @@ export class GraphOverlay {
|
|
|
605
591
|
if (this.matches('crtr.graph.close-subtree', data)) {
|
|
606
592
|
if (this.remote)
|
|
607
593
|
return;
|
|
608
|
-
if (this.role !== 'controller') {
|
|
609
|
-
this.onNotice('Read-only observer attach — cannot close nodes');
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
594
|
const target = this.cursorId ?? this.self;
|
|
613
595
|
const n = snapshot?.nodes.get(target) ?? null;
|
|
614
596
|
const nm = n !== null ? fullName(n) : shortId(target);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Container, TUI } from '@earendil-works/pi-tui';
|
|
2
|
-
import type { ClientRole } from '../../../core/runtime/broker-protocol.js';
|
|
3
2
|
import type { CanvasSource } from '../../../core/canvas/source.js';
|
|
4
3
|
import type { NodeMeta } from '../../../core/canvas/types.js';
|
|
5
4
|
import type { BrokerClient } from '../../../core/broker-client/index.js';
|
|
@@ -32,12 +31,6 @@ export interface AttachSession {
|
|
|
32
31
|
/** THE single remote/read-only fact, derived once in `runAttach` from
|
|
33
32
|
* `--canvas` and threaded from here. No collaborator may re-derive it. */
|
|
34
33
|
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;
|
|
41
34
|
/** Viewer-LOCAL concerns only: `cwd` drives theming and this pane's own git
|
|
42
35
|
* status, and is the CLI's own cwd for a remote attach — never the remote
|
|
43
36
|
* node's directory. */
|
|
@@ -127,6 +127,11 @@ 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;
|
|
130
135
|
case 'model_changed': {
|
|
131
136
|
// The broker's own post-set_model/cycle_model announcement (pi emits no
|
|
132
137
|
// engine event for a model switch) already moved the footer's model
|
|
@@ -137,7 +142,9 @@ export function createFrameHandler(ctx, fx) {
|
|
|
137
142
|
break;
|
|
138
143
|
}
|
|
139
144
|
case 'error':
|
|
140
|
-
fx.setNotice(frame.code === '
|
|
145
|
+
fx.setNotice(frame.code === 'not_controller'
|
|
146
|
+
? 'read-only — another viewer is the controller'
|
|
147
|
+
: `error: ${frame.message}`);
|
|
141
148
|
break;
|
|
142
149
|
case 'ack':
|
|
143
150
|
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' | '
|
|
31
|
+
export declare function createInputController(ctx: Pick<AttachSession, 'nodeId' | 'remote' | 'meta' | 'socket' | 'tui' | 'editor' | 'containers'>, km: KeybindingsManager, hooks: InputWiringHooks): InputController;
|
|
@@ -18,13 +18,8 @@ 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,
|
|
21
|
+
const { nodeId, remote, 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';
|
|
28
23
|
// `/copy` — the last assistant message (ChatView keeps it current from both
|
|
29
24
|
// the welcome snapshot and the live stream) → the system clipboard.
|
|
30
25
|
const copyLastAssistant = () => {
|
|
@@ -40,15 +35,14 @@ export function createInputController(ctx, km, hooks) {
|
|
|
40
35
|
// model), so they run entirely in this process and then tell the broker to
|
|
41
36
|
// reload with a `reload_auth` frame.
|
|
42
37
|
//
|
|
43
|
-
// A
|
|
44
|
-
//
|
|
45
|
-
// host for a remote node
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// themselves unavailable rather than reaching a `reload_auth` frame.
|
|
38
|
+
// A REMOTE attach (`--canvas`) disables both entirely (Phase 3 review Major 1,
|
|
39
|
+
// 1d): they target the LOCAL `auth.json` via `defaultAgentDir()`, the wrong
|
|
40
|
+
// host for a remote node. The read-only-command gate (slash/dispatch.ts)
|
|
41
|
+
// already blocks `/login` and `/logout` before dispatch — leaving these
|
|
42
|
+
// unwired is belt-and-suspenders, so there is no path to a `reload_auth`
|
|
43
|
+
// frame for remote even if something else called the hooks directly.
|
|
50
44
|
const reloadAuth = () => socket.send({ type: 'reload_auth' });
|
|
51
|
-
const authPickers =
|
|
45
|
+
const authPickers = remote
|
|
52
46
|
? {}
|
|
53
47
|
: {
|
|
54
48
|
openLoginPicker: () => {
|
|
@@ -70,7 +64,6 @@ export function createInputController(ctx, km, hooks) {
|
|
|
70
64
|
nodeId,
|
|
71
65
|
profileId: ctx.meta.profile_id ?? undefined,
|
|
72
66
|
remote,
|
|
73
|
-
role,
|
|
74
67
|
onGraph: hooks.toggleGraph,
|
|
75
68
|
onNodeMetadata: hooks.openNodeMetadata,
|
|
76
69
|
onQuit: hooks.quit,
|
|
@@ -100,13 +93,10 @@ export function createInputController(ctx, km, hooks) {
|
|
|
100
93
|
// Dormant-node input: the node has no live broker, so a submitted prompt
|
|
101
94
|
// cannot reach a socket. The reconnect supervisor reports dormancy and the
|
|
102
95
|
// text is delivered as a wake message (revive + deliver) instead, so the
|
|
103
|
-
// still-open input box just works.
|
|
104
|
-
//
|
|
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.
|
|
96
|
+
// still-open input box just works. Local only — a remote attach cannot
|
|
97
|
+
// drive, leaves `onDormantSubmit` unwired, and prompts fall through.
|
|
108
98
|
isDormant: hooks.isDormant,
|
|
109
|
-
onDormantSubmit:
|
|
99
|
+
onDormantSubmit: remote ? undefined : (msgText) => {
|
|
110
100
|
void (async () => {
|
|
111
101
|
try {
|
|
112
102
|
// Text typed into a node's box IS the human talking to it — mark it
|
|
@@ -13,13 +13,12 @@ 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
|
-
*
|
|
18
|
-
* and an OBSERVER may not change the node's mode at all. */
|
|
16
|
+
/** Advance to the next mode and repaint. A REMOTE attach no-ops entirely
|
|
17
|
+
* (there is no remote-safe mode-switch protocol), badge included. */
|
|
19
18
|
cycle: () => void;
|
|
20
19
|
}
|
|
21
20
|
/** Resolve the initial mode, paint the badge, and return the cycle verb. */
|
|
22
|
-
export declare function createModeBadge(ctx: Pick<AttachSession, 'nodeId' | 'remote' | '
|
|
21
|
+
export declare function createModeBadge(ctx: Pick<AttachSession, 'nodeId' | 'remote' | 'meta' | 'tui' | 'editor'>): ModeBadge;
|
|
23
22
|
/** The badge chip painted for each mode: spec is blue, plan is amber, normal is
|
|
24
23
|
* unstyled (no badge chrome when there is nothing to say). */
|
|
25
24
|
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,
|
|
64
|
+
const { nodeId, remote, 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,11 +70,6 @@ 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;
|
|
78
73
|
const next = nextMode(mode);
|
|
79
74
|
if (!requestModeSwitch(meta.cwd, nodeId, remote, next))
|
|
80
75
|
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: ClientRole;
|
|
7
|
-
/** This viewer's stable client id, echoed by the broker
|
|
5
|
+
/** This viewer's CURRENT role, read at send time — see `sendHello`. */
|
|
6
|
+
role: () => ClientRole;
|
|
7
|
+
/** This viewer's stable client id, echoed by the broker to prove control. */
|
|
8
8
|
clientId: string;
|
|
9
9
|
/** Every broker frame, in wire order. */
|
|
10
10
|
onFrame: (frame: BrokerToClient) => void;
|
|
@@ -23,15 +23,16 @@ 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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
26
|
+
// After a broker refresh / crash-revive the fresh process has no controller
|
|
27
|
+
// and no memory of us, so re-`hello` reclaims our role and the `welcome`
|
|
28
|
+
// snapshot repaints full scrollback/state. Sends the CURRENT role, not the
|
|
29
|
+
// `--observer` flag we started with: if a `control_changed` demoted us at
|
|
30
|
+
// runtime, re-hello'ing as 'controller' would let us reclaim control from the
|
|
31
|
+
// new controller after a refresh — the demotion must survive the reconnect.
|
|
31
32
|
const sendHello = () => {
|
|
32
33
|
socket.send({
|
|
33
34
|
type: 'hello',
|
|
34
|
-
role: hooks.role,
|
|
35
|
+
role: hooks.role(),
|
|
35
36
|
client_id: hooks.clientId,
|
|
36
37
|
term: { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 },
|
|
37
38
|
});
|
|
@@ -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, fx: StateSyncEffects): SessionStateSync;
|
|
38
|
+
export declare function createSessionStateSync(role: ClientRole, clientId: string, 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, fx) {
|
|
15
|
+
export function createSessionStateSync(role, clientId, 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, fx) {
|
|
|
46
46
|
state: () => session,
|
|
47
47
|
fold: (frame) => {
|
|
48
48
|
const prev = session;
|
|
49
|
-
session = reduce(session, frame);
|
|
49
|
+
session = reduce(session, frame, clientId);
|
|
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,
|
|
2
|
+
import type { BrokerSnapshot, 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,13 +19,6 @@ 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;
|
|
29
22
|
/** The canvas node this viewer is attached to — used by `/promote` (passed
|
|
30
23
|
* as `--node`). Falls back to `CRTR_NODE_ID` when absent; if neither is set
|
|
31
24
|
* `/promote` notifies and no-ops. (Unit Q wires this from `runAttach`.) */
|
|
@@ -83,11 +76,6 @@ export declare const CANVAS_SLASH_COMMANDS: ReadonlyArray<{
|
|
|
83
76
|
name: string;
|
|
84
77
|
description: string;
|
|
85
78
|
}>;
|
|
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;
|
|
91
79
|
/** Every leading-command name this viewer consumes ENTIRELY locally — the
|
|
92
80
|
* message never reaches the engine as prose, whether dispatched or blocked
|
|
93
81
|
* with a notice (a remote-unsafe canvas/builtin command still returns
|