@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
@@ -3,8 +3,9 @@
3
3
  // One broker process per node — the SOLE host. It hosts ONE pi engine IN-PROCESS
4
4
  // via the SDK (createAgentSession), is the SOLE writer of the node's session
5
5
  // `.jsonl`, listens on `nodeDir(id)/view.sock`, fans the single engine event
6
- // stream out to N viewers, serializes a single controller's drive commands, and
7
- // routes blocking extension dialogs. The engine has no terminal of its own; a
6
+ // stream out to N viewers, serializes the drive commands of EVERY writable
7
+ // viewer through one frame loop, and fans blocking extension dialogs out to all
8
+ // of them (first answer wins). The engine has no terminal of its own; a
8
9
  // tmux pane (or web tab) is only a viewer of this socket. It
9
10
  // runs one turn-cycle and, when the engine settles (the stophook calls
10
11
  // ctx.shutdown(), or the engine goes idle), disposes the engine and exits 0 —
@@ -95,8 +96,8 @@ export function isUnknownModel(model) {
95
96
  model.api === 'unknown');
96
97
  }
97
98
  /**
98
- * Route a controller `prompt`/`follow_up` frame against the LIVE session state.
99
- * The controller picks its frame type off a possibly-STALE `isStreaming`
99
+ * Route a writable client's `prompt`/`follow_up` frame against the LIVE session
100
+ * state. The client picks its frame type off a possibly-STALE `isStreaming`
100
101
  * snapshot, so the broker is authoritative and the client's choice is a HINT:
101
102
  *
102
103
  * - m-B (streaming-safe prompt): a `prompt` arriving mid-stream needs
@@ -378,10 +379,10 @@ const MAX_PENDING_BYTES = 32 * 1024 * 1024; // 32 MiB
378
379
  * after its message_end). */
379
380
  const MESSAGE_UPDATE_COALESCE_MS = 75;
380
381
  /** Broker-side default dialog timeout (C2 anti-deadlock, T4). When an extension
381
- * dialog is forwarded to a controller, the broker ALWAYS arms a timeout (this
382
- * default, or a shorter per-dialog `opts.timeout` if the extension passed one)
383
- * so a controller that never answers — or detaches and is never replaced — can
384
- * never hang the agent turn forever. On fire it resolves to the SAFE default
382
+ * dialog is forwarded to every writable client, the broker ALWAYS arms a timeout
383
+ * (this default, or a shorter per-dialog `opts.timeout` if the extension passed
384
+ * one) so unanswered dialogs — including ones whose writable viewers all detach
385
+ * — can never hang the agent turn forever. On fire it resolves to the SAFE default
385
386
  * (deny for confirm; cancel/undefined for select/input/editor). */
386
387
  const DEFAULT_DIALOG_TIMEOUT_MS = 120_000; // 120 s
