@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
@@ -91,8 +91,8 @@ export function createAttachKit(getH) {
91
91
  return { client, frames, welcome, send: (f) => client.send(f), waitFrame, close: () => client.close() };
92
92
  }
93
93
  // Attach (as `role`) and retry until the welcome satisfies `pred` — used where the
94
- // observable lands a beat after a prior action (G3 snapshot accrual, G5b control
95
- // handoff after a controller detaches). Deterministic: it polls an observable.
94
+ // observable lands a beat after a prior action, such as snapshot accrual or
95
+ // persisted presence after a socket closes. Deterministic: it polls an observable.
96
96
  async function attachUntil(id, role, clientId, pred, label) {
97
97
  for (let attempt = 0;; attempt++) {
98
98
  const a = await attach(id, role, `${clientId}-${attempt}`);
@@ -16,7 +16,6 @@
16
16
  import { test } from 'node:test';
17
17
  import assert from 'node:assert/strict';
18
18
  import { applySnapshot, initialSessionState, reduce, } from '../session-model/session-state.js';
19
- const CLIENT = 'viewer-1';
20
19
  const engine = (patch = {}) => ({
21
20
  model: undefined,
22
21
  thinkingLevel: 'off',
@@ -35,7 +34,6 @@ const engine = (patch = {}) => ({
35
34
  const welcome = (over = {}, state = engine(), display) => ({
36
35
  type: 'welcome',
37
36
  role: 'controller',
38
- controller_id: CLIENT,
39
37
  agentDir: '/home/u/.pi/agent',
40
38
  snapshot: {
41
39
  messages: [],
@@ -45,7 +43,7 @@ const welcome = (over = {}, state = engine(), display) => ({
45
43
  },
46
44
  ...over,
47
45
  });
48
- const fold = (from, frames) => frames.reduce((s, f) => reduce(s, f, CLIENT), from);
46
+ const fold = (from, frames) => frames.reduce((s, f) => reduce(s, f), from);
49
47
  test('applySnapshot(welcome) equals folding the edges that produced the same broker state', () => {
50
48
  // The live path: a viewer attached from t=0 watches these edges go by.
51
49
  const edges = [
@@ -75,42 +73,42 @@ test('applySnapshot(welcome) equals folding the edges that produced the same bro
75
73
  assert.deepEqual(late, live, 'the two ways to learn a session must agree');
76
74
  });
77
75
  test('agent_settled clears isStreaming (the frozen-Working… fix, now shared by both viewers)', () => {
78
- const s0 = reduce(initialSessionState(), welcome(), CLIENT);
79
- const streaming = reduce(s0, { type: 'agent_start' }, CLIENT);
76
+ const s0 = reduce(initialSessionState(), welcome());
77
+ const streaming = reduce(s0, { type: 'agent_start' });
80
78
  assert.equal(streaming.engine?.isStreaming, true);
81
79
  // agent_settled ALONE — no agent_end — must settle it. A dormant orchestrator
82
80
  // between wakes showed a frozen spinner when this was missed.
83
- const settled = reduce(streaming, { type: 'agent_settled' }, CLIENT);
81
+ const settled = reduce(streaming, { type: 'agent_settled' });
84
82
  assert.equal(settled.engine?.isStreaming, false);
85
83
  });
86
84
  test('a notify is never a dialog, and never disturbs one that is pending', () => {
87
- const s0 = reduce(initialSessionState(), welcome(), CLIENT);
88
- const asked = reduce(s0, { type: 'extension_ui_request', id: 'ask-1', method: 'input', title: 'Paste the code' }, CLIENT);
85
+ const s0 = reduce(initialSessionState(), welcome());
86
+ const asked = reduce(s0, { type: 'extension_ui_request', id: 'ask-1', method: 'input', title: 'Paste the code' });
89
87
  assert.equal(asked.dialog?.id, 'ask-1');
90
- const notified = reduce(asked, { type: 'extension_ui_request', id: 'n-1', method: 'notify', message: 'background progress' }, CLIENT);
88
+ const notified = reduce(asked, { type: 'extension_ui_request', id: 'n-1', method: 'notify', message: 'background progress' });
91
89
  assert.equal(notified, asked, 'a notify carries no session state — same reference, dialog untouched');
92
90
  // A display paint is equally inert with respect to the pending dialog.
93
- const painted = reduce(asked, { type: 'extension_ui_request', id: 's-1', method: 'setStatus', statusKey: 'k', statusText: 'v' }, CLIENT);
91
+ const painted = reduce(asked, { type: 'extension_ui_request', id: 's-1', method: 'setStatus', statusKey: 'k', statusText: 'v' });
94
92
  assert.equal(painted.dialog?.id, 'ask-1', 'a chrome paint must not clear a pending dialog');
95
93
  // And only the MATCHING correlated dismiss clears it.
96
- assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'other' }, CLIENT), asked);
97
- assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'ask-1' }, CLIENT).dialog, null);
94
+ assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'other' }), asked);
95
+ assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'ask-1' }).dialog, null);
98
96
  });
99
97
  test('frames that carry no session state return the SAME state reference', () => {
100
- const s0 = reduce(initialSessionState(), welcome(), CLIENT);
98
+ const s0 = reduce(initialSessionState(), welcome());
101
99
  const inert = [
102
- { type: 'error', code: 'not_controller', message: 'read-only' },
100
+ { type: 'error', code: 'read_only', message: 'read-only' },
103
101
  { type: 'ack', for: 'get_commands', ok: true, detail: '[]' },
104
102
  { type: 'bash_start', command: 'ls' },
105
103
  { type: 'bash_output', chunk: 'a\n' },
106
104
  { type: 'bash_end', exitCode: 0, cancelled: false, truncated: false },
107
105
  ];
108
106
  for (const f of inert) {
109
- assert.equal(reduce(s0, f, CLIENT), s0, `${f.type} must not allocate a new state`);
107
+ assert.equal(reduce(s0, f), s0, `${f.type} must not allocate a new state`);
110
108
  }
111
109
  // Slice identity is what viewers repaint off: an engine-only change must leave
112
110
  // the display slice untouched by reference.
113
- const started = reduce(s0, { type: 'agent_start' }, CLIENT);
111
+ const started = reduce(s0, { type: 'agent_start' });
114
112
  assert.notEqual(started, s0);
115
113
  assert.equal(started.display, s0.display, 'an engine change must not dirty the display slice');
116
114
  });
