@north-light/crouter 0.3.157 → 0.3.159
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/dist/api/client.d.ts +4 -1
- package/dist/api/client.js +5 -0
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +15 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/internal/storage-tiers.md +2 -0
- package/dist/builtin-views/chat/core.mjs +6 -51
- package/dist/builtin-views/chat/tui.mjs +6 -14
- package/dist/builtin-views/chat/web.jsx +2 -7
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
- package/dist/clients/attach/chrome/bash-jobs.d.ts +0 -5
- package/dist/clients/attach/chrome/bash-jobs.js +5 -24
- package/dist/clients/attach/chrome/roster.d.ts +4 -3
- package/dist/clients/attach/chrome/roster.js +20 -36
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +23 -8
- package/dist/clients/attach/input/controller.js +59 -29
- package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
- package/dist/clients/attach/overlays/graph.d.ts +4 -1
- package/dist/clients/attach/overlays/graph.js +25 -7
- package/dist/clients/attach/session/context.d.ts +7 -0
- package/dist/clients/attach/session/frames.js +1 -8
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +21 -11
- package/dist/clients/attach/session/mode.d.ts +4 -3
- package/dist/clients/attach/session/mode.js +6 -1
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +6 -7
- 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 +13 -1
- package/dist/clients/attach/slash/dispatch.js +65 -17
- package/dist/clients/attach/viewer.js +497 -499
- package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
- package/dist/commands/node.js +142 -4
- package/dist/commands/surface-inspect.js +4 -3
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
- package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
- package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +15 -26
- package/dist/core/bash-jobs.d.ts +11 -0
- package/dist/core/bash-jobs.js +41 -1
- package/dist/core/feed/inbox.d.ts +3 -3
- package/dist/core/feed/inbox.js +10 -6
- package/dist/core/inspector/core.d.ts +17 -2
- package/dist/core/inspector/core.js +172 -23
- package/dist/core/inspector/model.d.ts +30 -1
- package/dist/core/inspector/model.js +39 -0
- package/dist/core/inspector/text.js +14 -1
- package/dist/core/inspector/tui.js +71 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +3 -4
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +9 -13
- 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 +23 -27
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +5 -11
- package/dist/core/runtime/broker.d.ts +21 -13
- package/dist/core/runtime/broker.js +151 -186
- package/dist/core/runtime/interactive-deliver.js +4 -5
- package/dist/core/runtime/model-swap.d.ts +2 -3
- package/dist/core/runtime/model-swap.js +3 -4
- package/dist/core/runtime/node-read.d.ts +20 -0
- package/dist/core/runtime/node-read.js +34 -1
- 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 +8 -6
- package/dist/core/session-model/session-state.js +6 -16
- package/dist/core/tui/host.js +21 -6
- package/dist/daemon/api/handlers/nodes.js +18 -1
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/pi-extensions/canvas-bash-valve.js +9 -3
- package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
- package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v5.md +175 -0
- package/docs/public-api.md +2 -2
- package/package.json +2 -2
- package/runtime.lock.json +6 -6
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
- 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
|
|
7
|
-
//
|
|
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
|
|
99
|
-
* The
|
|
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
|
|
382
|
-
* default, or a shorter per-dialog `opts.timeout` if the extension passed
|
|
383
|
-
* so
|
|
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
|
|
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
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
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.
|
|
602
|
+
if (!c.helloed || c.role !== 'controller')
|
|
607
603
|
continue;
|
|
608
604
|
if (c.socket.destroyed || c.socket.readableEnded || !c.socket.writable)
|
|
609
|
-
|
|
610
|
-
|
|
605
|
+
continue;
|
|
606
|
+
out.push(c);
|
|
611
607
|
}
|
|
612
|
-
return
|
|
608
|
+
return out;
|
|
613
609
|
};
|
|
614
|
-
// Persist viewer presence to job/attach.json
|
|
615
|
-
// (hello accepted, client
|
|
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.
|
|
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,
|
|
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
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
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
|
|
1417
|
-
//
|
|
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
|
|
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
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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:
|
|
1515
|
-
//
|
|
1516
|
-
// to
|
|
1517
|
-
// through. The zero-
|
|
1518
|
-
// timeout (design §5.4's timeout premise is false — see
|
|
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
|
-
|
|
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
|
|
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
|
|
1953
|
-
// inlined twice) and reused by all
|
|
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
|
|
1957
|
-
*
|
|
1958
|
-
|
|
1959
|
-
|
|
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: '
|
|
1964
|
-
message: `only
|
|
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
|
|
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
|
|
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
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
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
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
2874
|
-
// shared auth.json + a model-registry refresh. It
|
|
2875
|
-
// conversation, so there is nothing to gate
|
|
2876
|
-
//
|
|
2877
|
-
//
|
|
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
|
-
//
|
|
3039
|
-
//
|
|
3040
|
-
//
|
|
3041
|
-
//
|
|
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
|
|
3603
|
-
// behavior — resolve to the default (deny / cancel / undefined)
|
|
3604
|
-
// never arming a timer, never waiting.
|
|
3605
|
-
//
|
|
3606
|
-
// but never
|
|
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
|
-
|
|
3611
|
-
//
|
|
3612
|
-
|
|
3613
|
-
|
|
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
|
-
//
|
|
3616
|
-
//
|
|
3617
|
-
// timeout (
|
|
3618
|
-
//
|
|
3619
|
-
// timeout if the extension passed one; otherwise the broker default.
|
|
3620
|
-
// it resolves to the SAFE default (deny/cancel/undefined).
|
|
3621
|
-
//
|
|
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
|
-
//
|
|
3635
|
-
//
|
|
3636
|
-
//
|
|
3637
|
-
//
|
|
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
|
|
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
|
-
|
|
3628
|
+
for (const target of targets)
|
|
3629
|
+
deps.forward(target, request);
|
|
3665
3630
|
});
|
|
3666
3631
|
};
|
|
3667
3632
|
const noop = () => { };
|