@north-light/crouter 0.3.154 → 0.3.157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +2 -1
  2. package/dist/api/client.d.ts +1 -4
  3. package/dist/api/client.js +0 -5
  4. package/dist/api/dto/broker.d.ts +1 -1
  5. package/dist/api/dto/nodes.d.ts +0 -15
  6. package/dist/api/routes.d.ts +0 -1
  7. package/dist/api/routes.js +0 -1
  8. package/dist/builtin-views/chat/core.mjs +51 -6
  9. package/dist/builtin-views/chat/tui.mjs +14 -6
  10. package/dist/builtin-views/chat/web.jsx +7 -2
  11. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
  12. package/dist/clients/attach/chrome/roster.d.ts +1 -1
  13. package/dist/clients/attach/chrome/roster.js +1 -9
  14. package/dist/clients/attach/command.js +5 -5
  15. package/dist/clients/attach/input/controller.d.ts +8 -23
  16. package/dist/clients/attach/input/controller.js +29 -59
  17. package/dist/clients/attach/overlays/dialogs.d.ts +2 -1
  18. package/dist/clients/attach/overlays/graph.d.ts +1 -4
  19. package/dist/clients/attach/overlays/graph.js +7 -25
  20. package/dist/clients/attach/session/context.d.ts +0 -7
  21. package/dist/clients/attach/session/frames.js +8 -1
  22. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  23. package/dist/clients/attach/session/input-wiring.js +11 -21
  24. package/dist/clients/attach/session/mode.d.ts +3 -4
  25. package/dist/clients/attach/session/mode.js +1 -6
  26. package/dist/clients/attach/session/reconnect.d.ts +3 -3
  27. package/dist/clients/attach/session/reconnect.js +7 -6
  28. package/dist/clients/attach/session/state-sync.d.ts +1 -1
  29. package/dist/clients/attach/session/state-sync.js +2 -2
  30. package/dist/clients/attach/slash/dispatch.d.ts +1 -13
  31. package/dist/clients/attach/slash/dispatch.js +17 -65
  32. package/dist/clients/attach/viewer.js +523 -523
  33. package/dist/clients/web/web-client/shared/protocol.d.ts +3 -5
  34. package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
  35. package/dist/core/__tests__/chat-view-reconnect.test.js +23 -44
  36. package/dist/core/__tests__/full/broker-attach-limits.test.js +36 -60
  37. package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
  38. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  39. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  40. package/dist/core/__tests__/full/broker-dialogs.test.js +62 -121
  41. package/dist/core/__tests__/helpers/broker-clients.js +2 -2
  42. package/dist/core/__tests__/session-model.test.js +26 -15
  43. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  44. package/dist/core/keybindings/catalog.d.ts +2 -2
  45. package/dist/core/keybindings/catalog.js +2 -0
  46. package/dist/core/runtime/auth-reload.d.ts +4 -4
  47. package/dist/core/runtime/auth-reload.js +13 -9
  48. package/dist/core/runtime/boot-root.d.ts +2 -2
  49. package/dist/core/runtime/boot-root.js +7 -7
  50. package/dist/core/runtime/broker-protocol.d.ts +27 -23
  51. package/dist/core/runtime/broker-protocol.js +1 -1
  52. package/dist/core/runtime/broker-request.js +11 -5
  53. package/dist/core/runtime/broker.d.ts +13 -21
  54. package/dist/core/runtime/broker.js +186 -151
  55. package/dist/core/runtime/interactive-deliver.js +5 -4
  56. package/dist/core/runtime/model-swap.d.ts +3 -2
  57. package/dist/core/runtime/model-swap.js +4 -3
  58. package/dist/core/runtime/node-read.d.ts +0 -20
  59. package/dist/core/runtime/node-read.js +1 -34
  60. package/dist/core/runtime/resume-root.d.ts +1 -1
  61. package/dist/core/runtime/resume-root.js +6 -6
  62. package/dist/core/runtime/spawn.js +3 -3
  63. package/dist/core/session-model/session-state.d.ts +6 -8
  64. package/dist/core/session-model/session-state.js +16 -6
  65. package/dist/daemon/api/handlers/nodes.js +1 -18
  66. package/dist/daemon/manage.js +2 -2
  67. package/dist/index.d.ts +1 -1
  68. package/dist/web-client/assets/index--SsQYcKu.js +79 -0
  69. package/dist/web-client/assets/{index-CpEl9LTS.css → index-DJhQZoAj.css} +1 -1
  70. package/dist/web-client/index.html +2 -2
  71. package/dist/web-client/sw.js +1 -1
  72. package/docs/compat/hearth-crtr-v1.md +1 -1
  73. package/docs/compat/hearth-crtr-v2.md +1 -1
  74. package/docs/compat/hearth-crtr-v3.md +1 -1
  75. package/docs/compat/hearth-crtr-v4.md +3 -1
  76. package/docs/public-api.md +2 -2
  77. package/package.json +4 -4
  78. package/runtime.lock.json +2 -2
  79. package/dist/web-client/assets/index-BpyZGBhI.js +0 -79
  80. package/docs/compat/hearth-crtr-v5.md +0 -175
