@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.
- package/README.md +2 -1
- package/dist/api/client.d.ts +1 -4
- package/dist/api/client.js +0 -5
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +0 -15
- package/dist/api/routes.d.ts +0 -1
- package/dist/api/routes.js +0 -1
- package/dist/builtin-views/chat/core.mjs +51 -6
- package/dist/builtin-views/chat/tui.mjs +14 -6
- package/dist/builtin-views/chat/web.jsx +7 -2
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
- package/dist/clients/attach/chrome/roster.d.ts +1 -1
- package/dist/clients/attach/chrome/roster.js +1 -9
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +8 -23
- package/dist/clients/attach/input/controller.js +29 -59
- package/dist/clients/attach/overlays/dialogs.d.ts +2 -1
- package/dist/clients/attach/overlays/graph.d.ts +1 -4
- package/dist/clients/attach/overlays/graph.js +7 -25
- package/dist/clients/attach/session/context.d.ts +0 -7
- package/dist/clients/attach/session/frames.js +8 -1
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +11 -21
- package/dist/clients/attach/session/mode.d.ts +3 -4
- package/dist/clients/attach/session/mode.js +1 -6
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +7 -6
- package/dist/clients/attach/session/state-sync.d.ts +1 -1
- package/dist/clients/attach/session/state-sync.js +2 -2
- package/dist/clients/attach/slash/dispatch.d.ts +1 -13
- package/dist/clients/attach/slash/dispatch.js +17 -65
- package/dist/clients/attach/viewer.js +523 -523
- package/dist/clients/web/web-client/shared/protocol.d.ts +3 -5
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/chat-view-reconnect.test.js +23 -44
- package/dist/core/__tests__/full/broker-attach-limits.test.js +36 -60
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +62 -121
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +26 -15
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -0
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +13 -9
- package/dist/core/runtime/boot-root.d.ts +2 -2
- package/dist/core/runtime/boot-root.js +7 -7
- package/dist/core/runtime/broker-protocol.d.ts +27 -23
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +11 -5
- package/dist/core/runtime/broker.d.ts +13 -21
- package/dist/core/runtime/broker.js +186 -151
- package/dist/core/runtime/interactive-deliver.js +5 -4
- package/dist/core/runtime/model-swap.d.ts +3 -2
- package/dist/core/runtime/model-swap.js +4 -3
- package/dist/core/runtime/node-read.d.ts +0 -20
- package/dist/core/runtime/node-read.js +1 -34
- package/dist/core/runtime/resume-root.d.ts +1 -1
- package/dist/core/runtime/resume-root.js +6 -6
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/session-model/session-state.d.ts +6 -8
- package/dist/core/session-model/session-state.js +16 -6
- package/dist/daemon/api/handlers/nodes.js +1 -18
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/web-client/assets/index--SsQYcKu.js +79 -0
- package/dist/web-client/assets/{index-CpEl9LTS.css → index-DJhQZoAj.css} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v1.md +1 -1
- package/docs/compat/hearth-crtr-v2.md +1 -1
- package/docs/compat/hearth-crtr-v3.md +1 -1
- package/docs/compat/hearth-crtr-v4.md +3 -1
- package/docs/public-api.md +2 -2
- package/package.json +4 -4
- package/runtime.lock.json +2 -2
- package/dist/web-client/assets/index-BpyZGBhI.js +0 -79
- 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
|
|
7
|
-
//
|
|
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
|
|
100
|
-
*
|
|
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
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
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
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
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 (
|
|
606
|
+
if (c.id !== controllerId)
|
|
603
607
|
continue;
|
|
604
608
|
if (c.socket.destroyed || c.socket.readableEnded || !c.socket.writable)
|
|
605
|
-
|
|
606
|
-
|
|
609
|
+
return null;
|
|
610
|
+
return c;
|
|
607
611
|
}
|
|
608
|
-
return
|
|
612
|
+
return null;
|
|
609
613
|
};
|
|
610
|
-
// Persist viewer presence to job/attach.json
|
|
611
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
635
|
-
//
|
|
636
|
-
// the
|
|
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
|
|
1399
|
-
// can
|
|
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
|
|
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
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
1937
|
-
// inlined twice) and reused by all
|
|
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
|
|
1941
|
-
*
|
|
1942
|
-
|
|
1943
|
-
|
|
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: '
|
|
1950
|
-
message: `
|
|
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
|
|
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
|
|
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
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
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
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
2830
|
-
//
|
|
2831
|
-
//
|
|
2832
|
-
//
|
|
2833
|
-
// to every live broker)
|
|
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
|
-
//
|
|
2993
|
-
// pending under the broker-side default timeout
|
|
2994
|
-
//
|
|
2995
|
-
//
|
|
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
|
|
3555
|
-
//
|
|
3556
|
-
//
|
|
3557
|
-
//
|
|
3558
|
-
//
|
|
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
|
-
|
|
3563
|
-
//
|
|
3564
|
-
|
|
3565
|
-
|
|
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
|
-
//
|
|
3570
|
-
//
|
|
3571
|
-
//
|
|
3572
|
-
//
|
|
3573
|
-
//
|
|
3574
|
-
//
|
|
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
|
-
//
|
|
3594
|
-
// broker entry we just dropped would otherwise linger onscreen with
|
|
3595
|
-
// left to answer it. Keyed to request.id so only the abandoned
|
|
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
|
|
3610
|
-
//
|
|
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
|
-
|
|
3629
|
-
deps.forward(target, request);
|
|
3664
|
+
deps.forward(controller, request);
|
|
3630
3665
|
});
|
|
3631
3666
|
};
|
|
3632
3667
|
const noop = () => { };
|