@@ -125,16 +123,7 @@ test('a welcome is a COMPLETE catch-up: a re-welcome drops a dead broker s level
125
123
  assert.equal(s0.engine?.pendingMessageCount, 1);
126
124
  // A fresh broker (post-revive) welcomes with its OWN levels and an empty queue,
127
125
  // and emits no queue_update to overwrite the panel — so the welcome must.
128
- const s1 = reduce(s0, welcome(), CLIENT);
126
+ const s1 = reduce(s0, welcome());
129
127
  assert.deepEqual(s1.display.statuses, {}, 'a dead broker s status keys must not survive');
130
128
  assert.deepEqual(s1.queued, [], 'a dead broker s queued rows must not survive');
131
129
  });
132
- test('role follows the broker: control_changed re-derives it from our own client id', () => {
133
- const s0 = reduce(initialSessionState(), welcome({ role: 'controller', controller_id: CLIENT }), CLIENT);
134
- assert.equal(s0.role, 'controller');
135
- const demoted = reduce(s0, { type: 'control_changed', controller_id: 'someone-else' }, CLIENT);
136
- assert.equal(demoted.role, 'observer');
137
- assert.equal(demoted.controllerId, 'someone-else');
138
- const back = reduce(demoted, { type: 'control_changed', controller_id: CLIENT }, CLIENT);
139
- assert.equal(back.role, 'controller');
140
- });
@@ -25,7 +25,7 @@ const REQUIRED_IDS = [
25
25
  ...ids('crtr.graph.confirm', ['accept', 'cancel']),
26
26
  ...ids('crtr.view.host', ['force-quit', 'editor.backspace']),
27
27
  ...ids('crtr.view.canvas', ['down', 'up', 'refresh', 'quit']),
28
- ...ids('crtr.view.chat', ['submit', 'contextual-cancel', 'reconnect', 'request-control', 'release-control', 'quit']),
28
+ ...ids('crtr.view.chat', ['submit', 'contextual-cancel', 'reconnect', 'quit']),
29
29
  ...ids('crtr.view.git-pr', ['down', 'up', 'refresh', 'quit']),
30
30
  ...ids('crtr.view.inbox', ['down', 'up', 'open', 'reply', 'react', 'filter', 'refresh', 'connect-linkedin', 'connect-gmail', 'quit', 'submit-reply', 'cancel', 'reaction-previous', 'reaction-next', 'submit-reaction']),
31
31
  ...ids('crtr.view.linkedin', ['down', 'up', 'open', 'reply', 'react', 'refresh', 'quit', 'submit-reply', 'cancel', 'reaction-previous', 'reaction-next', 'submit-reaction']),
@@ -8,8 +8,8 @@ type ProfileMenuAction = 'up' | 'down' | 'select' | 'accept-default' | 'abort' |
8
8
  type SetupAction = 'cancel' | 'tab-next' | 'tab-previous' | 'open' | 'list-down' | 'list-up' | 'toggle' | 'details' | 'advance' | 'cell-previous' | 'cell-next';
9
9
  type SetupKeybindingAction = 'edit' | 'commit-edit' | 'cancel-edit' | 'unbind' | 'reset-row' | 'reset-all' | 'save' | 'discard' | 'page-up' | 'page-down' | 'filter' | 'filter-delete' | 'filter-clear';
10
10
  type SetupModelsAction = 'open' | 'commit' | 'cancel' | 'scope-next';
11
- export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | `crtr.tmux.node.${'next' | 'previous'}` | `crtr.tmux.view.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.${'detach' | 'clear-or-detach' | 'graph.toggle' | 'model-ladder.next' | 'model-ladder.previous' | 'command.inspect' | 'file-review'}` | 'crtr.mode.cycle' | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.view.host.${'force-quit' | 'editor.backspace'}` | `crtr.view.canvas.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.chat.${'submit' | 'contextual-cancel' | 'reconnect' | 'request-control' | 'release-control' | 'quit'}` | `crtr.view.git-pr.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.inbox.${InboxAction}` | `crtr.view.linkedin.${LinkedinAction}` | `crtr.view.prompt-review.${'down' | 'up' | 'compare-previous' | 'compare-next' | 'toggle-mode' | 'expand' | 'export' | 'refresh' | 'quit'}` | `crtr.view.workspace-sidebar.${'down' | 'up' | 'open' | 'refresh' | 'quit'}` | `crtr.view.inspector.${'down' | 'up' | 'section-next' | 'section-prev' | 'page-down' | 'page-up' | 'toggle-detail' | 'cancel-item' | 'confirm' | 'dismiss' | 'retarget' | 'refresh' | 'quit'}` | `crtr.view-picker.${'down' | 'up' | 'run' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}` | `crtr.setup.models.${SetupModelsAction}`;
12
- export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.promote">, BindingDefinition<"crtr.tmux.menu.resume">, BindingDefinition<"crtr.tmux.menu.fork">, BindingDefinition<"crtr.tmux.menu.demote">, BindingDefinition<"crtr.tmux.menu.detach">, BindingDefinition<"crtr.tmux.menu.close-subtree">, BindingDefinition<"crtr.tmux.menu.background-bash">, BindingDefinition<"crtr.tmux.menu.file-review">, BindingDefinition<"crtr.tmux.menu.pin">, BindingDefinition<"crtr.tmux.menu.show">, BindingDefinition<"crtr.tmux.menu.metadata">, BindingDefinition<"crtr.tmux.menu.context">, BindingDefinition<"crtr.tmux.menu.providers">, BindingDefinition<"crtr.tmux.menu.graph">, BindingDefinition<"crtr.tmux.menu.focus-manager">, BindingDefinition<"crtr.tmux.menu.copy-id">, BindingDefinition<"crtr.tmux.menu.reload-viewer">, BindingDefinition<"crtr.tmux.menu.issues">, BindingDefinition<"crtr.tmux.menu.issue.idea">, BindingDefinition<"crtr.tmux.menu.issue.bug">, BindingDefinition<"crtr.tmux.node.next">, BindingDefinition<"crtr.tmux.node.previous">, BindingDefinition<"crtr.tmux.inbox.toggle">, BindingDefinition<"crtr.tmux.view.next">, BindingDefinition<"crtr.tmux.view.previous">, BindingDefinition<"crtr.attach.detach">, BindingDefinition<"crtr.attach.clear-or-detach">, BindingDefinition<"crtr.attach.graph.toggle">, BindingDefinition<"crtr.attach.model-ladder.next">, BindingDefinition<"crtr.attach.model-ladder.previous">, BindingDefinition<"crtr.attach.command.inspect">, BindingDefinition<"crtr.attach.file-review">, BindingDefinition<"crtr.mode.cycle">, BindingDefinition<"crtr.graph.down">, BindingDefinition<"crtr.graph.up">, BindingDefinition<"crtr.graph.first">, BindingDefinition<"crtr.graph.last">, BindingDefinition<"crtr.graph.collapse-or-manager">, BindingDefinition<"crtr.graph.expand-or-child">, BindingDefinition<"crtr.graph.focus">, BindingDefinition<"crtr.graph.revive">, BindingDefinition<"crtr.graph.focus-manager">, BindingDefinition<"crtr.graph.close-subtree">, BindingDefinition<"crtr.graph.dismiss">, BindingDefinition<"crtr.graph.confirm.accept">, BindingDefinition<"crtr.graph.confirm.cancel">, BindingDefinition<"crtr.view.host.force-quit">, BindingDefinition<"crtr.view.host.editor.backspace">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<"crtr.view.chat.submit">, BindingDefinition<"crtr.view.chat.contextual-cancel">, BindingDefinition<"crtr.view.chat.reconnect">, BindingDefinition<"crtr.view.chat.request-control">, BindingDefinition<"crtr.view.chat.release-control">, BindingDefinition<"crtr.view.chat.quit">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.view.prompt-review.down"> | BindingDefinition<"crtr.view.prompt-review.up"> | BindingDefinition<"crtr.view.prompt-review.compare-previous"> | BindingDefinition<"crtr.view.prompt-review.compare-next"> | BindingDefinition<"crtr.view.prompt-review.toggle-mode"> | BindingDefinition<"crtr.view.prompt-review.expand"> | BindingDefinition<"crtr.view.prompt-review.export"> | BindingDefinition<"crtr.view.prompt-review.refresh"> | BindingDefinition<"crtr.view.prompt-review.quit"> | BindingDefinition<"crtr.view.workspace-sidebar.down"> | BindingDefinition<"crtr.view.workspace-sidebar.up"> | BindingDefinition<"crtr.view.workspace-sidebar.open"> | BindingDefinition<"crtr.view.workspace-sidebar.refresh"> | BindingDefinition<"crtr.view.workspace-sidebar.quit"> | BindingDefinition<"crtr.view.inspector.down"> | BindingDefinition<"crtr.view.inspector.up"> | BindingDefinition<"crtr.view.inspector.section-next"> | BindingDefinition<"crtr.view.inspector.section-prev"> | BindingDefinition<"crtr.view.inspector.page-down"> | BindingDefinition<"crtr.view.inspector.page-up"> | BindingDefinition<"crtr.view.inspector.toggle-detail"> | BindingDefinition<"crtr.view.inspector.cancel-item"> | BindingDefinition<"crtr.view.inspector.confirm"> | BindingDefinition<"crtr.view.inspector.dismiss"> | BindingDefinition<"crtr.view.inspector.retarget"> | BindingDefinition<"crtr.view.inspector.refresh"> | BindingDefinition<"crtr.view.inspector.quit"> | BindingDefinition<"crtr.view-picker.down"> | BindingDefinition<"crtr.view-picker.up"> | BindingDefinition<"crtr.view-picker.run"> | BindingDefinition<"crtr.view-picker.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.9" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.6">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
11
+ export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | `crtr.tmux.node.${'next' | 'previous'}` | `crtr.tmux.view.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.${'detach' | 'clear-or-detach' | 'graph.toggle' | 'model-ladder.next' | 'model-ladder.previous' | 'command.inspect' | 'file-review'}` | 'crtr.mode.cycle' | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.view.host.${'force-quit' | 'editor.backspace'}` | `crtr.view.canvas.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.chat.${'submit' | 'contextual-cancel' | 'reconnect' | 'quit'}` | `crtr.view.git-pr.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.inbox.${InboxAction}` | `crtr.view.linkedin.${LinkedinAction}` | `crtr.view.prompt-review.${'down' | 'up' | 'compare-previous' | 'compare-next' | 'toggle-mode' | 'expand' | 'export' | 'refresh' | 'quit'}` | `crtr.view.workspace-sidebar.${'down' | 'up' | 'open' | 'refresh' | 'quit'}` | `crtr.view.inspector.${'down' | 'up' | 'section-next' | 'section-prev' | 'page-down' | 'page-up' | 'toggle-detail' | 'cancel-item' | 'confirm' | 'dismiss' | 'retarget' | 'refresh' | 'quit'}` | `crtr.view-picker.${'down' | 'up' | 'run' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}` | `crtr.setup.models.${SetupModelsAction}`;
12
+ export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.promote">, BindingDefinition<"crtr.tmux.menu.resume">, BindingDefinition<"crtr.tmux.menu.fork">, BindingDefinition<"crtr.tmux.menu.demote">, BindingDefinition<"crtr.tmux.menu.detach">, BindingDefinition<"crtr.tmux.menu.close-subtree">, BindingDefinition<"crtr.tmux.menu.background-bash">, BindingDefinition<"crtr.tmux.menu.file-review">, BindingDefinition<"crtr.tmux.menu.pin">, BindingDefinition<"crtr.tmux.menu.show">, BindingDefinition<"crtr.tmux.menu.metadata">, BindingDefinition<"crtr.tmux.menu.context">, BindingDefinition<"crtr.tmux.menu.providers">, BindingDefinition<"crtr.tmux.menu.graph">, BindingDefinition<"crtr.tmux.menu.focus-manager">, BindingDefinition<"crtr.tmux.menu.copy-id">, BindingDefinition<"crtr.tmux.menu.reload-viewer">, BindingDefinition<"crtr.tmux.menu.issues">, BindingDefinition<"crtr.tmux.menu.issue.idea">, BindingDefinition<"crtr.tmux.menu.issue.bug">, BindingDefinition<"crtr.tmux.node.next">, BindingDefinition<"crtr.tmux.node.previous">, BindingDefinition<"crtr.tmux.inbox.toggle">, BindingDefinition<"crtr.tmux.view.next">, BindingDefinition<"crtr.tmux.view.previous">, BindingDefinition<"crtr.attach.detach">, BindingDefinition<"crtr.attach.clear-or-detach">, BindingDefinition<"crtr.attach.graph.toggle">, BindingDefinition<"crtr.attach.model-ladder.next">, BindingDefinition<"crtr.attach.model-ladder.previous">, BindingDefinition<"crtr.attach.command.inspect">, BindingDefinition<"crtr.attach.file-review">, BindingDefinition<"crtr.mode.cycle">, BindingDefinition<"crtr.graph.down">, BindingDefinition<"crtr.graph.up">, BindingDefinition<"crtr.graph.first">, BindingDefinition<"crtr.graph.last">, BindingDefinition<"crtr.graph.collapse-or-manager">, BindingDefinition<"crtr.graph.expand-or-child">, BindingDefinition<"crtr.graph.focus">, BindingDefinition<"crtr.graph.revive">, BindingDefinition<"crtr.graph.focus-manager">, BindingDefinition<"crtr.graph.close-subtree">, BindingDefinition<"crtr.graph.dismiss">, BindingDefinition<"crtr.graph.confirm.accept">, BindingDefinition<"crtr.graph.confirm.cancel">, BindingDefinition<"crtr.view.host.force-quit">, BindingDefinition<"crtr.view.host.editor.backspace">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<"crtr.view.chat.submit">, BindingDefinition<"crtr.view.chat.contextual-cancel">, BindingDefinition<"crtr.view.chat.reconnect">, BindingDefinition<"crtr.view.chat.quit">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.view.prompt-review.down"> | BindingDefinition<"crtr.view.prompt-review.up"> | BindingDefinition<"crtr.view.prompt-review.compare-previous"> | BindingDefinition<"crtr.view.prompt-review.compare-next"> | BindingDefinition<"crtr.view.prompt-review.toggle-mode"> | BindingDefinition<"crtr.view.prompt-review.expand"> | BindingDefinition<"crtr.view.prompt-review.export"> | BindingDefinition<"crtr.view.prompt-review.refresh"> | BindingDefinition<"crtr.view.prompt-review.quit"> | BindingDefinition<"crtr.view.workspace-sidebar.down"> | BindingDefinition<"crtr.view.workspace-sidebar.up"> | BindingDefinition<"crtr.view.workspace-sidebar.open"> | BindingDefinition<"crtr.view.workspace-sidebar.refresh"> | BindingDefinition<"crtr.view.workspace-sidebar.quit"> | BindingDefinition<"crtr.view.inspector.down"> | BindingDefinition<"crtr.view.inspector.up"> | BindingDefinition<"crtr.view.inspector.section-next"> | BindingDefinition<"crtr.view.inspector.section-prev"> | BindingDefinition<"crtr.view.inspector.page-down"> | BindingDefinition<"crtr.view.inspector.page-up"> | BindingDefinition<"crtr.view.inspector.toggle-detail"> | BindingDefinition<"crtr.view.inspector.cancel-item"> | BindingDefinition<"crtr.view.inspector.confirm"> | BindingDefinition<"crtr.view.inspector.dismiss"> | BindingDefinition<"crtr.view.inspector.retarget"> | BindingDefinition<"crtr.view.inspector.refresh"> | BindingDefinition<"crtr.view.inspector.quit"> | BindingDefinition<"crtr.view-picker.down"> | BindingDefinition<"crtr.view-picker.up"> | BindingDefinition<"crtr.view-picker.run"> | BindingDefinition<"crtr.view-picker.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.9" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.6">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
13
13
  export declare const BINDING_IDS: ReadonlySet<string>;
14
14
  export declare const SAFETY_REQUIREMENTS: readonly SafetyRequirement[];
15
15
  export {};
@@ -79,8 +79,6 @@ export const BINDING_CATALOG = Object.freeze([
79
79
  b('crtr.view.chat.submit', 'Views', 'Submit chat', 'Submit the current chat input.', ['enter'], 'terminal', ['view.chat']),
80
80
  b('crtr.view.chat.contextual-cancel', 'Views', 'Cancel chat action', 'Cancel the active chat action.', ['escape'], 'terminal', ['view.chat']),
81
81
  b('crtr.view.chat.reconnect', 'Views', 'Reconnect chat', 'Reconnect the chat stream.', ['r'], 'terminal', ['view.chat']),
82
- b('crtr.view.chat.request-control', 'Views', 'Request control', 'Request control of the chat.', ['c'], 'terminal', ['view.chat']),
83
- b('crtr.view.chat.release-control', 'Views', 'Release control', 'Release control of the chat.', ['shift+c', 'ctrl+r'], 'terminal', ['view.chat']),
84
82
  b('crtr.view.chat.quit', 'Views', 'Quit chat', 'Leave the chat view.', ['q'], 'terminal', ['view.chat'], 'leave-screen'),
85
83
  ...viewNavigation('git-pr', 'Git PR view'),
86
84
  ...listView('inbox', 'Inbox', [
@@ -1,7 +1,7 @@
1
- /** Reload a LIVE node's credentials over its view.sock. Connects as an OBSERVER
2
- * (hello only — NO request_control, so an attached human keeps control through
3
- * the fan; the broker's reload_auth handler is open to any client), sends
4
- * `reload_auth`, and resolves on its ack. Rejects on the broker's `error` frame,
1
+ /** Reload a LIVE node's credentials over its view.sock. Connects as an
2
+ * OBSERVER, sends `reload_auth`, and resolves on its ack. The handler is open
3
+ * to any role, but observer accurately describes this role-independent operation.
4
+ * Rejects on the broker's `error` frame,
5
5
  * connect failure, a premature close, or timeout — the caller (the daemon fan)
6
6
  * isolates each broker so one failure never aborts the rest. */
7
7
  export declare function reloadAuthLive(nodeId: string): Promise<void>;
@@ -9,11 +9,9 @@
9
9
  // live broker, so the whole canvas follows one account switch with no per-broker
10
10
  // re-login.
11
11
  //
12
- // connect → hello → reload_auth → await ack. NO request_control: the broker's
13
- // reload_auth handler is open to any client (it's an idempotent local re-read
14
- // that doesn't steer the conversation), so the fan must NOT claim controller —
15
- // request_control ALWAYS preempts, which would silently demote any human
16
- // attached-and-driving a node every time /login fans canvas-wide.
12
+ // connect → observer hello → reload_auth → await ack. The broker's reload_auth
13
+ // handler is open to any client (it is an idempotent local re-read that does not
14
+ // steer the conversation), so this deliberately remains an observer operation.
17
15
  import { randomUUID } from 'node:crypto';
18
16
  import { BrokerClient } from '../broker-client/index.js';
19
17
  /** How long to wait for the broker's `reload_auth` ack before giving up. The
@@ -21,10 +19,10 @@ import { BrokerClient } from '../broker-client/index.js';
21
19
  * supervision loop awaits this on a real change — so the timeout is kept tight
22
20
  * to cap the worst-case stall if a broker connects but never acks. */
23
21
  const ACK_TIMEOUT_MS = 3_000;
24
- /** Reload a LIVE node's credentials over its view.sock. Connects as an OBSERVER
25
- * (hello only — NO request_control, so an attached human keeps control through
26
- * the fan; the broker's reload_auth handler is open to any client), sends
27
- * `reload_auth`, and resolves on its ack. Rejects on the broker's `error` frame,
22
+ /** Reload a LIVE node's credentials over its view.sock. Connects as an
23
+ * OBSERVER, sends `reload_auth`, and resolves on its ack. The handler is open
24
+ * to any role, but observer accurately describes this role-independent operation.
25
+ * Rejects on the broker's `error` frame,
28
26
  * connect failure, a premature close, or timeout — the caller (the daemon fan)
29
27
  * isolates each broker so one failure never aborts the rest. */
30
28
  export function reloadAuthLive(nodeId) {
@@ -51,10 +49,8 @@ export function reloadAuthLive(nodeId) {
51
49
  client.on('error', (err) => finish(() => reject(err)));
52
50
  client.on('close', () => finish(() => reject(new Error('broker connection closed before it acked the auth reload'))));
53
51
  client.on('connect', () => {
54
- // Hello as OBSERVER — reload_auth is open to any client, and an observer
55
- // hello never touches controllerId, so a human driving this broker keeps
56
- // control through the fan. (A `controller` hello also only takes control
57
- // when none is held, which is safe; observer is the more honest signal.)
52
+ // Hello as OBSERVER — reload_auth is role-independent, and this helper
53
+ // does not drive the conversation.
58
54
  client.send({ type: 'hello', role: 'observer', client_id: clientId });
59
55
  client.send({ type: 'reload_auth' });
60
56
  });
@@ -17,8 +17,8 @@ export interface BootRootOpts {
17
17
  }
18
18
  /** Boot the front-door root: create the resident root through crtrd (broker
19
19
  * launched + view socket ready server-side), register THIS terminal's pane as
20
- * its one viewer, then exec `crtr surface attach` inline so this terminal
21
- * becomes the broker's controller-viewer. Does not return — it process.exit()s
20
+ * its foreground viewer, then exec `crtr surface attach` inline so this terminal
21
+ * becomes a writable viewer. Does not return — it process.exit()s
22
22
  * when the inline attach exits (detach leaves the resident broker running, to
23
23
  * be reconnected later). */
24
24
  export declare function bootRoot(opts: BootRootOpts): Promise<never>;
@@ -3,7 +3,7 @@
3
3
  // bootRoot creates the resident root through crtrd (`createNode({root:true})`,
4
4
  // which the daemon serves at readiness 'await' — node birth, broker launch, and
5
5
  // view-socket readiness ALL happen server-side), then execs `crtr surface
6
- // attach` inline so THIS terminal becomes the broker's controller-viewer.
6
+ // attach` inline so THIS terminal becomes the broker's writable viewer.
7
7
  //
8
8
  // crtrd is the sole owner of canvas state (see crtrd-sole-state-owner), so this
9
9
  // module opens NO canvas.db: node creation and the viewer-focus row both route
@@ -18,13 +18,13 @@ import { cliClient } from '../../commands/api-client.js';
18
18
  import { mark } from '../timing.js';
19
19
  /** Boot the front-door root: create the resident root through crtrd (broker
20
20
  * launched + view socket ready server-side), register THIS terminal's pane as
21
- * its one viewer, then exec `crtr surface attach` inline so this terminal
22
- * becomes the broker's controller-viewer. Does not return — it process.exit()s
21
+ * its foreground viewer, then exec `crtr surface attach` inline so this terminal
22
+ * becomes a writable viewer. Does not return — it process.exit()s
23
23
  * when the inline attach exits (detach leaves the resident broker running, to
24
24
  * be reconnected later). */
25
25
  export async function bootRoot(opts) {
26
26
  // crtr is tmux-only: the front door execs `crtr surface attach` inline in THIS
27
- // pane to become the root's controller-viewer, so there must be a tmux pane to
27
+ // pane to become the root's writable viewer, so there must be a tmux pane to
28
28
  // anchor on. Outside tmux there is no viewer surface — throw with a friendly
29
29
  // message rather than boot a root nobody can see.
30
30
  if (!inTmux()) {
@@ -71,8 +71,8 @@ export async function bootRoot(opts) {
71
71
  // so its window stays where the user already is. The node row carries NO
72
72
  // presence — a broker node's pane/window/tmux_session stay NULL; the viewer
73
73
  // pane lives in the focuses table. Register the FOREGROUND front-door pane as
74
- // the node's one viewer focus (canvas write, routed through crtrd) so focus/nav
75
- // can find it. Best-effort — attach works regardless.
74
+ // this node's focus (canvas write, routed through crtrd) so focus/nav can find
75
+ // it. Best-effort — attach works regardless.
76
76
  const here = currentTmux();
77
77
  if (here !== null) {
78
78
  try {
@@ -82,7 +82,7 @@ export async function bootRoot(opts) {
82
82
  }
83
83
  // Broker is the only host: the root runs as a detached broker engine (already
84
84
  // launched above), and THIS terminal execs `crtr surface attach` inline to
85
- // become its controller-viewer. The attach's own ensureAttach is an idempotent
85
+ // become its writable viewer. The attach's own ensureAttach is an idempotent
86
86
  // no-op on the already-live broker. CRTR_ATTACH_WAIT_MS tells the viewer to
87
87
  // poll view.sock until it accepts instead of failing fast on a not-yet-bound
88
88
  // socket. CRTR_ATTACH_BORROWED_PANE tells the viewer that its pane is the
@@ -5,7 +5,10 @@ import type { BrokerErrorFrame } from '../../api/dto/broker.js';
5
5
  * import site for the wire union; the shapes themselves are pi's, versioned by
6
6
  * pi, so a pi change is a compile error here instead of silent drift. */
7
7
  export type { RpcExtensionUIRequest, RpcExtensionUIResponse, RpcSessionState, } from '@earendil-works/pi-coding-agent';
8
- /** Single controller (drives the engine) + N read-only observers (§5.3). */
8
+ /** A socket's fixed, per-client capability, stated once in its `hello` and never
9
+ * reassigned: `controller` may drive the engine, `observer` is read-only. This is
10
+ * a CAPABILITY, not ownership of a singleton slot — any number of clients may be
11
+ * controllers at the same time and all of them may write concurrently. */
9
12
  export type ClientRole = 'controller' | 'observer';
10
13
  /** The full state a (re)attaching client needs to catch up instantly — the
11
14
  * broker's authoritative in-memory view (design §5.2: get_messages +
@@ -43,6 +46,8 @@ export interface BrokerSnapshot {
43
46
  title: string | undefined;
44
47
  };
45
48
  }
49
+ /** Opens the connection and FIXES this client's role for its lifetime. A repeated
50
+ * `hello` on an already-helloed socket never changes the established role. */
46
51
  export interface HelloFrame {
47
52
  type: 'hello';
48
53
  role: ClientRole;
@@ -53,7 +58,7 @@ export interface HelloFrame {
53
58
  rows: number;
54
59
  };
55
60
  }
56
- /** Drive the engine — controller only. Map 1:1 to session.prompt/steer/followUp/abort.
61
+ /** Drive the engine — writable (`controller`) clients only. Map 1:1 to session.prompt/steer/followUp/abort.
57
62
  * `images` carries pasted/attached images to the engine (review M1): pi accepts
58
63
  * `prompt(text,{images})` / `steer(text,images)` / `followUp(text,images)` at
59
64
  * 0.78.1. The wire TYPE lives here; T3/T6 wire the runtime side. The BROKER read
@@ -87,7 +92,7 @@ export interface AbortFrame {
87
92
  * `POST /v1/nodes/{id}/messages` with `delivery:'interactive'`). The broker
88
93
  * routes it ITSELF — turn in flight → steer, idle → prompt — because only the
89
94
  * broker authoritatively knows streaming state (viewers track it client-side;
90
- * a one-shot client can't). Controller-only, like the other drive frames.
95
+ * a one-shot client can't). Writable-only, like the other drive frames.
91
96
  * Acked once routed (`ack{for:'deliver', id}`, `detail` = 'prompt' | 'steer')
92
97
  * so the daemon handler can return deterministically. */
93
98
  export interface DeliverFrame {
@@ -96,7 +101,7 @@ export interface DeliverFrame {
96
101
  text: string;
97
102
  images?: ImageContent[];
98
103
  }
99
- /** Run a `!` bash command — controller only. Maps to `session.executeBash()`,
104
+ /** Run a `!` bash command — writable clients only. Maps to `session.executeBash()`,
100
105
  * which runs the command, records a `bashExecution` message in context, and
101
106
  * starts NO agent turn (pi's interactive `!`/`!!` semantics). `command` is the
102
107
  * text AFTER the leading `!`/`!!`; `excludeFromContext` is the `!!` form (output
@@ -107,13 +112,6 @@ export interface BashFrame {
107
112
  command: string;
108
113
  excludeFromContext?: boolean;
109
114
  }
110
- /** Controller arbitration (§5.3). */
111
- export interface RequestControlFrame {
112
- type: 'request_control';
113
- }
114
- export interface ReleaseControlFrame {
115
- type: 'release_control';
116
- }
117
115
  /** Detach this client — the engine runs on (distinct from `shutdown`). */
118
116
  export interface ByeFrame {
119
117
  type: 'bye';
@@ -299,13 +297,13 @@ export interface ListMemoryRefsFrame {
299
297
  }
300
298
  /** Answer a blocking extension dialog — pi's public RPC response type. */
301
299
  export type ExtensionUIResponseFrame = RpcExtensionUIResponse;
302
- /** Clone the current session to a new branch (`/clone`). Controller-only.
300
+ /** Clone the current session to a new branch (`/clone`). Writable-only.
303
301
  * Broker handler: reads current leaf from sessionManager, creates a branched
304
302
  * session file, switches to it via runReplacement. */
305
303
  export interface CloneFrame {
306
304
  type: 'clone';
307
305
  }
308
- /** Share the session as a secret GitHub gist (`/share`). Controller-only.
306
+ /** Share the session as a secret GitHub gist (`/share`). Writable-only.
309
307
  * Broker handler: exports session to a temp HTML file, shells `gh gist create --secret`,
310
308
  * returns the URL in ack.detail. */
311
309
  export interface ShareFrame {
@@ -314,8 +312,8 @@ export interface ShareFrame {
314
312
  /** Reload credentials + refresh model registry after a viewer-side auth change.
315
313
  * Open to any client — reload_auth is an idempotent local re-read that doesn't
316
314
  * steer the conversation, so the daemon's canvas-wide fan (one /login → every
317
- * live broker) can trigger it without claiming controller and demoting an
318
- * attached human. Broker handler: services.authStorage.reload() +
315
+ * live broker) can trigger it from an observer connection. Broker handler:
316
+ * services.authStorage.reload() +
319
317
  * services.modelRegistry.refresh(). */
320
318
  export interface ReloadAuthFrame {
321
319
  type: 'reload_auth';
@@ -327,25 +325,22 @@ export interface GetHumanForkCoordinatesFrame {
327
325
  type: 'get_human_fork_coordinates';
328
326
  id: string;
329
327
  }
330
- export type ClientToBroker = HelloFrame | PromptFrame | SteerFrame | FollowUpFrame | AbortFrame | DeliverFrame | BashFrame | RequestControlFrame | ReleaseControlFrame | ByeFrame | ShutdownFrame | SetModelFrame | DeliverCustomMessageFrame | CycleModelFrame | CycleLadderFrame | CycleThinkingFrame | SetThinkingLevelFrame | DequeueFrame | SetAutoRetryFrame | SetAutoCompactionFrame | CompactFrame | NewSessionFrame | SwitchSessionFrame | ForkFrame | SetSessionNameFrame | GetCommandsFrame | NavigateTreeFrame | ReloadFrame | ExportFrame | ListModelsFrame | ListSessionsFrame | GetTreeFrame | GetSettingsFrame | ListScopedModelsFrame | ListMemoryRefsFrame | ExtensionUIResponseFrame | CloneFrame | ShareFrame | ReloadAuthFrame | GetHumanForkCoordinatesFrame;
328
+ export type ClientToBroker = HelloFrame | PromptFrame | SteerFrame | FollowUpFrame | AbortFrame | DeliverFrame | BashFrame | ByeFrame | ShutdownFrame | SetModelFrame | DeliverCustomMessageFrame | CycleModelFrame | CycleLadderFrame | CycleThinkingFrame | SetThinkingLevelFrame | DequeueFrame | SetAutoRetryFrame | SetAutoCompactionFrame | CompactFrame | NewSessionFrame | SwitchSessionFrame | ForkFrame | SetSessionNameFrame | GetCommandsFrame | NavigateTreeFrame | ReloadFrame | ExportFrame | ListModelsFrame | ListSessionsFrame | GetTreeFrame | GetSettingsFrame | ListScopedModelsFrame | ListMemoryRefsFrame | ExtensionUIResponseFrame | CloneFrame | ShareFrame | ReloadAuthFrame | GetHumanForkCoordinatesFrame;
331
329
  export interface WelcomeFrame {
332
330
  type: 'welcome';
333
331
  snapshot: BrokerSnapshot;
332
+ /** The COMPLETE statement of this client's authority — there is no other
333
+ * role/ownership field. Fixed at `hello` and never changed afterwards. */
334
334
  role: ClientRole;
335
- controller_id: string | null;
336
- /** A dialog already in-flight when this client attached (Phase 4
337
- * attach-mid-dialog). The field exists now; it is only ever populated in
338
- * Phase 4 — Phase 3 always sends it absent/null. */
335
+ /** A dialog already in-flight when this client attached. Populated only for a
336
+ * writable client (an observer can never answer one), so a viewer joining
337
+ * mid-dialog can answer it alongside its peers. */
339
338
  pending_dialog?: RpcExtensionUIRequest | null;
340
339
  /** The pi agent dir (`~/.pi/agent`) from the broker's process — so the viewer
341
340
  * can construct an AuthStorage/ModelRegistry pointing at the SAME auth.json.
342
341
  * crtr surface attach is tmux-local only: broker + viewer share a filesystem. */
343
342
  agentDir?: string;
344
343
  }
345
- export interface ControlChangedFrame {
346
- type: 'control_changed';
347
- controller_id: string | null;
348
- }
349
344
  /** Broadcast to EVERY client after a successful `set_model`/`cycle_model`. pi
350
345
  * emits no AgentSessionEvent for a model switch, so without this the new model
351
346
  * reaches no viewer at all (the requester gets only a bare ack) and footers
@@ -375,7 +370,7 @@ export interface ModelChangedFrame {
375
370
  * promise instead of hanging it — absent on uncorrelated errors (engine drive
376
371
  * errors, command-op failures, frame_overflow). There is NO `retryable` field. */
377
372
  export type ErrorFrame = BrokerErrorFrame;
378
- /** Result of a controller command op (§1.3): `for` echoes the op name, `ok` the
373
+ /** Result of a command op (§1.3): `for` echoes the op name, `ok` the
379
374
  * outcome, `detail` an optional human-readable note. */
380
375
  export interface AckFrame {
381
376
  type: 'ack';
@@ -560,7 +555,8 @@ export type ExtensionUIRequestFrame = RpcExtensionUIRequest;
560
555
  * WITHOUT a client answer. The broker sends this exactly when it resolves a pending
561
556
  * dialog ITSELF rather than the client answering it — the extension aborted the
562
557
  * request out-of-band (e.g. a local OAuth loopback callback won the race against a
563
- * still-open manual-paste dialog) or the broker-side timeout fired. The client tears
558
+ * still-open manual-paste dialog), the broker-side timeout fired, or ANOTHER writable
559
+ * client answered the fan-out first and this one must close its copy. The client tears
564
560
  * down ONLY the overlay whose `id` matches; every other dialog stays exactly as it
565
561
  * was, so an unrelated request can never dismiss an unrelated blocking dialog. */
566
562
  export interface ExtensionUIDismissFrame {
@@ -570,7 +566,7 @@ export interface ExtensionUIDismissFrame {
570
566
  /** Everything the broker can send. Live `AgentSessionEvent`s are relayed
571
567
  * verbatim (the broker adds nothing); the broker's own control frames carry
572
568
  * non-colliding `type` discriminants. */
573
- export type BrokerToClient = WelcomeFrame | ControlChangedFrame | ModelChangedFrame | ErrorFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | AgentSessionEvent;
569
+ export type BrokerToClient = WelcomeFrame | ModelChangedFrame | ErrorFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | AgentSessionEvent;
574
570
  /** Encode one frame as a single newline-terminated JSON line. */
575
571
  export declare function encodeFrame(frame: ClientToBroker | BrokerToClient): string;
576
572
  /** Byte bounds for a {@link FrameDecoder} (C5). */
@@ -6,7 +6,7 @@
6
6
  // The transport is one unix socket per node (`nodeDir(id)/view.sock`) speaking
7
7
  // newline-delimited JSON frames. Live engine events are relayed VERBATIM — the
8
8
  // broker is a transparent multiplexer, so a broker→client frame is either one of
9
- // the broker's own control frames (welcome/control_changed/error) or a raw pi
9
+ // the broker's own control frames (welcome/error) or a raw pi
10
10
  // `AgentSessionEvent` / `extension_ui_request`. The `type` namespaces never
11
11
  // collide, so the union below stays a clean discriminated union.
12
12
  //
@@ -1,6 +1,6 @@
1
- // broker-request.ts — the ONE one-shot controller round-trip against a LIVE
2
- // node broker over its view.sock: connect → `hello` + `request_control` →
3
- // send a single frame → await its ack.
1
+ // broker-request.ts — the ONE one-shot writable round-trip against a LIVE
2
+ // node broker over its view.sock: connect → `hello` as controller → send a
3
+ // single frame → await its ack.
4
4
  //
5
5
  // Every host-side "tell a booted engine something" path rides this: the model
6
6
  // swap (model-swap.ts), interactive deliver/interrupt (interactive-deliver.ts),
@@ -8,11 +8,6 @@
8
8
  // They differ only in the frame sent, the ack awaited, and what they read off
9
9
  // the ack — so the dance itself lives here once instead of being re-copied per
10
10
  // caller.
11
- //
12
- // `request_control` is sent unconditionally: `hello` admits us as controller
13
- // only if none is held, and `request_control` preempts an attached viewer
14
- // (idempotent when we already hold it). The broker processes both in order
15
- // before the payload frame.
16
11
  import { randomUUID } from 'node:crypto';
17
12
  import { BrokerClient } from '../broker-client/index.js';
18
13
  /** How long to wait for the broker's ack. Every frame this carries is local
@@ -67,7 +62,6 @@ export function oneShotControllerRequest(opts) {
67
62
  client.on('connect', () => {
68
63
  connected = true;
69
64
  client.send({ type: 'hello', role: 'controller', client_id: clientId });
70
- client.send({ type: 'request_control' });
71
65
  client.send(opts.frame(frameId));
72
66
  });
73
67
  client.on('frame', (frame) => {
@@ -78,8 +72,8 @@ export function oneShotControllerRequest(opts) {
78
72
  finish(() => reject(new Error(frame.detail ?? `broker rejected ${opts.what}`)));
79
73
  }
80
74
  else if (frame.type === 'error') {
81
- // This connection only ever sent hello/request_control + one payload
82
- // frame, so any error frame here is a response to ours.
75
+ // This connection only ever sent hello + one payload frame, so any
76
+ // error frame here is a response to ours.
83
77
  finish(() => reject(new Error(frame.message)));
84
78
  }
85
79
  else if (opts.observe !== undefined) {
@@ -22,8 +22,8 @@ export declare function isUnknownModel(model: {
22
22
  api?: string;
23
23
  } | null | undefined): boolean;
24
24
  /**
25
- * Route a controller `prompt`/`follow_up` frame against the LIVE session state.
26
- * The controller picks its frame type off a possibly-STALE `isStreaming`
25
+ * Route a writable client's `prompt`/`follow_up` frame against the LIVE session
26
+ * state. The client picks its frame type off a possibly-STALE `isStreaming`
27
27
  * snapshot, so the broker is authoritative and the client's choice is a HINT:
28
28
  *
29
29
  * - m-B (streaming-safe prompt): a `prompt` arriving mid-stream needs
@@ -186,15 +186,19 @@ interface BrokerClient {
186
186
  * high-water mark). */
187
187
  queuedFrames: number;
188
188
  }
189
- /** A blocking dialog awaiting the controller's response, the broker-side default
190
- * timeout, or the engine's abort. */
189
+ /** A blocking dialog awaiting a response from ANY writable client, the
190
+ * broker-side default timeout, or the engine's abort. The dialog is fanned out
191
+ * to every writable client at raise time; this entry is the single settlement
192
+ * point they race for. */
191
193
  interface PendingDialog {
192
- /** The original request (T4) — retained so `welcome.pending_dialog` and the
193
- * re-route-on-become-controller path can re-deliver a still-pending dialog to
194
- * a (new) controller. The Wave-0 shape stored only the resolver. */
194
+ /** The original request — retained so `welcome.pending_dialog` and the
195
+ * attach-mid-dialog replay can hand a still-pending dialog to a writable
196
+ * client that attaches after the fan-out. */
195
197
  request: RpcExtensionUIRequest;
196
- /** The controller answered — resolve with its parsed response (also clears the
197
- * broker-side timeout and removes the entry from the registry). */
198
+ /** Some writable client answered — resolve with its parsed response. Settling
199
+ * clears the broker-side timeout, removes the entry from the registry (so a
200
+ * later answer for the same id finds nothing and is a no-op), and broadcasts
201
+ * `extension_ui_dismiss` so every peer closes its copy. */
198
202
  resolve: (response: RpcExtensionUIResponse) => void;
199
203
  }
200
204
  /** Dispose the live engine session if one exists (idempotent). Called by
@@ -214,11 +218,15 @@ export declare function buildBrokerSession(engine: BrokerEngine, cfg: BrokerSdkC
214
218
  }>;
215
219
  /** Broker-side hooks the UI context needs to route (or noOp) extension dialogs. */
216
220
  export interface BrokerDialogDeps {
217
- /** The controller client, or null when ZERO viewers are attached. */
218
- controller: () => BrokerClient | null;
219
- /** Forward a dialog request to the (non-null) controller. */
221
+ /** Every client that may answer a dialog right now — empty when no writable
222
+ * viewer is attached, which is the noOp fallback path. */
223
+ writable: () => BrokerClient[];
224
+ /** Forward a dialog request to one writable client (called once per client in
225
+ * the fan-out). */
220
226
  forward: (client: BrokerClient, request: RpcExtensionUIRequest) => void;
221
- /** Pending-dialog registry, keyed by request id (answered via extension_ui_response). */
227
+ /** Pending-dialog registry, keyed by request id. ONE entry per dialog no matter
228
+ * how many clients were fanned it; the first `extension_ui_response` settles
229
+ * and removes it, so later answers for the same id are no-ops. */
222
230
  pending: Map<string, PendingDialog>;
223
231
  /** Broadcast a non-blocking display frame (setStatus/setWidget/setTitle) to ALL
224
232
  * viewers — the relay path for pi's fire-and-forget extension-UI surface. */