@@ -3,9 +3,8 @@
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 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
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
9
8
  // tmux pane (or web tab) is only a viewer of this socket. It
10
9
  // runs one turn-cycle and, when the engine settles (the stophook calls
11
10
  // ctx.shutdown(), or the engine goes idle), disposes the engine and exits 0 —
@@ -96,8 +95,8 @@ export function isUnknownModel(model) {
96
95
  model.api === 'unknown');
97
96
  }
98
97
  /**
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`
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`
101
100
  * snapshot, so the broker is authoritative and the client's choice is a HINT:
102
101
  *
103
102
  * - m-B (streaming-safe prompt): a `prompt` arriving mid-stream needs
@@ -379,10 +378,10 @@ const MAX_PENDING_BYTES = 32 * 1024 * 1024; // 32 MiB
379
378
  * after its message_end). */
380
379
  const MESSAGE_UPDATE_COALESCE_MS = 75;
381
380
  /** Broker-side default dialog timeout (C2 anti-deadlock, T4). When an extension
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
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
386
385
  * (deny for confirm; cancel/undefined for select/input/editor). */
387
386
  const DEFAULT_DIALOG_TIMEOUT_MS = 120_000; // 120 s
388
387
  /** Admission bound for `runReplacement` (one active head + at most one queued
@@ -572,7 +571,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
572
571
  // survives a session replacement (new_session/switch_session/fork), not only at
573
572
  // boot.
574
573
  // -------------------------------------------------------------------------
575
- // Socket fan-out state
574
+ // Socket fan-out + controller arbitration state
576
575
  // -------------------------------------------------------------------------
577
576
  const sockPath = viewSocketPath(nodeId);
578
577
  const attachPath = join(jobDir(nodeId), 'attach.json');
@@ -586,32 +585,36 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
586
585
  const displayStatuses = new Map();
587
586
  const displayWidgets = new Map();
588
587
  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
- // 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 = [];
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;
601
605
  for (const c of clients) {
602
- if (!c.helloed || c.role !== 'controller')
606
+ if (c.id !== controllerId)
603
607
  continue;
604
608
  if (c.socket.destroyed || c.socket.readableEnded || !c.socket.writable)
605
- continue;
606
- out.push(c);
609
+ return null;
610
+ return c;
607
611
  }
608
- return out;
612
+ return null;
609
613
  };
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
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
612
616
  // readers (the GRAPH view's attached-row tint) can see whether a human is
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
617
+ // watching this paneless node. Plain writeFileSync, matching telemetry.json's
615
618
  // convention; best-effort — presence writing must never crash the broker.
616
619
  // disposeAndExit unlinks the file, so a clean exit never leaves a stale claim
617
620
  // (readers additionally trust it only while the node is 'active', fencing off
@@ -625,15 +628,25 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
625
628
  const dirPath = jobDir(nodeId);
626
629
  if (!existsSync(dirPath))
627
630
  mkdirSync(dirPath, { recursive: true });
628
- writeFileSync(attachPath, JSON.stringify({ viewers, updated: new Date().toISOString() }, null, 2), 'utf8');
631
+ writeFileSync(attachPath, JSON.stringify({ viewers, controller_id: controllerId, updated: new Date().toISOString() }, null, 2), 'utf8');
629
632
  }
630
633
  catch {
631
634
  /* presence is best-effort; never crash the broker */