387
388
  /** Admission bound for `runReplacement` (one active head + at most one queued
@@ -571,7 +572,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
571
572
  // survives a session replacement (new_session/switch_session/fork), not only at
572
573
  // boot.
573
574
  // -------------------------------------------------------------------------
574
- // Socket fan-out + controller arbitration state
575
+ // Socket fan-out state
575
576
  // -------------------------------------------------------------------------
576
577
  const sockPath = viewSocketPath(nodeId);
577
578
  const attachPath = join(jobDir(nodeId), 'attach.json');
@@ -585,36 +586,32 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
585
586
  const displayStatuses = new Map();
586
587
  const displayWidgets = new Map();
587
588
  let displayTitle;
588
- let controllerId = null;
589
589
  let disposed = false;
590
590
  let agentRunActive = false;
591
591
  let shutdownRequested = false;
592
592
  let server;
593
- // Liveness-aware: a controllerId whose client's transport is already gone counts
594
- // as NO controller, so control self-heals the instant a controller's peer departs
595
- // — WITHOUT waiting for the socket 'close' event. On a unix socket a peer
596
- // destroy() delivers EOF (readableEnded) promptly, but the matching 'close' can
597
- // lag arbitrarily while undrainable pending writes to the gone peer flush; under
598
- // load that lag stranded control on a dead client and froze admission (a fresh
599
- // controller hello was denied for the whole window — the one-writer reattach
600
- // deadlock the G9 gate locks). Treating an ended/destroyed/unwritable holder as
601
- // free closes that window at every read of the controller (admission included).
602
- const controllerClient = () => {
603
- if (controllerId === null)
604
- return null;
593
+ // Every client whose `hello` claimed the writable role and whose transport is
594
+ // still usable. Liveness matters because on a unix socket a peer destroy()
595
+ // delivers EOF (readableEnded) promptly while the matching 'close' can lag
596
+ // arbitrarily behind undrainable pending writes — a departed peer must not be
597
+ // counted as an answerer a dialog fan-out waits on. Roles are FIXED per client,
598
+ // so this is a pure filter, never an arbitration.
599
+ const writableClients = () => {
600
+ const out = [];
605
601
  for (const c of clients) {
606
- if (c.id !== controllerId)
602
+ if (!c.helloed || c.role !== 'controller')
607
603
  continue;
608
604
  if (c.socket.destroyed || c.socket.readableEnded || !c.socket.writable)
609
- return null;
610
- return c;
605
+ continue;
606
+ out.push(c);
611
607
  }
612
- return null;
608
+ return out;
613
609
  };
614
- // Persist viewer presence to job/attach.json on every viewer state change
615
- // (hello accepted, client drop/shed, control handoff) so out-of-process
610
+ // Persist viewer presence to job/attach.json when the set of helloed viewers
611
+ // changes (a hello is accepted, or a client drops/is shed) so out-of-process
616
612
  // readers (the GRAPH view's attached-row tint) can see whether a human is
617
- // watching this paneless node. Plain writeFileSync, matching telemetry.json's
613
+ // watching this paneless node. Presence is a truthful COUNT and nothing more —
614
+ // there is no owner to record. Plain writeFileSync, matching telemetry.json's
618
615
  // convention; best-effort — presence writing must never crash the broker.
619
616
  // disposeAndExit unlinks the file, so a clean exit never leaves a stale claim
620
617
  // (readers additionally trust it only while the node is 'active', fencing off
@@ -628,25 +625,15 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
628
625
  const dirPath = jobDir(nodeId);
629
626
  if (!existsSync(dirPath))
630
627
  mkdirSync(dirPath, { recursive: true });
631
- writeFileSync(attachPath, JSON.stringify({ viewers, controller_id: controllerId, updated: new Date().toISOString() }, null, 2), 'utf8');
628
+ writeFileSync(attachPath, JSON.stringify({ viewers, updated: new Date().toISOString() }, null, 2), 'utf8');
632
629
  }
633
630
  catch {
634
631
  /* presence is best-effort; never crash the broker */
635
632
  }
636
633
  };
637
- // Free control if the departing/dropped client held it (shared by drop +
638
- // dropSlowClient). controllerId can outlive the socket until 'close' fires, so
639
- // releasing here keeps arbitration correct the instant a controller is shed.
640
- const releaseControlIfHeldBy = (client) => {
641
- if (client.id !== '' && client.id === controllerId) {
642
- controllerId = null;
643
- broadcastControlChanged();
644
- }
645
- };
646
- // Shed a client — destroy the socket + remove it + release its control — used by
647
- // both the M1 backpressure drop and the G7 frame-overflow drop. 'close' (→ drop)
648
- // follows the destroy; releasing control here makes the shed immediate so a
649
- // misbehaving controller can't keep arbitration pinned until 'close' fires.
634
+ // Shed a client — destroy the socket + remove it — used by both the M1
635
+ // backpressure drop and the G7 frame-overflow drop. 'close' (→ drop) follows
636
+ // the destroy.
650
637
  const dropClient = (client, reason) => {
651
638
  if (!clients.has(client))
652
639
  return; // already gone
@@ -661,7 +648,6 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
661
648
  });
662
649
  clients.delete(client);
663
650
  persistAttachState(); // a viewer was shed
664
- releaseControlIfHeldBy(client);
665
651
  try {
666
652
  client.socket.destroy();
667
653
  }
@@ -1325,10 +1311,6 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1325
1311
  flushPendingUpdate();
1326
1312
  broadcast(event);
1327
1313
  };
1328
- const broadcastControlChanged = () => {
1329
- persistAttachState(); // every control change is a viewer-state change
1330
- broadcast({ type: 'control_changed', controller_id: controllerId });
1331
- };
1332
1314
  // Persist a live model switch into the node's durable launch recipe so it
1333
1315
  // survives a yield/revive. pi's `/model` (→ set_model/cycle_model) only
1334
1316
  // mutates the in-memory engine; without this the node reverts to its
@@ -1413,17 +1395,16 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1413
1395
  },
1414
1396
  });
1415
1397
  // Send a client its catch-up snapshot. welcome.pending_dialog carries a single
1416
- // still-in-flight dialog to a controller attaching mid-dialog (T4); only the
1417
- // controller can answer one, so observers get null. The pendingDialogs map is
1398
+ // still-in-flight dialog to a WRITABLE client attaching mid-dialog; an observer
1399
+ // can never answer one, so observers get null. The pendingDialogs map is
1418
1400
  // insertion-ordered — the first entry is the canonical one carried here; any
