@north-light/crouter 0.3.157 → 0.3.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/api/client.d.ts +4 -1
  2. package/dist/api/client.js +5 -0
  3. package/dist/api/dto/broker.d.ts +1 -1
  4. package/dist/api/dto/nodes.d.ts +15 -0
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-views/chat/core.mjs +6 -51
  8. package/dist/builtin-views/chat/tui.mjs +6 -14
  9. package/dist/builtin-views/chat/web.jsx +2 -7
  10. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
  11. package/dist/clients/attach/chrome/roster.d.ts +1 -1
  12. package/dist/clients/attach/chrome/roster.js +9 -1
  13. package/dist/clients/attach/command.js +5 -5
  14. package/dist/clients/attach/input/controller.d.ts +23 -8
  15. package/dist/clients/attach/input/controller.js +59 -29
  16. package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
  17. package/dist/clients/attach/overlays/graph.d.ts +4 -1
  18. package/dist/clients/attach/overlays/graph.js +25 -7
  19. package/dist/clients/attach/session/context.d.ts +7 -0
  20. package/dist/clients/attach/session/frames.js +1 -8
  21. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  22. package/dist/clients/attach/session/input-wiring.js +21 -11
  23. package/dist/clients/attach/session/mode.d.ts +4 -3
  24. package/dist/clients/attach/session/mode.js +6 -1
  25. package/dist/clients/attach/session/reconnect.d.ts +3 -3
  26. package/dist/clients/attach/session/reconnect.js +6 -7
  27. package/dist/clients/attach/session/state-sync.d.ts +1 -1
  28. package/dist/clients/attach/session/state-sync.js +2 -2
  29. package/dist/clients/attach/slash/dispatch.d.ts +13 -1
  30. package/dist/clients/attach/slash/dispatch.js +65 -17
  31. package/dist/clients/attach/viewer.js +523 -523
  32. package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
  33. package/dist/commands/node.js +1 -1
  34. package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
  35. package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
  36. package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
  37. package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
  38. package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
  39. package/dist/core/__tests__/helpers/broker-clients.js +2 -2
  40. package/dist/core/__tests__/session-model.test.js +15 -26
  41. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  42. package/dist/core/keybindings/catalog.d.ts +2 -2
  43. package/dist/core/keybindings/catalog.js +0 -2
  44. package/dist/core/runtime/auth-reload.d.ts +4 -4
  45. package/dist/core/runtime/auth-reload.js +9 -13
  46. package/dist/core/runtime/boot-root.d.ts +2 -2
  47. package/dist/core/runtime/boot-root.js +7 -7
  48. package/dist/core/runtime/broker-protocol.d.ts +23 -27
  49. package/dist/core/runtime/broker-protocol.js +1 -1
  50. package/dist/core/runtime/broker-request.js +5 -11
  51. package/dist/core/runtime/broker.d.ts +21 -13
  52. package/dist/core/runtime/broker.js +151 -186
  53. package/dist/core/runtime/interactive-deliver.js +4 -5
  54. package/dist/core/runtime/model-swap.d.ts +2 -3
  55. package/dist/core/runtime/model-swap.js +3 -4
  56. package/dist/core/runtime/node-read.d.ts +20 -0
  57. package/dist/core/runtime/node-read.js +34 -1
  58. package/dist/core/runtime/resume-root.d.ts +1 -1
  59. package/dist/core/runtime/resume-root.js +6 -6
  60. package/dist/core/runtime/spawn.js +3 -3
  61. package/dist/core/session-model/session-state.d.ts +8 -6
  62. package/dist/core/session-model/session-state.js +6 -16
  63. package/dist/daemon/api/handlers/nodes.js +18 -1
  64. package/dist/daemon/manage.js +2 -2
  65. package/dist/index.d.ts +1 -1
  66. package/dist/pi-extensions/canvas-bash-valve.js +4 -3
  67. package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
  68. package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
  69. package/dist/web-client/index.html +2 -2
  70. package/dist/web-client/sw.js +1 -1
  71. package/docs/compat/hearth-crtr-v5.md +175 -0
  72. package/docs/public-api.md +2 -2
  73. package/package.json +2 -2
  74. package/runtime.lock.json +6 -6
  75. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
  76. 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 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.
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 remote guard). Read-only frames (get_commands,
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 REMOTE_SAFE_BUILTIN_NAMES gate). */
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', 'request_control', 'release_control', 'reload_auth',
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). Local
98
- * attach (`hooks.remote` false/absent) is byte-for-byte unchanged. */
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
- if (this.hooks.remote === true && DRIVE_FRAME_TYPES.has(frame.type)) {
101
- this.notify('Read-only remote attach — cannot drive the engine');
102
- return;
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 (control handoff
111
- * re-route), (b) its own answer, or (c) a correlated {@link dismissDialog}
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) => this.emitCommand(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, this.slashContext())) {
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 — keep editor to trim
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
- if (this.hooks.remote === true) {
571
- this.notify('Read-only remote attach — cannot restore queued messages');
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 so the caller leaves the editor + pending
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 — e.g. the broker resolves it on its own timeout, or a
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 GRAPH_PRIMARY_REMOTE_HINTS = [
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 ? GRAPH_PRIMARY_REMOTE_HINTS : GRAPH_PRIMARY_LOCAL_HINTS),
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` — 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.
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 === 'not_controller'
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 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.
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 = remote
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. Local only — a remote attach cannot
97
- // drive, leaves `onDormantSubmit` unwired, and prompts fall through.
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: remote ? undefined : (msgText) => {
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 REMOTE attach no-ops entirely
17
- * (there is no remote-safe mode-switch protocol), badge included. */
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 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. */
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 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.
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, clientId: string, fx: StateSyncEffects): SessionStateSync;
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, clientId, fx) {
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, clientId);
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