632
635
  }
633
636
  };
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.
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.
637
650
  const dropClient = (client, reason) => {
638
651
  if (!clients.has(client))
639
652
  return; // already gone
@@ -648,6 +661,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
648
661
  });
649
662
  clients.delete(client);
650
663
  persistAttachState(); // a viewer was shed
664
+ releaseControlIfHeldBy(client);
651
665
  try {
652
666
  client.socket.destroy();
653
667
  }
@@ -1311,6 +1325,10 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1311
1325
  flushPendingUpdate();
1312
1326
  broadcast(event);
1313
1327
  };
1328
+ const broadcastControlChanged = () => {
1329
+ persistAttachState(); // every control change is a viewer-state change
1330
+ broadcast({ type: 'control_changed', controller_id: controllerId });
1331
+ };
1314
1332
  // Persist a live model switch into the node's durable launch recipe so it
1315
1333
  // survives a yield/revive. pi's `/model` (→ set_model/cycle_model) only
1316
1334
  // mutates the in-memory engine; without this the node reverts to its
@@ -1395,16 +1413,17 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1395
1413
  },
1396
1414
  });
1397
1415
  // Send a client its catch-up snapshot. welcome.pending_dialog carries a single
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
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
1400
1418
  // insertion-ordered — the first entry is the canonical one carried here; any
1401
- // extras are replayed explicitly by the caller (rare: concurrent dialogs).
1419
+ // extras are re-routed explicitly by the caller (rare: concurrent dialogs).
1402
1420
  const sendWelcome = (client) => {
1403
1421
  const first = client.role === 'controller' ? pendingDialogs.values().next().value : undefined;
1404
1422
  sendFrame(client, {
1405
1423
  type: 'welcome',
1406
1424
  snapshot: buildSnapshot(),
1407
1425
  role: client.role,
1426
+ controller_id: controllerId,
1408
1427
  pending_dialog: first !== undefined ? first.request : null,
1409
1428
  agentDir: getAgentDir(),
1410
1429
  }, true);
@@ -1413,15 +1432,13 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1413
1432
  // after an idle brief was painted still sees it on first paint, and there is
1414
1433
  // no window between `welcome` and the replay where chrome renders blank.
1415
1434
  };
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);
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);
1425
1442
  };
1426
1443
  // After a session-replacing op (new_session/switch_session/fork) the engine's
1427
1444
  // entire message history changed, so every attached viewer must rebuild from a
@@ -1494,15 +1511,14 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1494
1511
  };
1495
1512
  // -------------------------------------------------------------------------
1496
1513
  // Extension-dialog routing (C2). makeBrokerUiContext owns the dialogPromise;
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).
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).
1503
1519
  // -------------------------------------------------------------------------
1504
1520
  const uiContext = makeBrokerUiContext({
1505
- writable: writableClients,
1521
+ controller: controllerClient,
1506
1522
  forward: (client, request) => sendFrame(client, request),
1507
1523
  pending: pendingDialogs,
1508
1524
  broadcast: broadcastUi,
@@ -1715,7 +1731,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1715
1731
  runtime?.setRebindSession((candidateSession) => enqueueRebind(candidateSession, runtime.services));
1716
1732
  await enqueueRebind(session, services);
1717
1733
  // -------------------------------------------------------------------------
1718
- // Drive the engine on behalf of a writable client.
1734
+ // Drive the engine on behalf of the single controller.
1719
1735
  // -------------------------------------------------------------------------
1720
1736
  // ---------------------------------------------------------------------------
1721
1737
  // Inline memory-reference inventory (design: inline memory references) — a
@@ -1933,23 +1949,21 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
1933
1949
  }
1934
1950
  };
1935
1951
  // -------------------------------------------------------------------------
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
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
1938
1954
  // and a few resolvers keep the per-op cases one-liners.
1939
1955
  // -------------------------------------------------------------------------
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')
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)
1946
1960
  return false;
1947
1961
  sendFrame(client, {
1948
1962
  type: 'error',
1949
- code: 'read_only',
1950
- message: `a read-only client may not ${what}`,
1963
+ code: 'not_controller',
1964
+ message: `only the controlling client may ${what}`,
1951
1965
  // M1: echo a correlated request's id so its pending-by-id promise rejects
1952
- // rather than hanging (e.g. a read-only `dequeue`).
1966
+ // rather than hanging (e.g. a non-controller `dequeue`).
1953
1967
  ...(id !== undefined ? { id } : {}),
1954
1968
  });