1419
- // extras are re-routed explicitly by the caller (rare: concurrent dialogs).
1401
+ // extras are replayed explicitly by the caller (rare: concurrent dialogs).
1420
1402
  const sendWelcome = (client) => {
1421
1403
  const first = client.role === 'controller' ? pendingDialogs.values().next().value : undefined;
1422
1404
  sendFrame(client, {
1423
1405
  type: 'welcome',
1424
1406
  snapshot: buildSnapshot(),
1425
1407
  role: client.role,
1426
- controller_id: controllerId,
1427
1408
  pending_dialog: first !== undefined ? first.request : null,
1428
1409
  agentDir: getAgentDir(),
1429
1410
  }, true);
@@ -1432,13 +1413,15 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1432
1413
  // after an idle brief was painted still sees it on first paint, and there is
1433
1414
  // no window between `welcome` and the replay where chrome renders blank.
1434
1415
  };
1435
- // T4 re-route on become-controller: a dialog raised while a prior controller was
1436
- // attached stays pending after that controller detaches (it is NOT cancelled —
1437
- // see makeBrokerUiContext / the M2 keep-pending fix), so whoever takes control
1438
- // next must be handed it to answer.
1439
- const reroutePendingDialogsTo = (client) => {
1440
- for (const d of pendingDialogs.values())
1441
- sendFrame(client, d.request);
1416
+ // Attach-mid-dialog replay: a dialog raised before this client attached is
1417
+ // still pending (a peer detaching never cancels one — see makeBrokerUiContext),
1418
+ // so a writable client joining afterwards is handed every in-flight dialog and
1419
+ // becomes a full peer in the race to answer it. `welcome.pending_dialog` already
1420
+ // carried the FIRST entry, so this replays only the extras.
1421
+ const replayExtraPendingDialogsTo = (client) => {
1422
+ const pend = [...pendingDialogs.values()];
1423
+ for (let i = 1; i < pend.length; i++)
1424
+ sendFrame(client, pend[i].request);
1442
1425
  };
1443
1426
  // After a session-replacing op (new_session/switch_session/fork) the engine's
1444
1427
  // entire message history changed, so every attached viewer must rebuild from a
@@ -1511,14 +1494,15 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1511
1494
  };
1512
1495
  // -------------------------------------------------------------------------
1513
1496
  // Extension-dialog routing (C2). makeBrokerUiContext owns the dialogPromise;
1514
- // here we just give it the three broker-side hooks it needs: who the current
1515
- // controller is (null = zero viewers → noOp fallback), how to forward a dialog
1516
- // to that controller, and the pending-dialog registry the controller answers
1517
- // through. The zero-viewer path NEVER hangs and NEVER waits on a per-dialog
1518
- // timeout (design §5.4's timeout premise is false — see makeBrokerUiContext).
1497
+ // here we just give it the three broker-side hooks it needs: the live set of
1498
+ // writable clients (empty = nobody can answer → noOp fallback), how to forward a
1499
+ // dialog to one of them, and the pending-dialog registry whichever one answers
1500
+ // first settles through. The zero-writable path NEVER hangs and NEVER waits on a
1501
+ // per-dialog timeout (design §5.4's timeout premise is false — see
1502
+ // makeBrokerUiContext).
1519
1503
  // -------------------------------------------------------------------------
1520
1504
  const uiContext = makeBrokerUiContext({
1521
- controller: controllerClient,
1505
+ writable: writableClients,
1522
1506
  forward: (client, request) => sendFrame(client, request),
1523
1507
  pending: pendingDialogs,
1524
1508
  broadcast: broadcastUi,
@@ -1731,7 +1715,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1731
1715
  runtime?.setRebindSession((candidateSession) => enqueueRebind(candidateSession, runtime.services));
1732
1716
  await enqueueRebind(session, services);
1733
1717
  // -------------------------------------------------------------------------
1734
- // Drive the engine on behalf of the single controller.
1718
+ // Drive the engine on behalf of a writable client.
1735
1719
  // -------------------------------------------------------------------------
1736
1720
  // ---------------------------------------------------------------------------
1737
1721
  // Inline memory-reference inventory (design: inline memory references) — a
@@ -1949,21 +1933,23 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1949
1933
  }
1950
1934
  };
1951
1935
  // -------------------------------------------------------------------------
