@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.
Files changed (80) hide show
  1. package/README.md +2 -1
  2. package/dist/api/client.d.ts +1 -4
  3. package/dist/api/client.js +0 -5
  4. package/dist/api/dto/broker.d.ts +1 -1
  5. package/dist/api/dto/nodes.d.ts +0 -15
  6. package/dist/api/routes.d.ts +0 -1
  7. package/dist/api/routes.js +0 -1
  8. package/dist/builtin-views/chat/core.mjs +51 -6
  9. package/dist/builtin-views/chat/tui.mjs +14 -6
  10. package/dist/builtin-views/chat/web.jsx +7 -2
  11. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
  12. package/dist/clients/attach/chrome/roster.d.ts +1 -1
  13. package/dist/clients/attach/chrome/roster.js +1 -9
  14. package/dist/clients/attach/command.js +5 -5
  15. package/dist/clients/attach/input/controller.d.ts +8 -23
  16. package/dist/clients/attach/input/controller.js +29 -59
  17. package/dist/clients/attach/overlays/dialogs.d.ts +2 -1
  18. package/dist/clients/attach/overlays/graph.d.ts +1 -4
  19. package/dist/clients/attach/overlays/graph.js +7 -25
  20. package/dist/clients/attach/session/context.d.ts +0 -7
  21. package/dist/clients/attach/session/frames.js +8 -1
  22. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  23. package/dist/clients/attach/session/input-wiring.js +11 -21
  24. package/dist/clients/attach/session/mode.d.ts +3 -4
  25. package/dist/clients/attach/session/mode.js +1 -6
  26. package/dist/clients/attach/session/reconnect.d.ts +3 -3
  27. package/dist/clients/attach/session/reconnect.js +7 -6
  28. package/dist/clients/attach/session/state-sync.d.ts +1 -1
  29. package/dist/clients/attach/session/state-sync.js +2 -2
  30. package/dist/clients/attach/slash/dispatch.d.ts +1 -13
  31. package/dist/clients/attach/slash/dispatch.js +17 -65
  32. package/dist/clients/attach/viewer.js +523 -523
  33. package/dist/clients/web/web-client/shared/protocol.d.ts +3 -5
  34. package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
  35. package/dist/core/__tests__/chat-view-reconnect.test.js +23 -44
  36. package/dist/core/__tests__/full/broker-attach-limits.test.js +36 -60
  37. package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
  38. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  39. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  40. package/dist/core/__tests__/full/broker-dialogs.test.js +62 -121
  41. package/dist/core/__tests__/helpers/broker-clients.js +2 -2
  42. package/dist/core/__tests__/session-model.test.js +26 -15
  43. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  44. package/dist/core/keybindings/catalog.d.ts +2 -2
  45. package/dist/core/keybindings/catalog.js +2 -0
  46. package/dist/core/runtime/auth-reload.d.ts +4 -4
  47. package/dist/core/runtime/auth-reload.js +13 -9
  48. package/dist/core/runtime/boot-root.d.ts +2 -2
  49. package/dist/core/runtime/boot-root.js +7 -7
  50. package/dist/core/runtime/broker-protocol.d.ts +27 -23
  51. package/dist/core/runtime/broker-protocol.js +1 -1
  52. package/dist/core/runtime/broker-request.js +11 -5
  53. package/dist/core/runtime/broker.d.ts +13 -21
  54. package/dist/core/runtime/broker.js +186 -151
  55. package/dist/core/runtime/interactive-deliver.js +5 -4
  56. package/dist/core/runtime/model-swap.d.ts +3 -2
  57. package/dist/core/runtime/model-swap.js +4 -3
  58. package/dist/core/runtime/node-read.d.ts +0 -20
  59. package/dist/core/runtime/node-read.js +1 -34
  60. package/dist/core/runtime/resume-root.d.ts +1 -1
  61. package/dist/core/runtime/resume-root.js +6 -6
  62. package/dist/core/runtime/spawn.js +3 -3
  63. package/dist/core/session-model/session-state.d.ts +6 -8
  64. package/dist/core/session-model/session-state.js +16 -6
  65. package/dist/daemon/api/handlers/nodes.js +1 -18
  66. package/dist/daemon/manage.js +2 -2
  67. package/dist/index.d.ts +1 -1
  68. package/dist/web-client/assets/index--SsQYcKu.js +79 -0
  69. package/dist/web-client/assets/{index-CpEl9LTS.css → index-DJhQZoAj.css} +1 -1
  70. package/dist/web-client/index.html +2 -2
  71. package/dist/web-client/sw.js +1 -1
  72. package/docs/compat/hearth-crtr-v1.md +1 -1
  73. package/docs/compat/hearth-crtr-v2.md +1 -1
  74. package/docs/compat/hearth-crtr-v3.md +1 -1
  75. package/docs/compat/hearth-crtr-v4.md +3 -1
  76. package/docs/public-api.md +2 -2
  77. package/package.json +4 -4
  78. package/runtime.lock.json +2 -2
  79. package/dist/web-client/assets/index-BpyZGBhI.js +0 -79
  80. 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, readOnlySlashRefusal } from '../slash/dispatch.js';
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 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.
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 capability guard). Read-only frames (get_commands,
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 READ_ONLY_SAFE_BUILTIN_NAMES gate). */
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). 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. */
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
- 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;
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, (b) its own
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, slashCtx)) {
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; // refused or too large — no frame emitted
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
- const reason = this.readOnlyReason();
602
- if (reason !== undefined) {
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 without sending a socket-destroying overflow. */
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
- return this.emitCommand(frame);
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, answered, or dismissed by the broker. */
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, role?: ClientRole, onRefreshFailure?: (cause?: unknown) => void, onNotice?: (message: string) => void);
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 GRAPH_PRIMARY_READ_ONLY_HINTS = [
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, role = 'controller', onRefreshFailure = () => { }, onNotice = () => { }) {
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 || this.role !== 'controller' ? GRAPH_PRIMARY_READ_ONLY_HINTS : GRAPH_PRIMARY_LOCAL_HINTS),
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`. 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.
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 === 'read_only' ? 'read-only' : `error: ${frame.message}`);
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' | 'role' | 'meta' | 'socket' | 'tui' | 'editor' | 'containers'>, km: KeybindingsManager, hooks: InputWiringHooks): InputController;
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, role, socket, tui, editor, containers } = ctx;
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 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.
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 = !writable
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. 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.
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: !writable ? undefined : (msgText) => {
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 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. */
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' | 'role' | 'meta' | 'tui' | 'editor'>): ModeBadge;
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, role, meta, tui, editor } = ctx;
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 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. */
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 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.
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, ClientRole, ClientToBroker } from '../../../core/runtime/broker-protocol.js';
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