1955
1969
  return true;
@@ -2069,7 +2083,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2069
2083
  // -------------------------------------------------------------------------
2070
2084
  // Read-op data builders (operator-view picker payloads, §5 Unit A). Each is a
2071
2085
  // PURE getter read against the live engine session — the data a native pi
2072
- // picker's constructor needs, serialized for the viewer. Not role-gated
2086
+ // picker's constructor needs, serialized for the viewer. Not controller-gated
2073
2087
  // (read-only, like get_commands), so the web bridge's observer connection can
2074
2088
  // populate pickers too.
2075
2089
  // -------------------------------------------------------------------------
@@ -2292,28 +2306,29 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2292
2306
  const handleFrame = (client, frame) => {
2293
2307
  switch (frame.type) {
2294
2308
  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;
2305
2309
  client.id = frame.client_id;
2306
- if (firstHello) {
2307
- client.helloed = true;
2308
- client.role = frame.role === 'controller' ? 'controller' : 'observer';
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';
2309
2321
  }
2310
2322
  sendWelcome(client);
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);
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
+ }
2317
2332
  break;
2318
2333
  }
2319
2334
  case 'prompt':
@@ -2321,7 +2336,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2321
2336
  case 'follow_up':
2322
2337
  case 'abort':
2323
2338
  case 'bash': {
2324
- if (notWritable(client, 'drive the engine'))
2339
+ if (notController(client, 'drive the engine'))
2325
2340
  break;
2326
2341
  driveEngine(client, frame);
2327
2342
  break;
@@ -2346,7 +2361,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2346
2361
  // race between acceptance (agent_start) and rejection; a steer route
2347
2362
  // joins a turn already running, so no new agent_start is coming and its
2348
2363
  // ack stays at routing time.
2349
- if (notWritable(client, 'drive the engine', frame.id))
2364
+ if (notController(client, 'drive the engine', frame.id))
2350
2365
  break;
2351
2366
  const via = session.isStreaming ? 'steer' : 'prompt';
2352
2367
  let delivered = false;
@@ -2385,17 +2400,46 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2385
2400
  break;
2386
2401
  }
2387
2402
  case 'extension_ui_response': {
2388
- if (notWritable(client, 'answer dialogs'))
2403
+ if (notController(client, 'answer dialogs'))
2389
2404
  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.
2393
2405
  pendingDialogs.get(frame.id)?.resolve(frame);
2394
2406
  break;
2395
2407
  }
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
+ }
2396
2440
  // --- extended engine-command ops (T3, §1.2 floor set) ------------------
2397
2441
  case 'set_model': {
2398
- if (notWritable(client, 'set the model'))
2442
+ if (notController(client, 'set the model'))
2399
2443
  break;
2400
2444
  const requested = parseModelSpec(frame.model);
2401
2445
  let model;
@@ -2442,7 +2486,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2442
2486
  break;
2443
2487
  }
2444
2488
  case 'deliver_custom_message': {
2445
- if (notWritable(client, 'deliver a custom message'))
2489
+ if (notController(client, 'deliver a custom message'))
2446
2490
  break;
2447
2491
  // Never triggers a turn either way. `nextTurn` folds the message into
2448
2492
  // whatever turn comes next (situational context); omitting it pushes
@@ -2462,7 +2506,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2462
2506
  break;
2463
2507
  }
2464
2508
  case 'cycle_model': {
2465
- if (notWritable(client, 'cycle the model'))
2509
+ if (notController(client, 'cycle the model'))
2466
2510
  break;
2467
2511
  void session
2468
2512
  .cycleModel(frame.direction)
@@ -2474,7 +2518,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2474
2518
  break;
2475
2519
  }
2476
2520
  case 'cycle_ladder': {
2477
- if (notWritable(client, 'cycle the model ladder'))
2521
+ if (notController(client, 'cycle the model ladder'))
2478
2522
  break;
2479
2523
  // Resolve the next interleaved-ladder rung from the current model+thinking
2480
2524
  // spec, then reuse the set_model path (registry resolve + thinking apply).
@@ -2523,7 +2567,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2523
2567
  break;
2524
2568
  }