1952
- // Command-op helpers (T3, §2.3). The controller guard is hoisted here (it was
1953
- // inlined twice) and reused by all controller-only ops; the ack/error replies
1936
+ // Command-op helpers (T3, §2.3). The write guard is hoisted here (it was
1937
+ // inlined twice) and reused by all mutating ops; the ack/error replies
1954
1938
  // and a few resolvers keep the per-op cases one-liners.
1955
1939
  // -------------------------------------------------------------------------
1956
- /** Reject a non-controller for a controller-only op. Returns true when rejected
1957
- * (the caller should `break`). */
1958
- const notController = (client, what, id) => {
1959
- if (client.id === controllerId)
1940
+ /** Reject a read-only client for a mutating op. The decision reads this
1941
+ * client's OWN fixed role — there is no shared writer slot, so any number of
1942
+ * writable clients pass concurrently. Returns true when rejected (the caller
1943
+ * should `break`). */
1944
+ const notWritable = (client, what, id) => {
1945
+ if (client.role === 'controller')
1960
1946
  return false;
1961
1947
  sendFrame(client, {
1962
1948
  type: 'error',
1963
- code: 'not_controller',
1964
- message: `only the controlling client may ${what}`,
1949
+ code: 'read_only',
1950
+ message: `a read-only client may not ${what}`,
1965
1951
  // M1: echo a correlated request's id so its pending-by-id promise rejects
1966
- // rather than hanging (e.g. a non-controller `dequeue`).
1952
+ // rather than hanging (e.g. a read-only `dequeue`).
1967
1953
  ...(id !== undefined ? { id } : {}),
1968
1954
  });
1969
1955
  return true;
@@ -2083,7 +2069,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2083
2069
  // -------------------------------------------------------------------------
2084
2070
  // Read-op data builders (operator-view picker payloads, §5 Unit A). Each is a
2085
2071
  // PURE getter read against the live engine session — the data a native pi
2086
- // picker's constructor needs, serialized for the viewer. Not controller-gated
2072
+ // picker's constructor needs, serialized for the viewer. Not role-gated
2087
2073
  // (read-only, like get_commands), so the web bridge's observer connection can
2088
2074
  // populate pickers too.
2089
2075
  // -------------------------------------------------------------------------
@@ -2306,29 +2292,28 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2306
2292
  const handleFrame = (client, frame) => {
2307
2293
  switch (frame.type) {
2308
2294
  case 'hello': {
2295
+ // Admit at the REQUESTED role — unconditionally. `controller` is a
2296
+ // per-client write capability, not a singleton slot, so an arriving
2297
+ // writable client never demotes (or is demoted by) an already-attached
2298
+ // one: N terminal panes and browser tabs are all writable at once.
2299
+ //
2300
+ // A repeated hello on an already-helloed socket must not RE-role it: the
2301
+ // role is fixed for the socket's lifetime, so every downstream gate and
2302
+ // every already-fanned-out dialog keep the authority the first welcome
2303
+ // stated. A duplicate hello still re-sends the catch-up snapshot.
2304
+ const firstHello = !client.helloed;
2309
2305
  client.id = frame.client_id;
2310
- client.helloed = true;
2311
- // First-attach-wins (§5.3), but only against a LIVE controller: admit as
2312
- // controller iff none is currently held by a live client (controllerClient
2313
- // is liveness-aware, so a controllerId stranded on a departed peer reads as
2314
- // free here). Otherwise read-only observer.
2315
- if (frame.role === 'controller' && controllerClient() === null) {
2316
- client.role = 'controller';
2317
- controllerId = client.id;
2318
- }
2319
- else {
2320
- client.role = 'observer';
2306
+ if (firstHello) {
2307
+ client.helloed = true;
2308
+ client.role = frame.role === 'controller' ? 'controller' : 'observer';
2321
2309
  }
2322
2310
  sendWelcome(client);
2323
- persistAttachState(); // a helloed viewer arrived
2324
- if (client.role === 'controller') {
2325
- // welcome carried the FIRST pending dialog (T4); forward any extras so a
2326
- // controller attaching mid-dialog can answer every in-flight dialog.
2327
- const pend = [...pendingDialogs.values()];
2328
- for (let i = 1; i < pend.length; i++)
2329
- sendFrame(client, pend[i].request);
2330
- broadcastControlChanged();
2331
- }
2311
+ if (firstHello)
2312
+ persistAttachState(); // a helloed viewer arrived
2313
+ // welcome carried the FIRST pending dialog; replay any extras so a writable
2314
+ // client attaching mid-dialog can answer every in-flight dialog.
2315
+ if (client.role === 'controller')
2316
+ replayExtraPendingDialogsTo(client);
2332
2317
  break;
2333
2318
  }
2334
2319
  case 'prompt':
@@ -2336,7 +2321,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2336
2321
  case 'follow_up':
2337
2322
  case 'abort':
2338
2323
  case 'bash': {
2339
- if (notController(client, 'drive the engine'))
2324
+ if (notWritable(client, 'drive the engine'))
2340
2325
  break;
2341
2326
  driveEngine(client, frame);
2342
2327
  break;
@@ -2361,7 +2346,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2361
2346
  // race between acceptance (agent_start) and rejection; a steer route
2362
2347
  // joins a turn already running, so no new agent_start is coming and its
2363
2348
  // ack stays at routing time.
2364
- if (notController(client, 'drive the engine', frame.id))
2349
+ if (notWritable(client, 'drive the engine', frame.id))
2365
2350
  break;
2366
2351
  const via = session.isStreaming ? 'steer' : 'prompt';
2367
2352
  let delivered = false;
@@ -2400,46 +2385,17 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2400
2385
  break;
2401
2386
  }
2402
2387
  case 'extension_ui_response': {
2403
- if (notController(client, 'answer dialogs'))
2388
+ if (notWritable(client, 'answer dialogs'))
2404
2389
  break;
2390
+ // First response for this id wins: `resolve` settles the entry and deletes
2391
+ // it, so every later response from a peer that was also fanned this dialog
2392
+ // finds nothing here and is a silent no-op.
2405
2393
  pendingDialogs.get(frame.id)?.resolve(frame);
2406
2394
  break;
2407
2395
  }
2408
- case 'request_control': {
2409
- // §D preemptive handoff (last-requester-wins): a control request ALWAYS
2410
- // succeeds, reassigning control to the requester and demoting the prior
2411
- // controller to observer. This makes a tmux pane and a web tab true peers —
2412
- // either can take control of a node the other currently drives — which is
2413
- // the broker-is-the-host invariant (the prior cooperative-only model could
2414
- // not preempt an idle/abandoned controller, the common case). The prior
2415
- // controller demotes itself on receiving the control_changed broadcast
2416
- // (viewer.ts already does this; the web client implements the same
2417
- // rule). Idempotent when the requester already holds control.
2418
- if (client.id === controllerId)
2419
- break;
2420
- const prior = controllerClient();
2421
- if (prior !== null)
2422
- prior.role = 'observer';
2423
- controllerId = client.id;
2424
- client.role = 'controller';
2425
- broadcastControlChanged();
2426
- reroutePendingDialogsTo(client); // T4: hand the new controller pending dialogs
2427
- break;
2428
- }
2429
- case 'release_control': {
2430
- if (client.id === controllerId) {
2431
- controllerId = null;
2432
- client.role = 'observer';
2433
- // M2 (T4): do NOT cancel in-flight dialogs on release — keep them pending
2434
- // under the broker-side default timeout so a brief release/reattach (or a
2435
- // handoff to another observer) never loses an answerable dialog.
2436
- broadcastControlChanged();
2437
- }
2438
- break;
2439
- }
2440
2396
  // --- extended engine-command ops (T3, §1.2 floor set) ------------------
2441
2397
  case 'set_model': {
2442
- if (notController(client, 'set the model'))
2398
+ if (notWritable(client, 'set the model'))
2443
2399
  break;
2444
2400
  const requested = parseModelSpec(frame.model);
2445
2401
  let model;
@@ -2486,7 +2442,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2486
2442
  break;
2487
2443
  }
2488
2444
  case 'deliver_custom_message': {
2489
- if (notController(client, 'deliver a custom message'))
2445
+ if (notWritable(client, 'deliver a custom message'))
2490
2446
  break;
2491
2447
  // Never triggers a turn either way. `nextTurn` folds the message into
2492
2448
  // whatever turn comes next (situational context); omitting it pushes
@@ -2506,7 +2462,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2506
2462
  break;
2507
2463
  }
2508
2464
  case 'cycle_model': {
2509
- if (notController(client, 'cycle the model'))
2465
+ if (notWritable(client, 'cycle the model'))
2510
2466
  break;
2511
2467
  void session
2512
2468
  .cycleModel(frame.direction)
@@ -2518,7 +2474,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2518
2474
  break;
2519
2475
  }
2520
2476
  case 'cycle_ladder': {
2521
- if (notController(client, 'cycle the model ladder'))
2477
+ if (notWritable(client, 'cycle the model ladder'))
2522
2478
  break;
2523
2479
  // Resolve the next interleaved-ladder rung from the current model+thinking
2524
2480
  // spec, then reuse the set_model path (registry resolve + thinking apply).
@@ -2567,7 +2523,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2567
2523
  break;
2568
2524
  }
2569
2525
  case 'cycle_thinking': {
2570
- if (notController(client, 'cycle the thinking level'))
2526
+ if (notWritable(client, 'cycle the thinking level'))
2571
2527
  break;
2572
2528
  try {
2573
2529
  session.cycleThinkingLevel();
@@ -2579,7 +2535,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2579
2535
  break;
2580
2536
  }
2581
2537
  case 'dequeue': {
2582
- if (notController(client, 'dequeue messages', frame.id))
2538
+ if (notWritable(client, 'dequeue messages', frame.id))
2583
2539
  break;
2584
2540
  try {
2585
2541
  const { steering, followUp } = session.clearQueue();
@@ -2591,7 +2547,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2591
2547
  break;
2592
2548
  }
2593
2549
  case 'set_thinking_level': {
2594
- if (notController(client, 'set the thinking level'))
2550
+ if (notWritable(client, 'set the thinking level'))
2595
2551
  break;
2596
2552
  try {
2597
2553
  session.setThinkingLevel(frame.level);
@@ -2603,7 +2559,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2603
2559
  break;
2604
2560
  }
2605
2561
  case 'set_auto_retry': {
2606
- if (notController(client, 'set auto-retry'))
2562
+ if (notWritable(client, 'set auto-retry'))
2607
2563
  break;
2608
2564
  try {
2609
2565
  session.setAutoRetryEnabled(frame.enabled);
@@ -2615,7 +2571,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2615
2571
  break;
2616
2572
  }
2617
2573
  case 'set_auto_compaction': {
2618
- if (notController(client, 'set auto-compaction'))
2574
+ if (notWritable(client, 'set auto-compaction'))
2619
2575
  break;
2620
2576
  try {
2621
2577
  session.setAutoCompactionEnabled(frame.enabled);
@@ -2627,7 +2583,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2627
2583
  break;
2628
2584
  }
2629
2585
  case 'compact': {
2630
- if (notController(client, 'compact the session'))
2586
+ if (notWritable(client, 'compact the session'))
2631
2587
  break;
2632
2588
  void session
2633
2589
  .compact(frame.instructions)
@@ -2636,7 +2592,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2636
2592
  break;
2637
2593
  }
2638
2594
  case 'set_session_name': {
2639
- if (notController(client, 'rename the session'))
2595
+ if (notWritable(client, 'rename the session'))
2640
2596
  break;
2641
2597
  try {
2642
2598
  session.setSessionName(frame.name);
@@ -2747,7 +2703,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2747
2703
  break;
2748
2704
  }
2749
2705
  case 'navigate_tree': {
2750
- if (notController(client, 'navigate the session tree'))
2706
+ if (notWritable(client, 'navigate the session tree'))
2751
2707
  break;
2752
2708
  // navigateTree rewinds IN-PLACE (same session file, new leaf) and emits no
2753
2709
  // relayed event, so every viewer must be re-snapshotted onto the rewound
@@ -2765,7 +2721,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2765
2721
  break;
2766
2722
  }
2767
2723
  case 'reload': {
2768
- if (notController(client, 'reload'))
2724
+ if (notWritable(client, 'reload'))
2769
2725
  break;
2770
2726
  // A SUCCESSFUL reload invalidates the memoized ref inventory (work item
2771
2727
  // 3) so the next read-op/submission re-walks the corpus, AND clears the
@@ -2784,7 +2740,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2784
2740
  break;
2785
2741
  }
2786
2742
  case 'export': {
2787
- if (notController(client, 'export the session'))
2743
+ if (notWritable(client, 'export the session'))
2788
2744
  break;
2789
2745
  if (frame.format === 'jsonl') {
2790
2746
  try {
@@ -2805,25 +2761,25 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2805
2761
  break;
2806
2762
  }
2807
2763
  case 'new_session': {
2808
- if (notController(client, 'start a new session'))
2764
+ if (notWritable(client, 'start a new session'))
2809
2765
  break;
2810
2766
  runReplacement(client, 'new_session', (rt) => rt.newSession());
2811
2767
  break;
2812
2768
  }
2813
2769
  case 'switch_session': {
2814
- if (notController(client, 'switch sessions'))
2770
+ if (notWritable(client, 'switch sessions'))
2815
2771
  break;
2816
2772
  runReplacement(client, 'switch_session', (rt) => rt.switchSession(frame.path));
2817
2773
  break;
2818
2774
  }
2819
2775
  case 'fork': {
2820
- if (notController(client, 'fork the session'))
2776
+ if (notWritable(client, 'fork the session'))
2821
2777
  break;
2822
2778
  runReplacement(client, 'fork', (rt) => rt.fork(frame.entryId));
2823
2779
  break;
2824
2780
  }
2825
2781
  case 'clone': {
2826
- if (notController(client, 'clone the session'))
2782
+ if (notWritable(client, 'clone the session'))
2827
2783
  break;
2828
2784
  runReplacement(client, 'clone', async (rt) => {
2829
2785
  const sm = session.sessionManager;
@@ -2840,7 +2796,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2840
2796
  break;
2841
2797
  }
2842
2798
  case 'share': {
2843
- if (notController(client, 'share the session'))
2799
+ if (notWritable(client, 'share the session'))
2844
2800
  break;
2845
2801
  const tmpPath = join(tmpdir(), `pi-share-${Date.now()}.html`);
2846
2802
  void session.exportToHtml(tmpPath)
@@ -2870,13 +2826,11 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2870
2826
  break;
2871
2827
  }
2872
2828
  case 'reload_auth': {
2873
- // Open to any client: reload_auth is an idempotent local re-read of the
2874
- // shared auth.json + a model-registry refresh. It does NOT steer the
2875
- // conversation, so there is nothing to gate behind controller. Gating it
2876
- // forced callers (notably the daemon's canvas-wide fan, which propagates
2877
- // a single /login to every live broker) to first request_control, which
2878
- // ALWAYS preempts — silently demoting any attached human to observer on
2879
- // every login. Letting an observer trigger this is the safer default.
2829
+ // Open to any client, observers included: reload_auth is an idempotent
2830
+ // local re-read of the shared auth.json + a model-registry refresh. It
2831
+ // does NOT steer the conversation, so there is nothing to role-gate —
2832
+ // which is what lets the daemon's canvas-wide fan (one /login propagated
2833
+ // to every live broker) ride an observer connection.
2880
2834
  try {
2881
2835
  // pi 0.82: crouter's CredentialStore reads auth.json fresh under the
2882
2836
  // lock on every call, so there is no cached credential view left to
@@ -3035,12 +2989,10 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
3035
2989
  const drop = () => {
3036
2990
  clients.delete(client);
3037
2991
  persistAttachState(); // a viewer disconnected
3038
- // M2 (T4): controller detach frees control but does NOT cancel in-flight
3039
- // dialogs — they stay pending under the broker-side default timeout so a
3040
- // brief detach/reattach (or a handoff to another observer who takes control)
3041
- // never loses an answerable dialog. Only the timeout or a new controller's
3042
- // answer resolves one.
3043
- releaseControlIfHeldBy(client);
2992
+ // A writable client detaching does NOT cancel in-flight dialogs — they stay
2993
+ // pending under the broker-side default timeout, so a peer that is still
2994
+ // attached (or one that attaches next and gets the replay) can still answer.
2995
+ // Only an answer, the timeout, or an abort resolves one.
3044
2996
  };
3045
2997
  socket.on('close', drop);
3046
2998
  socket.on('error', () => {
@@ -3599,26 +3551,33 @@ export function makeBrokerUiContext(deps) {
3599
3551
  // OPTIONAL on dialog opts, editor() takes none, and almost no real extension
3600
3552
  // passes one (permission-gate / confirm-destructive / plan-mode / subagent all
3601
3553
  // omit it). A timeout-reliant unattended node therefore deadlocks the agent
3602
- // turn FOREVER. So with ZERO viewers attached we fall back to the SDK's noOp UI
3603
- // behavior — resolve to the default (deny / cancel / undefined) IMMEDIATELY,
3604
- // never arming a timer, never waiting. (Phase 4 adds the WITH-viewer forwarding
3605
- // path, wrapped in a broker-side timeout+abort so a controller that attaches
3606
- // but never answers cannot hang the turn either.)
3554
+ // turn FOREVER. So with NO WRITABLE client attached we fall back to the SDK's
3555
+ // noOp UI behavior — resolve to the default (deny / cancel / undefined)
3556
+ // IMMEDIATELY, never arming a timer, never waiting. With one or more, the dialog
3557
+ // is fanned out to all of them under a broker-side timeout+abort, so viewers
3558
+ // that attach but never answer cannot hang the turn either.
3607
3559
  const dialogPromise = (defaultValue, request, parse, opts) => {
3608
3560
  if (opts?.signal?.aborted)
3609
3561
  return Promise.resolve(defaultValue);
3610
- const controller = deps.controller();
3611
- // C2 (Wave-0, KEEP): no controller at raise time → noOp, resolved at once. No
3612
- // timer, no wait, no deadlock. This is the genuine zero-controller path.
3613
- if (controller === null)
3562
+ // Snapshot the writable set ONCE, at raise time — the fan-out targets and the
3563
+ // zero-answerer decision must agree.
3564
+ const targets = deps.writable();
3565
+ // C2 (Wave-0, KEEP): nobody can answer at raise time → noOp, resolved at once.
3566
+ // No timer, no wait, no deadlock.
3567
+ if (targets.length === 0)
3614
3568
  return Promise.resolve(defaultValue);
3615
- // A controller is attached: forward the dialog, register it (so a re-routed /
3616
- // re-attaching controller can answer it — T4), and ALWAYS arm a broker-side
3617
- // timeout (T4/C2 anti-deadlock): a controller that never answers — or detaches
3618
- // and is never replaced — can never hang the turn. Honor a shorter per-dialog
3619
- // timeout if the extension passed one; otherwise the broker default. On fire
3620
- // it resolves to the SAFE default (deny/cancel/undefined). NOTE: controller
3621
- // detach does NOT cancel this (M2) — only an answer, the timeout, or an abort.
3569
+ // At least one writable client: register the dialog FIRST (so a client that
3570
+ // attaches mid-dialog gets it replayed and joins the race), fan it out to every
3571
+ // target, and ALWAYS arm a broker-side timeout (C2 anti-deadlock): viewers that
3572
+ // never answer — or all detach — can never hang the turn. Honor a shorter
3573
+ // per-dialog timeout if the extension passed one; otherwise the broker default.
3574
+ // On fire it resolves to the SAFE default (deny/cancel/undefined). A detach
3575
+ // does NOT cancel this — only an answer, the timeout, or an abort.
3576
+ //
3577
+ // EVERY settlement path runs the same `cleanup` (drop the entry, so a later
3578
+ // answer for this id is a no-op) and then broadcasts `extension_ui_dismiss`
3579
+ // keyed to this id, so all peers close their copy of exactly this dialog and
3580
+ // nothing else.
3622
3581
  return new Promise((resolve) => {
3623
3582
  let timer;
3624
3583
  const cleanup = () => {
@@ -3631,10 +3590,10 @@ export function makeBrokerUiContext(deps) {
3631
3590
  cleanup();
3632
3591
  // The extension aborted this request out-of-band (e.g. an OAuth loopback
3633
3592
  // callback won the race against a still-open manual-paste dialog). Tell
3634
- // the controller to tear down THIS overlay by id — an unanswered dialog
3635
- // whose broker entry we just dropped would otherwise linger onscreen with
3636
- // nothing left to answer it. Keyed to request.id so only the abandoned
3637
- // dialog is dismissed, never an unrelated one.
3593
+ // every peer to tear down THIS overlay by id — an unanswered dialog whose
3594
+ // broker entry we just dropped would otherwise linger onscreen with nothing
3595
+ // left to answer it. Keyed to request.id so only the abandoned dialog is
3596
+ // dismissed, never an unrelated one.
3638
3597
  deps.broadcast({ type: 'extension_ui_dismiss', id: request.id });
3639
3598
  resolve(defaultValue);
3640
3599
  };
@@ -3647,8 +3606,8 @@ export function makeBrokerUiContext(deps) {
3647
3606
  timer = setTimeout(() => {
3648
3607
  cleanup();
3649
3608
  // Same correlated teardown as onAbort: the broker resolved this dialog on
3650
- // its own timeout, so the controller's overlay (which has no independent
3651
- // timer) must be dismissed by id or it lingers forever.
3609
+ // its own timeout, so every peer's overlay (none of which has an
3610
+ // independent timer) must be dismissed by id or it lingers forever.
3652
3611
  deps.broadcast({ type: 'extension_ui_dismiss', id: request.id });
3653
3612
  resolve(defaultValue);
3654
3613
  }, ms);
@@ -3658,10 +3617,16 @@ export function makeBrokerUiContext(deps) {
3658
3617
  request,
3659
3618
  resolve: (r) => {
3660
3619
  cleanup();
3620
+ // The first answer settled it; tell every OTHER client that was fanned
3621
+ // this dialog (and the answerer, for whom it is an idempotent no-op) to
3622
+ // close its copy. Without this, peers would sit on an overlay whose
3623
+ // answer can never arrive.
3624
+ deps.broadcast({ type: 'extension_ui_dismiss', id: request.id });
3661
3625
  resolve(parse(r));
3662
3626
  },
3663
3627
  });
3664
- deps.forward(controller, request);
3628
+ for (const target of targets)
3629
+ deps.forward(target, request);
3665
3630
  });
3666
3631
  };
3667
3632
  const noop = () => { };