2525
2569
  case 'cycle_thinking': {
2526
- if (notWritable(client, 'cycle the thinking level'))
2570
+ if (notController(client, 'cycle the thinking level'))
2527
2571
  break;
2528
2572
  try {
2529
2573
  session.cycleThinkingLevel();
@@ -2535,7 +2579,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2535
2579
  break;
2536
2580
  }
2537
2581
  case 'dequeue': {
2538
- if (notWritable(client, 'dequeue messages', frame.id))
2582
+ if (notController(client, 'dequeue messages', frame.id))
2539
2583
  break;
2540
2584
  try {
2541
2585
  const { steering, followUp } = session.clearQueue();
@@ -2547,7 +2591,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2547
2591
  break;
2548
2592
  }
2549
2593
  case 'set_thinking_level': {
2550
- if (notWritable(client, 'set the thinking level'))
2594
+ if (notController(client, 'set the thinking level'))
2551
2595
  break;
2552
2596
  try {
2553
2597
  session.setThinkingLevel(frame.level);
@@ -2559,7 +2603,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2559
2603
  break;
2560
2604
  }
2561
2605
  case 'set_auto_retry': {
2562
- if (notWritable(client, 'set auto-retry'))
2606
+ if (notController(client, 'set auto-retry'))
2563
2607
  break;
2564
2608
  try {
2565
2609
  session.setAutoRetryEnabled(frame.enabled);
@@ -2571,7 +2615,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2571
2615
  break;
2572
2616
  }
2573
2617
  case 'set_auto_compaction': {
2574
- if (notWritable(client, 'set auto-compaction'))
2618
+ if (notController(client, 'set auto-compaction'))
2575
2619
  break;
2576
2620
  try {
2577
2621
  session.setAutoCompactionEnabled(frame.enabled);
@@ -2583,7 +2627,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2583
2627
  break;
2584
2628
  }
2585
2629
  case 'compact': {
2586
- if (notWritable(client, 'compact the session'))
2630
+ if (notController(client, 'compact the session'))
2587
2631
  break;
2588
2632
  void session
2589
2633
  .compact(frame.instructions)
@@ -2592,7 +2636,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2592
2636
  break;
2593
2637
  }
2594
2638
  case 'set_session_name': {
2595
- if (notWritable(client, 'rename the session'))
2639
+ if (notController(client, 'rename the session'))
2596
2640
  break;
2597
2641
  try {
2598
2642
  session.setSessionName(frame.name);
@@ -2703,7 +2747,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2703
2747
  break;
2704
2748
  }
2705
2749
  case 'navigate_tree': {
2706
- if (notWritable(client, 'navigate the session tree'))
2750
+ if (notController(client, 'navigate the session tree'))
2707
2751
  break;
2708
2752
  // navigateTree rewinds IN-PLACE (same session file, new leaf) and emits no
2709
2753
  // relayed event, so every viewer must be re-snapshotted onto the rewound
@@ -2721,7 +2765,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2721
2765
  break;
2722
2766
  }
2723
2767
  case 'reload': {
2724
- if (notWritable(client, 'reload'))
2768
+ if (notController(client, 'reload'))
2725
2769
  break;
2726
2770
  // A SUCCESSFUL reload invalidates the memoized ref inventory (work item
2727
2771
  // 3) so the next read-op/submission re-walks the corpus, AND clears the
@@ -2740,7 +2784,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2740
2784
  break;
2741
2785
  }
2742
2786
  case 'export': {
2743
- if (notWritable(client, 'export the session'))
2787
+ if (notController(client, 'export the session'))
2744
2788
  break;
2745
2789
  if (frame.format === 'jsonl') {
2746
2790
  try {
@@ -2761,25 +2805,25 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2761
2805
  break;
2762
2806
  }
2763
2807
  case 'new_session': {
2764
- if (notWritable(client, 'start a new session'))
2808
+ if (notController(client, 'start a new session'))
2765
2809
  break;
2766
2810
  runReplacement(client, 'new_session', (rt) => rt.newSession());
2767
2811
  break;
2768
2812
  }
2769
2813
  case 'switch_session': {
2770
- if (notWritable(client, 'switch sessions'))
2814
+ if (notController(client, 'switch sessions'))
2771
2815
  break;
2772
2816
  runReplacement(client, 'switch_session', (rt) => rt.switchSession(frame.path));
2773
2817
  break;
2774
2818
  }
2775
2819
  case 'fork': {
2776
- if (notWritable(client, 'fork the session'))
2820
+ if (notController(client, 'fork the session'))
2777
2821
  break;
2778
2822
  runReplacement(client, 'fork', (rt) => rt.fork(frame.entryId));
2779
2823
  break;
2780
2824
  }
2781
2825
  case 'clone': {
2782
- if (notWritable(client, 'clone the session'))
2826
+ if (notController(client, 'clone the session'))
2783
2827
  break;
2784
2828
  runReplacement(client, 'clone', async (rt) => {
2785
2829
  const sm = session.sessionManager;
@@ -2796,7 +2840,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2796
2840
  break;
2797
2841
  }
2798
2842
  case 'share': {
2799
- if (notWritable(client, 'share the session'))
2843
+ if (notController(client, 'share the session'))
2800
2844
  break;
2801
2845
  const tmpPath = join(tmpdir(), `pi-share-${Date.now()}.html`);
2802
2846
  void session.exportToHtml(tmpPath)
@@ -2826,11 +2870,13 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2826
2870
  break;
2827
2871
  }
2828
2872
  case 'reload_auth': {
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.
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.
2834
2880
  try {
2835
2881
  // pi 0.82: crouter's CredentialStore reads auth.json fresh under the
2836
2882
  // lock on every call, so there is no cached credential view left to
@@ -2989,10 +3035,12 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
2989
3035
  const drop = () => {
2990
3036
  clients.delete(client);
2991
3037
  persistAttachState(); // a viewer disconnected
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.
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);
2996
3044
  };
2997
3045
  socket.on('close', drop);
2998
3046
  socket.on('error', () => {
@@ -3551,33 +3599,26 @@ export function makeBrokerUiContext(deps) {
3551
3599
  // OPTIONAL on dialog opts, editor() takes none, and almost no real extension
3552
3600
  // passes one (permission-gate / confirm-destructive / plan-mode / subagent all
3553
3601
  // omit it). A timeout-reliant unattended node therefore deadlocks the agent
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.
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.)
3559
3607
  const dialogPromise = (defaultValue, request, parse, opts) => {
3560
3608
  if (opts?.signal?.aborted)
3561
3609
  return Promise.resolve(defaultValue);
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)
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)
3568
3614
  return Promise.resolve(defaultValue);
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.
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.
3581
3622
  return new Promise((resolve) => {
3582
3623
  let timer;
3583
3624
  const cleanup = () => {
@@ -3590,10 +3631,10 @@ export function makeBrokerUiContext(deps) {
3590
3631
  cleanup();
3591
3632
  // The extension aborted this request out-of-band (e.g. an OAuth loopback
3592
3633
  // callback won the race against a still-open manual-paste dialog). Tell
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.
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.
3597
3638
  deps.broadcast({ type: 'extension_ui_dismiss', id: request.id });
3598
3639
  resolve(defaultValue);
3599
3640
  };
@@ -3606,8 +3647,8 @@ export function makeBrokerUiContext(deps) {
3606
3647
  timer = setTimeout(() => {
3607
3648
  cleanup();
3608
3649
  // Same correlated teardown as onAbort: the broker resolved this dialog on
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.
3650
+ // its own timeout, so the controller's overlay (which has no independent
3651
+ // timer) must be dismissed by id or it lingers forever.
3611
3652
  deps.broadcast({ type: 'extension_ui_dismiss', id: request.id });
3612
3653
  resolve(defaultValue);
3613
3654
  }, ms);
@@ -3617,16 +3658,10 @@ export function makeBrokerUiContext(deps) {
3617
3658
  request,
3618
3659
  resolve: (r) => {
3619
3660
  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 });
3625
3661
  resolve(parse(r));
3626
3662
  },
3627
3663
  });
3628
- for (const target of targets)
3629
- deps.forward(target, request);
3664
+ deps.forward(controller, request);
3630
3665
  });
3631
3666
  };
3632
3667
  const noop = () => { };