@crouton-kit/crouter 0.3.18 → 0.3.20
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/build-root.js +6 -0
- package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
- package/dist/builtin-memory/crouter-development/personas.md +117 -0
- package/dist/builtin-memory/crouter-development/plugins.md +163 -0
- package/dist/builtin-memory/design.md +60 -0
- package/dist/builtin-memory/development.md +116 -0
- package/dist/builtin-memory/planning.md +66 -0
- package/dist/builtin-memory/spec.md +99 -0
- package/dist/builtin-personas/design/PERSONA.md +1 -0
- package/dist/builtin-personas/design/orchestrator.md +2 -1
- package/dist/builtin-personas/developer/PERSONA.md +1 -0
- package/dist/builtin-personas/developer/orchestrator.md +2 -1
- package/dist/builtin-personas/explore/PERSONA.md +1 -0
- package/dist/builtin-personas/explore/orchestrator.md +4 -0
- package/dist/builtin-personas/general/PERSONA.md +1 -0
- package/dist/builtin-personas/general/orchestrator.md +4 -0
- package/dist/builtin-personas/lifecycle/resident.md +1 -1
- package/dist/builtin-personas/lifecycle/terminal.md +5 -2
- package/dist/builtin-personas/orchestration-kernel.md +15 -24
- package/dist/builtin-personas/plan/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/orchestrator.md +2 -1
- package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
- package/dist/builtin-personas/review/PERSONA.md +1 -0
- package/dist/builtin-personas/review/orchestrator.md +4 -0
- package/dist/builtin-personas/runtime-base.md +6 -3
- package/dist/builtin-personas/spec/PERSONA.md +1 -0
- package/dist/builtin-personas/spec/orchestrator.md +2 -1
- package/dist/builtin-personas/waiting.md +8 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
- package/dist/builtin-views/_lib/states.mjs +161 -0
- package/dist/builtin-views/canvas/client.mjs +303 -0
- package/dist/builtin-views/canvas/view.mjs +576 -0
- package/dist/builtin-views/git-pr/client.mjs +440 -0
- package/dist/builtin-views/git-pr/view.mjs +675 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
- package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
- package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
- package/dist/builtin-views/inbox/view.mjs +889 -0
- package/dist/builtin-views/linkedin/client.mjs +610 -0
- package/dist/builtin-views/linkedin/view.mjs +1171 -0
- package/dist/clients/attach/attach-cmd.d.ts +2 -0
- package/dist/clients/attach/attach-cmd.js +354 -0
- package/dist/clients/attach/chat-view.d.ts +77 -0
- package/dist/clients/attach/chat-view.js +450 -0
- package/dist/clients/attach/clipboard-image.d.ts +16 -0
- package/dist/clients/attach/clipboard-image.js +113 -0
- package/dist/clients/attach/config-load.d.ts +31 -0
- package/dist/clients/attach/config-load.js +113 -0
- package/dist/clients/attach/extension-dialogs.d.ts +29 -0
- package/dist/clients/attach/extension-dialogs.js +101 -0
- package/dist/clients/attach/input-controller.d.ts +54 -0
- package/dist/clients/attach/input-controller.js +204 -0
- package/dist/clients/attach/slash-commands.d.ts +36 -0
- package/dist/clients/attach/slash-commands.js +200 -0
- package/dist/clients/attach/view-socket.d.ts +48 -0
- package/dist/clients/attach/view-socket.js +126 -0
- package/dist/commands/attention.js +3 -3
- package/dist/commands/canvas-prune.js +1 -1
- package/dist/commands/daemon.js +3 -2
- package/dist/commands/human/prompts.js +1 -1
- package/dist/commands/human/queue.js +54 -8
- package/dist/commands/human/shared.d.ts +2 -0
- package/dist/commands/memory/find.d.ts +1 -0
- package/dist/commands/memory/find.js +180 -0
- package/dist/commands/memory/lint.d.ts +1 -0
- package/dist/commands/memory/lint.js +140 -0
- package/dist/commands/memory/list.d.ts +1 -0
- package/dist/commands/memory/list.js +79 -0
- package/dist/commands/memory/read.js +103 -0
- package/dist/commands/memory/shared.d.ts +30 -0
- package/dist/commands/memory/shared.js +122 -0
- package/dist/commands/memory/write.d.ts +1 -0
- package/dist/commands/memory/write.js +85 -0
- package/dist/commands/memory.d.ts +2 -0
- package/dist/commands/memory.js +27 -0
- package/dist/commands/node.d.ts +3 -2
- package/dist/commands/node.js +610 -61
- package/dist/commands/pkg/market-manage.js +1 -1
- package/dist/commands/push.js +6 -6
- package/dist/commands/revive.js +1 -1
- package/dist/commands/skill/author.js +1 -1
- package/dist/commands/skill/shared.d.ts +1 -8
- package/dist/commands/skill/shared.js +2 -55
- package/dist/commands/skill.js +9 -14
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/update.js +1 -1
- package/dist/commands/view-cycle.d.ts +2 -0
- package/dist/commands/view-cycle.js +125 -0
- package/dist/commands/view-list.d.ts +2 -0
- package/dist/commands/view-list.js +66 -0
- package/dist/commands/view-new.d.ts +2 -0
- package/dist/commands/view-new.js +70 -0
- package/dist/commands/view-pick.d.ts +2 -0
- package/dist/commands/view-pick.js +119 -0
- package/dist/commands/view-run.d.ts +2 -0
- package/dist/commands/view-run.js +191 -0
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +29 -0
- package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
- package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
- package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
- package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dialogs.test.js +126 -0
- package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
- package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +87 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
- package/dist/core/__tests__/cascade-close.test.js +12 -2
- package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
- package/dist/core/__tests__/child-death-wake.test.js +245 -0
- package/dist/core/__tests__/context-intro.test.js +76 -62
- package/dist/core/__tests__/daemon-boot.test.js +14 -5
- package/dist/core/__tests__/daemon-liveness.test.js +34 -9
- package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
- package/dist/core/__tests__/detach-focus.test.js +206 -0
- package/dist/core/__tests__/draw-style.test.d.ts +1 -0
- package/dist/core/__tests__/draw-style.test.js +258 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
- package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
- package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
- package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
- package/dist/core/__tests__/helpers/broker-clients.js +178 -0
- package/dist/core/__tests__/helpers/harness.d.ts +9 -0
- package/dist/core/__tests__/helpers/harness.js +111 -1
- package/dist/core/__tests__/home-session.test.js +41 -8
- package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
- package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
- package/dist/core/__tests__/kickoff.test.js +37 -3
- package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
- package/dist/core/__tests__/live-mutation.test.js +6 -132
- package/dist/core/__tests__/memory.test.js +23 -115
- package/dist/core/__tests__/placement-focus.test.js +5 -0
- package/dist/core/__tests__/placement-teardown.test.js +54 -11
- package/dist/core/__tests__/relaunch.test.js +4 -3
- package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
- package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
- package/dist/core/__tests__/spawn-root.test.js +10 -0
- package/dist/core/__tests__/spike-harness.test.js +1 -0
- package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
- package/dist/core/__tests__/wake-bearings.test.js +156 -0
- package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
- package/dist/core/__tests__/wake-origin.test.js +110 -0
- package/dist/core/bootstrap.js +1 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +24 -2
- package/dist/core/canvas/browse/model.d.ts +38 -2
- package/dist/core/canvas/browse/model.js +134 -10
- package/dist/core/canvas/browse/render.d.ts +6 -12
- package/dist/core/canvas/browse/render.js +72 -104
- package/dist/core/canvas/canvas.js +16 -23
- package/dist/core/canvas/db.js +46 -0
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/pid.d.ts +4 -0
- package/dist/core/canvas/pid.js +23 -0
- package/dist/core/canvas/render.d.ts +11 -2
- package/dist/core/canvas/render.js +69 -0
- package/dist/core/canvas/types.d.ts +91 -3
- package/dist/core/canvas/wakeups.d.ts +76 -0
- package/dist/core/canvas/wakeups.js +185 -0
- package/dist/core/config.js +4 -1
- package/dist/core/frontmatter.js +37 -124
- package/dist/core/memory-resolver.d.ts +49 -0
- package/dist/core/memory-resolver.js +141 -0
- package/dist/core/personas/loader.d.ts +7 -0
- package/dist/core/personas/loader.js +36 -5
- package/dist/core/personas/resolve.js +13 -3
- package/dist/core/predicate.d.ts +63 -0
- package/dist/core/predicate.js +189 -0
- package/dist/core/resolver.js +26 -5
- package/dist/core/runtime/bearings.d.ts +53 -12
- package/dist/core/runtime/bearings.js +132 -59
- package/dist/core/runtime/broker-cli.d.ts +1 -0
- package/dist/core/runtime/broker-cli.js +46 -0
- package/dist/core/runtime/broker-protocol.d.ts +332 -0
- package/dist/core/runtime/broker-protocol.js +153 -0
- package/dist/core/runtime/broker-sdk.d.ts +48 -0
- package/dist/core/runtime/broker-sdk.js +72 -0
- package/dist/core/runtime/broker.d.ts +55 -0
- package/dist/core/runtime/broker.js +1128 -0
- package/dist/core/runtime/close.js +35 -6
- package/dist/core/runtime/host.d.ts +53 -0
- package/dist/core/runtime/host.js +186 -0
- package/dist/core/runtime/kickoff.d.ts +2 -1
- package/dist/core/runtime/kickoff.js +91 -5
- package/dist/core/runtime/launch.d.ts +54 -4
- package/dist/core/runtime/launch.js +85 -5
- package/dist/core/runtime/lifecycle.js +23 -6
- package/dist/core/runtime/memory.d.ts +2 -42
- package/dist/core/runtime/memory.js +11 -162
- package/dist/core/runtime/nodes.d.ts +37 -0
- package/dist/core/runtime/nodes.js +60 -1
- package/dist/core/runtime/persona.js +21 -11
- package/dist/core/runtime/pi-vendored.d.ts +18 -0
- package/dist/core/runtime/pi-vendored.js +49 -0
- package/dist/core/runtime/placement.d.ts +32 -14
- package/dist/core/runtime/placement.js +206 -52
- package/dist/core/runtime/promote.d.ts +0 -6
- package/dist/core/runtime/promote.js +4 -12
- package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
- package/dist/core/runtime/{demote.js → recycle.js} +27 -11
- package/dist/core/runtime/reset.js +11 -8
- package/dist/core/runtime/revive.d.ts +2 -0
- package/dist/core/runtime/revive.js +34 -34
- package/dist/core/runtime/spawn.d.ts +23 -0
- package/dist/core/runtime/spawn.js +77 -23
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +6 -1
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +1 -1
- package/dist/core/runtime/tmux.d.ts +28 -0
- package/dist/core/runtime/tmux.js +80 -6
- package/dist/core/scope.d.ts +11 -0
- package/dist/core/scope.js +39 -0
- package/dist/core/spawn.d.ts +5 -0
- package/dist/core/spawn.js +20 -2
- package/dist/core/substrate/gate.d.ts +13 -0
- package/dist/core/substrate/gate.js +21 -0
- package/dist/core/substrate/index.d.ts +7 -0
- package/dist/core/substrate/index.js +18 -0
- package/dist/core/substrate/on-read.d.ts +14 -0
- package/dist/core/substrate/on-read.js +292 -0
- package/dist/core/substrate/render.d.ts +25 -0
- package/dist/core/substrate/render.js +256 -0
- package/dist/core/substrate/schema.d.ts +76 -0
- package/dist/core/substrate/schema.js +124 -0
- package/dist/core/substrate/session-cache.d.ts +30 -0
- package/dist/core/substrate/session-cache.js +77 -0
- package/dist/core/substrate/subject.d.ts +41 -0
- package/dist/core/substrate/subject.js +54 -0
- package/dist/core/tui/contract.d.ts +83 -0
- package/dist/core/tui/contract.js +8 -0
- package/dist/core/tui/draw.d.ts +96 -0
- package/dist/core/tui/draw.js +339 -0
- package/dist/core/tui/host.d.ts +29 -0
- package/dist/core/tui/host.js +379 -0
- package/dist/core/tui/loader.d.ts +16 -0
- package/dist/core/tui/loader.js +94 -0
- package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
- package/dist/core/wake.d.ts +86 -0
- package/dist/core/wake.js +308 -0
- package/dist/daemon/crtrd.d.ts +29 -4
- package/dist/daemon/crtrd.js +662 -46
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
- package/dist/pi-extensions/canvas-commands.d.ts +3 -0
- package/dist/pi-extensions/canvas-commands.js +10 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
- package/dist/pi-extensions/canvas-context-intro.js +55 -15
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
- package/dist/pi-extensions/canvas-nav.d.ts +3 -0
- package/dist/pi-extensions/canvas-nav.js +87 -23
- package/dist/pi-extensions/canvas-stophook.js +17 -8
- package/dist/pi-extensions/canvas-view.d.ts +21 -0
- package/dist/pi-extensions/canvas-view.js +75 -0
- package/dist/prompts/skill.js +19 -26
- package/dist/prompts/view.d.ts +7 -0
- package/dist/prompts/view.js +101 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +1 -0
- package/package.json +9 -4
- package/dist/commands/skill/find.d.ts +0 -4
- package/dist/commands/skill/find.js +0 -257
- package/dist/commands/skill/read.js +0 -91
- /package/dist/commands/{skill → memory}/read.d.ts +0 -0
- /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
// broker.ts — the headless node broker (design §4, §5; plan T4).
|
|
2
|
+
//
|
|
3
|
+
// One broker process per `--headless` node. It hosts ONE pi engine IN-PROCESS
|
|
4
|
+
// via the SDK (createAgentSession), is the SOLE writer of the node's session
|
|
5
|
+
// `.jsonl`, listens on `nodeDir(id)/view.sock`, fans the single engine event
|
|
6
|
+
// stream out to N viewers, serializes a single controller's drive commands, and
|
|
7
|
+
// routes blocking extension dialogs. It is the headless analog of pi-in-a-pane:
|
|
8
|
+
// it runs one turn-cycle and, when the engine settles (the stophook calls
|
|
9
|
+
// ctx.shutdown(), or the engine goes idle), disposes the engine and exits 0 —
|
|
10
|
+
// the daemon then routes revival per the intent the bound stophook set.
|
|
11
|
+
//
|
|
12
|
+
// Engine resolution flows through broker-sdk.ts (`loadBrokerEngine`) so the T11
|
|
13
|
+
// `CRTR_BROKER_ENGINE` test seam can swap a fake engine in. The resource loader
|
|
14
|
+
// + model registry are real SDK construction helpers (NOT part of the swappable
|
|
15
|
+
// engine seam — the fake engine receives the real, extension-loaded loader so
|
|
16
|
+
// the real canvas-stophook fires session_start).
|
|
17
|
+
import { createServer } from 'node:net';
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { getAgentDir, initTheme, } from '@earendil-works/pi-coding-agent';
|
|
22
|
+
import { jobDir, nodeDir } from '../canvas/paths.js';
|
|
23
|
+
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
24
|
+
import { piInvocationToSdkConfig } from './launch.js';
|
|
25
|
+
import { assertEngineVersion, loadBrokerEngine } from './broker-sdk.js';
|
|
26
|
+
import { BUILTIN_SLASH_COMMANDS } from './pi-vendored.js';
|
|
27
|
+
import { encodeFrame, FrameDecoder, FrameOverflowError, BROKER_READ_CAPS, } from './broker-protocol.js';
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Tunables (T3 backpressure / T4 dialog anti-deadlock)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/** Per-viewer outbound high-water mark (M1). A viewer whose unflushed backlog
|
|
32
|
+
* exceeds EITHER bound is DROPPED (socket destroyed) rather than allowed to
|
|
33
|
+
* apply indefinite backpressure to the shared engine. Modeled on pi's RPC
|
|
34
|
+
* output-guard: bound the queue, shed the slow viewer. */
|
|
35
|
+
const MAX_QUEUED_FRAMES = 1000;
|
|
36
|
+
const MAX_PENDING_BYTES = 32 * 1024 * 1024; // 32 MiB
|
|
37
|
+
/** F2 (attach typing-lag fix, 2026-06-09): coalesce window for relayed
|
|
38
|
+
* `message_update` frames. pi emits one per content delta and each carries the
|
|
39
|
+
* FULL accumulated assistant message, so a verbatim relay re-serializes (and
|
|
40
|
+
* every viewer re-parses + re-renders) the whole message per token — O(len²)
|
|
41
|
+
* bytes over a long reply, and measured ~3× the viewer's render CPU vs 75 ms
|
|
42
|
+
* coalescing. Only the LATEST pending update matters (each supersedes the
|
|
43
|
+
* last), so the relay keeps one and flushes it on this timer — or immediately,
|
|
44
|
+
* BEFORE any other event type, preserving ordering (an update never arrives
|
|
45
|
+
* after its message_end). */
|
|
46
|
+
const MESSAGE_UPDATE_COALESCE_MS = 75;
|
|
47
|
+
/** Broker-side default dialog timeout (C2 anti-deadlock, T4). When an extension
|
|
48
|
+
* dialog is forwarded to a controller, the broker ALWAYS arms a timeout (this
|
|
49
|
+
* default, or a shorter per-dialog `opts.timeout` if the extension passed one)
|
|
50
|
+
* so a controller that never answers — or detaches and is never replaced — can
|
|
51
|
+
* never hang the agent turn forever. On fire it resolves to the SAFE default
|
|
52
|
+
* (deny for confirm; cancel/undefined for select/input/editor). */
|
|
53
|
+
const DEFAULT_DIALOG_TIMEOUT_MS = 120_000; // 120 s
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// M3 (scout mq5thyli): the active engine session, exposed so broker-cli's FATAL
|
|
56
|
+
// handlers (uncaughtException / unhandledRejection / the runBroker reject) can
|
|
57
|
+
// dispose it before process.exit. The bash tool spawns children `detached` (own
|
|
58
|
+
// pgid); only session.dispose() (→ abortBash / agent.abort → killProcessTree)
|
|
59
|
+
// reaps them. The graceful exit path already disposes; without this the crash
|
|
60
|
+
// path would `process.exit(1)` and ORPHAN every in-flight bash subprocess.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
let activeSession = null;
|
|
63
|
+
/** Dispose the live engine session if one exists (idempotent). Called by
|
|
64
|
+
* broker-cli's fatal handlers before exit so a crash never orphans detached
|
|
65
|
+
* bash children. No-op before the session is built or after a clean dispose. */
|
|
66
|
+
export function disposeActiveSession() {
|
|
67
|
+
const s = activeSession;
|
|
68
|
+
activeSession = null;
|
|
69
|
+
if (s !== null) {
|
|
70
|
+
try {
|
|
71
|
+
s.dispose();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* dispose must not block the fatal exit */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// runBroker — the broker process body (plan T4 steps 1–6)
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
export async function runBroker(nodeId) {
|
|
82
|
+
// Defensive: any `crtr` child the engine/extensions spawn must inherit the
|
|
83
|
+
// front-door recursion guard (runtime/CLAUDE.md fork-bomb invariant). The host
|
|
84
|
+
// (T6) also sets this; we set it again so the broker is self-sufficient.
|
|
85
|
+
process.env[FRONT_DOOR_ENV] = '1';
|
|
86
|
+
// The broker is PANELESS. A `--headless` child spawned by a tmux-resident
|
|
87
|
+
// parent inherits the parent's TMUX_PANE/TMUX in its env (T6 spawns with
|
|
88
|
+
// {...process.env}); left in place, the bound stophook's refresh-yield branch
|
|
89
|
+
// (canvas-stophook.ts) would call reviveInPlace on the PARENT's %pane_id
|
|
90
|
+
// (respawn-pane -k) — hijacking the parent's pane with a fresh pi, killing the
|
|
91
|
+
// parent and orphaning this broker. Strip both so every stophook pane-branch
|
|
92
|
+
// takes its no-pane path (→ ctx.shutdown() → our shutdownHandler → clean exit;
|
|
93
|
+
// the daemon then respawns the node as a broker).
|
|
94
|
+
delete process.env['TMUX_PANE'];
|
|
95
|
+
delete process.env['TMUX'];
|
|
96
|
+
// 1. Version tripwire + load the launch recipe the host serialized.
|
|
97
|
+
const engine = await loadBrokerEngine();
|
|
98
|
+
assertEngineVersion(engine.VERSION);
|
|
99
|
+
// M4 / M6 (scout mq5thyli) — DELIBERATELY SKIPPED on the 0.78.1 pin. Both
|
|
100
|
+
// `configureHttpDispatcher` (M4: proxy support + the undici decompression fix)
|
|
101
|
+
// and `runMigrations` (M6: config/auth migration for a box where the pi CLI
|
|
102
|
+
// never ran) are CLI-only helpers that the pinned SDK does NOT re-export: the
|
|
103
|
+
// package's `exports` map is `.`-only, neither symbol is in `dist/index.d.ts`,
|
|
104
|
+
// a deep import (`.../dist/core/http-dispatcher.js`) is blocked with
|
|
105
|
+
// ERR_PACKAGE_PATH_NOT_EXPORTED, and the only exports-map entries are `.` and
|
|
106
|
+
// `./hooks` (neither carries these symbols). Per the "verify export; skip-with-
|
|
107
|
+
// note if absent" directive we do not vendor or reach around the export wall.
|
|
108
|
+
// Revisit when the SDK pin bumps to a version that re-exports them publicly.
|
|
109
|
+
const dir = nodeDir(nodeId);
|
|
110
|
+
const launchFile = join(dir, 'broker-launch.json');
|
|
111
|
+
const inv = JSON.parse(readFileSync(launchFile, 'utf8'));
|
|
112
|
+
const cfg = piInvocationToSdkConfig(inv); // also merges inv.env
|
|
113
|
+
// Fork-on-spawn is the runtime.fork() path (createAgentSessionRuntime), not
|
|
114
|
+
// wired headless in Phase 3. Fail loud rather than silently mis-resume.
|
|
115
|
+
if (cfg.forkFrom !== undefined && cfg.forkFrom !== '') {
|
|
116
|
+
throw new Error(`[broker] --fork is not supported by the headless broker in Phase 3 ` +
|
|
117
|
+
`(forkFrom=${cfg.forkFrom}); the SDK fork path is createAgentSessionRuntime.fork()`);
|
|
118
|
+
}
|
|
119
|
+
// 2–4. Build the engine session via the SERVICES path (C3) — see
|
|
120
|
+
// buildBrokerSession below. Register it so the FATAL exit path (M3) can
|
|
121
|
+
// dispose it and reap detached bash children.
|
|
122
|
+
// `session` + `services` are MUTABLE holders (T3 session-rebind): when the
|
|
123
|
+
// controller drives new_session/switch_session/fork, the AgentSessionRuntime
|
|
124
|
+
// tears down the old session and builds the next one, then runs our rebind
|
|
125
|
+
// callback which reassigns these. Every closure below (buildSnapshot,
|
|
126
|
+
// driveEngine, handleFrame, disposeAndExit) reads through them, so they all
|
|
127
|
+
// follow the live session automatically. `runtime` is undefined for an engine
|
|
128
|
+
// that does not expose the replacement API (the fake-engine fixture).
|
|
129
|
+
// eslint-disable-next-line prefer-const
|
|
130
|
+
let { session, services, resuming, runtime } = await buildBrokerSession(engine, cfg);
|
|
131
|
+
activeSession = session;
|
|
132
|
+
// Initialize pi's process-global theme. The pi binary does this at boot
|
|
133
|
+
// (main.js: initTheme(settingsManager.getTheme(), appMode === 'interactive')),
|
|
134
|
+
// but the SDK convenience path the broker uses does NOT. Without it, ANY
|
|
135
|
+
// extension hook that reads `ctx.ui.theme` throws "Theme not initialized" —
|
|
136
|
+
// and the extension runner SILENTLY swallows that throw and discards the
|
|
137
|
+
// hook's returned payload. The visible symptom was the OAuth billing adapter's
|
|
138
|
+
// before_provider_request handler throwing, so its injected Claude-Code billing
|
|
139
|
+
// header was dropped and subscription turns 400'd ("draws from extra usage").
|
|
140
|
+
// headless ⇒ no file watcher (false). Idempotent global; call once at boot.
|
|
141
|
+
try {
|
|
142
|
+
initTheme(services.settingsManager.getTheme(), false);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
process.stderr.write(`[broker] WARNING: initTheme failed: ${String(err)}\n`);
|
|
146
|
+
}
|
|
147
|
+
// N3: the node's display name is re-applied inside rebindSession (below) so it
|
|
148
|
+
// survives a session replacement (new_session/switch_session/fork), not only at
|
|
149
|
+
// boot.
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
// Socket fan-out + controller arbitration state
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
const sockPath = join(dir, 'view.sock');
|
|
154
|
+
const attachPath = join(jobDir(nodeId), 'attach.json');
|
|
155
|
+
const clients = new Set();
|
|
156
|
+
const pendingDialogs = new Map();
|
|
157
|
+
let controllerId = null;
|
|
158
|
+
let disposed = false;
|
|
159
|
+
let server;
|
|
160
|
+
const controllerClient = () => {
|
|
161
|
+
if (controllerId === null)
|
|
162
|
+
return null;
|
|
163
|
+
for (const c of clients)
|
|
164
|
+
if (c.id === controllerId)
|
|
165
|
+
return c;
|
|
166
|
+
return null;
|
|
167
|
+
};
|
|
168
|
+
// Persist viewer presence to job/attach.json on every viewer state change
|
|
169
|
+
// (hello accepted, client drop/shed, control handoff) so out-of-process
|
|
170
|
+
// readers (the GRAPH view's attached-row tint) can see whether a human is
|
|
171
|
+
// watching this paneless node. Plain writeFileSync, matching telemetry.json's
|
|
172
|
+
// convention; best-effort — presence writing must never crash the broker.
|
|
173
|
+
// disposeAndExit unlinks the file, so a clean exit never leaves a stale claim
|
|
174
|
+
// (readers additionally trust it only while the node is 'active', fencing off
|
|
175
|
+
// a crash-orphaned file).
|
|
176
|
+
const persistAttachState = () => {
|
|
177
|
+
try {
|
|
178
|
+
let viewers = 0;
|
|
179
|
+
for (const c of clients)
|
|
180
|
+
if (c.helloed)
|
|
181
|
+
viewers += 1;
|
|
182
|
+
const dirPath = jobDir(nodeId);
|
|
183
|
+
if (!existsSync(dirPath))
|
|
184
|
+
mkdirSync(dirPath, { recursive: true });
|
|
185
|
+
writeFileSync(attachPath, JSON.stringify({ viewers, controller_id: controllerId, updated: new Date().toISOString() }, null, 2), 'utf8');
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* presence is best-effort; never crash the broker */
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
// The host redirects the broker's stdout+stderr to job/broker.log (host.ts), so
|
|
192
|
+
// stderr is the broker's durable log sink (boot/crash diagnostics already use it).
|
|
193
|
+
const logBroker = (msg) => {
|
|
194
|
+
try {
|
|
195
|
+
process.stderr.write(`[broker] ${msg}\n`);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
/* best effort */
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
// Free control if the departing/dropped client held it (shared by drop +
|
|
202
|
+
// dropSlowClient). controllerId can outlive the socket until 'close' fires, so
|
|
203
|
+
// releasing here keeps arbitration correct the instant a controller is shed.
|
|
204
|
+
const releaseControlIfHeldBy = (client) => {
|
|
205
|
+
if (client.id !== '' && client.id === controllerId) {
|
|
206
|
+
controllerId = null;
|
|
207
|
+
broadcastControlChanged();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
// Shed a client — destroy the socket + remove it + release its control — used by
|
|
211
|
+
// both the M1 backpressure drop and the G7 frame-overflow drop. 'close' (→ drop)
|
|
212
|
+
// follows the destroy; releasing control here makes the shed immediate so a
|
|
213
|
+
// misbehaving controller can't keep arbitration pinned until 'close' fires.
|
|
214
|
+
const dropClient = (client, reason) => {
|
|
215
|
+
if (!clients.has(client))
|
|
216
|
+
return; // already gone
|
|
217
|
+
logBroker(`dropping viewer ${client.id || '(pre-hello)'} — ${reason}`);
|
|
218
|
+
clients.delete(client);
|
|
219
|
+
persistAttachState(); // a viewer was shed
|
|
220
|
+
releaseControlIfHeldBy(client);
|
|
221
|
+
try {
|
|
222
|
+
client.socket.destroy();
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* ignore */
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
// `bypassHwm` (F1): the catch-up `welcome` carries the full message history and
|
|
229
|
+
// can legitimately be tens of MiB — far larger than a steady-state live frame.
|
|
230
|
+
// It is still ACCOUNTED (so a genuinely stalled client that also floods on live
|
|
231
|
+
// events is still shed by the NEXT frame's check), but it must never be the
|
|
232
|
+
// REASON a healthy client is dropped before it has had a chance to drain it.
|
|
233
|
+
const sendFrame = (client, frame, bypassHwm = false) => {
|
|
234
|
+
let data;
|
|
235
|
+
try {
|
|
236
|
+
data = encodeFrame(frame);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return; // an unserializable frame must never crash the broker
|
|
240
|
+
}
|
|
241
|
+
const bytes = Buffer.byteLength(data);
|
|
242
|
+
// M1 high-water mark: a viewer that has fallen too far behind on draining its
|
|
243
|
+
// socket is shed rather than allowed to grow the broker's memory unbounded.
|
|
244
|
+
if (!bypassHwm &&
|
|
245
|
+
(client.queuedFrames + 1 > MAX_QUEUED_FRAMES ||
|
|
246
|
+
client.pendingBytes + bytes > MAX_PENDING_BYTES)) {
|
|
247
|
+
dropClient(client, `backpressure high-water mark exceeded (queued=${client.queuedFrames}, pending=${client.pendingBytes}B)`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
client.queuedFrames += 1;
|
|
251
|
+
client.pendingBytes += bytes;
|
|
252
|
+
try {
|
|
253
|
+
// The per-write completion callback fires when THIS chunk is flushed to the
|
|
254
|
+
// OS — a finer-grained drain signal than a socket-wide 'drain' event, and it
|
|
255
|
+
// keeps the accounting exact. write()'s boolean return is the coarse
|
|
256
|
+
// "buffer full" hint; the per-chunk callback is what we account on.
|
|
257
|
+
client.socket.write(data, () => {
|
|
258
|
+
client.queuedFrames -= 1;
|
|
259
|
+
client.pendingBytes -= bytes;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Dead socket: undo this frame's accounting; 'close' → drop() cleans the rest.
|
|
264
|
+
client.queuedFrames -= 1;
|
|
265
|
+
client.pendingBytes -= bytes;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const broadcast = (frame) => {
|
|
269
|
+
for (const c of clients)
|
|
270
|
+
if (c.helloed)
|
|
271
|
+
sendFrame(c, frame);
|
|
272
|
+
};
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// F2: message_update coalescing. `relayEvent` is the single entry point for
|
|
275
|
+
// engine events headed to viewers: a message_update is held (latest wins) and
|
|
276
|
+
// flushed on a short timer; ANY other event flushes the held update first,
|
|
277
|
+
// synchronously, so cross-type ordering is exactly the engine's. The timer is
|
|
278
|
+
// unref'd — it must never hold the broker process open past dispose.
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
let pendingUpdate = null;
|
|
281
|
+
let pendingUpdateTimer = null;
|
|
282
|
+
const flushPendingUpdate = () => {
|
|
283
|
+
if (pendingUpdateTimer !== null) {
|
|
284
|
+
clearTimeout(pendingUpdateTimer);
|
|
285
|
+
pendingUpdateTimer = null;
|
|
286
|
+
}
|
|
287
|
+
if (pendingUpdate !== null) {
|
|
288
|
+
const frame = pendingUpdate;
|
|
289
|
+
pendingUpdate = null;
|
|
290
|
+
broadcast(frame);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const relayEvent = (event) => {
|
|
294
|
+
if (event.type === 'message_update') {
|
|
295
|
+
pendingUpdate = event; // latest full-message update supersedes the held one
|
|
296
|
+
if (pendingUpdateTimer === null) {
|
|
297
|
+
pendingUpdateTimer = setTimeout(flushPendingUpdate, MESSAGE_UPDATE_COALESCE_MS);
|
|
298
|
+
if (typeof pendingUpdateTimer.unref === 'function')
|
|
299
|
+
pendingUpdateTimer.unref();
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Any non-update event: flush the held update FIRST so a viewer never sees
|
|
304
|
+
// (e.g.) message_end before the update it supersedes — then relay verbatim.
|
|
305
|
+
flushPendingUpdate();
|
|
306
|
+
broadcast(event);
|
|
307
|
+
};
|
|
308
|
+
const broadcastControlChanged = () => {
|
|
309
|
+
persistAttachState(); // every control change is a viewer-state change
|
|
310
|
+
broadcast({ type: 'control_changed', controller_id: controllerId });
|
|
311
|
+
};
|
|
312
|
+
const buildSnapshot = () => ({
|
|
313
|
+
messages: session.messages,
|
|
314
|
+
stats: session.getSessionStats(),
|
|
315
|
+
state: {
|
|
316
|
+
sessionId: session.sessionId,
|
|
317
|
+
sessionFile: session.sessionFile,
|
|
318
|
+
model: session.model?.id,
|
|
319
|
+
isStreaming: session.isStreaming,
|
|
320
|
+
// §1.3.2 — pure getter reads mirroring RPC get_state (viewer footer parity).
|
|
321
|
+
thinkingLevel: session.thinkingLevel,
|
|
322
|
+
steeringMode: session.steeringMode,
|
|
323
|
+
followUpMode: session.followUpMode,
|
|
324
|
+
sessionName: session.sessionName,
|
|
325
|
+
autoCompactionEnabled: session.autoCompactionEnabled,
|
|
326
|
+
pendingMessageCount: session.pendingMessageCount,
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
// Send a client its catch-up snapshot. welcome.pending_dialog carries a single
|
|
330
|
+
// still-in-flight dialog to a controller attaching mid-dialog (T4); only the
|
|
331
|
+
// controller can answer one, so observers get null. The pendingDialogs map is
|
|
332
|
+
// insertion-ordered — the first entry is the canonical one carried here; any
|
|
333
|
+
// extras are re-routed explicitly by the caller (rare: concurrent dialogs).
|
|
334
|
+
const sendWelcome = (client) => {
|
|
335
|
+
const first = client.role === 'controller' ? pendingDialogs.values().next().value : undefined;
|
|
336
|
+
sendFrame(client, {
|
|
337
|
+
type: 'welcome',
|
|
338
|
+
snapshot: buildSnapshot(),
|
|
339
|
+
role: client.role,
|
|
340
|
+
controller_id: controllerId,
|
|
341
|
+
pending_dialog: first !== undefined ? first.request : null,
|
|
342
|
+
}, true);
|
|
343
|
+
};
|
|
344
|
+
// T4 re-route on become-controller: a dialog raised while a prior controller was
|
|
345
|
+
// attached stays pending after that controller detaches (it is NOT cancelled —
|
|
346
|
+
// see makeBrokerUiContext / the M2 keep-pending fix), so whoever takes control
|
|
347
|
+
// next must be handed it to answer.
|
|
348
|
+
const reroutePendingDialogsTo = (client) => {
|
|
349
|
+
for (const d of pendingDialogs.values())
|
|
350
|
+
sendFrame(client, d.request);
|
|
351
|
+
};
|
|
352
|
+
// After a session-replacing op (new_session/switch_session/fork) the engine's
|
|
353
|
+
// entire message history changed, so every attached viewer must rebuild from a
|
|
354
|
+
// fresh snapshot of the NEW session.
|
|
355
|
+
const reWelcomeAll = () => {
|
|
356
|
+
for (const c of clients)
|
|
357
|
+
if (c.helloed)
|
|
358
|
+
sendWelcome(c);
|
|
359
|
+
};
|
|
360
|
+
// -------------------------------------------------------------------------
|
|
361
|
+
// The single exit helper (plan §0): dispose the engine, close + unlink the
|
|
362
|
+
// socket, exit 0. Idempotent — every trigger converges here.
|
|
363
|
+
// -------------------------------------------------------------------------
|
|
364
|
+
const disposeAndExit = (_reason) => {
|
|
365
|
+
if (disposed)
|
|
366
|
+
return;
|
|
367
|
+
disposed = true;
|
|
368
|
+
activeSession = null; // graceful path owns dispose; keep the fatal hook a no-op
|
|
369
|
+
try {
|
|
370
|
+
session.dispose();
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
/* dispose must not block exit */
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
server?.close();
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
/* ignore */
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
if (existsSync(sockPath))
|
|
383
|
+
unlinkSync(sockPath);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
/* ignore */
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
// A clean exit never leaves a stale presence claim behind.
|
|
390
|
+
if (existsSync(attachPath))
|
|
391
|
+
unlinkSync(attachPath);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
/* ignore */
|
|
395
|
+
}
|
|
396
|
+
process.exit(0);
|
|
397
|
+
};
|
|
398
|
+
// -------------------------------------------------------------------------
|
|
399
|
+
// Extension-dialog routing (C2). makeBrokerUiContext owns the dialogPromise;
|
|
400
|
+
// here we just give it the three broker-side hooks it needs: who the current
|
|
401
|
+
// controller is (null = zero viewers → noOp fallback), how to forward a dialog
|
|
402
|
+
// to that controller, and the pending-dialog registry the controller answers
|
|
403
|
+
// through. The zero-viewer path NEVER hangs and NEVER waits on a per-dialog
|
|
404
|
+
// timeout (design §5.4's timeout premise is false — see makeBrokerUiContext).
|
|
405
|
+
// -------------------------------------------------------------------------
|
|
406
|
+
const uiContext = makeBrokerUiContext({
|
|
407
|
+
controller: controllerClient,
|
|
408
|
+
forward: (client, request) => sendFrame(client, request),
|
|
409
|
+
pending: pendingDialogs,
|
|
410
|
+
});
|
|
411
|
+
// -------------------------------------------------------------------------
|
|
412
|
+
// 3+4 (plan): bind the FULL canvas extensions (mode 'print') AND fan the single
|
|
413
|
+
// engine event stream to all clients, VERBATIM — both wrapped in one
|
|
414
|
+
// `rebindSession` so a session-replacing op (new_session/switch_session/fork)
|
|
415
|
+
// can re-run them against the NEW session. This mirrors pi's rpc-mode
|
|
416
|
+
// rebindSession (rpc-mode.js:227): the AgentSessionRuntime tears down the old
|
|
417
|
+
// session, builds the next, then calls this back; we re-bind extensions and
|
|
418
|
+
// re-subscribe so the canvas hooks + the fan-out follow the live session.
|
|
419
|
+
//
|
|
420
|
+
// bindExtensions fires session_start → the stophook records pi_session_id/file
|
|
421
|
+
// + recordPid(pid); the shutdownHandler is the precise broker-exit trigger (the
|
|
422
|
+
// stophook calls ctx.shutdown() only in its done / idle-release / refresh
|
|
423
|
+
// branches, NOT on reprompt). The engine-driven exit is deliberately NOT
|
|
424
|
+
// inferred from raw agent_end in the relay: the bound stophook is the SOLE
|
|
425
|
+
// authority on when the node stops — it exits synchronously at
|
|
426
|
+
// agent-session.js:266, BEFORE this subscriber runs at :268, with intent
|
|
427
|
+
// already persisted; in every OTHER branch it STAYS ALIVE. An agent_end idle
|
|
428
|
+
// heuristic would override those stay-alive decisions, so there is no such
|
|
429
|
+
// backstop; shutdownHandler + the inbound `shutdown` frame are the only exits.
|
|
430
|
+
//
|
|
431
|
+
// m7: the subscriber body is wrapped in try/catch — a synchronous throw out of
|
|
432
|
+
// a subscriber otherwise propagates as a run failure that would crash the broker.
|
|
433
|
+
// -------------------------------------------------------------------------
|
|
434
|
+
let unsubscribe;
|
|
435
|
+
const rebindSession = async () => {
|
|
436
|
+
if (runtime !== undefined) {
|
|
437
|
+
session = runtime.session; // the runtime replaced it; follow the new one
|
|
438
|
+
services = runtime.services;
|
|
439
|
+
}
|
|
440
|
+
activeSession = session; // keep the fatal-exit hook pointed at the live session
|
|
441
|
+
await session.bindExtensions({
|
|
442
|
+
uiContext,
|
|
443
|
+
mode: 'print',
|
|
444
|
+
shutdownHandler: () => disposeAndExit('shutdown-hook'),
|
|
445
|
+
});
|
|
446
|
+
if (cfg.editorName !== undefined && cfg.editorName !== '') {
|
|
447
|
+
try {
|
|
448
|
+
session.setSessionName(cfg.editorName); // N3: re-apply across session replacement
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
/* non-fatal: naming is cosmetic */
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
unsubscribe?.();
|
|
455
|
+
// F2: a session replacement abandons the old session's stream — drop (don't
|
|
456
|
+
// flush) a held update from it; its message history is superseded by the
|
|
457
|
+
// reWelcomeAll() snapshot of the NEW session.
|
|
458
|
+
if (pendingUpdateTimer !== null) {
|
|
459
|
+
clearTimeout(pendingUpdateTimer);
|
|
460
|
+
pendingUpdateTimer = null;
|
|
461
|
+
}
|
|
462
|
+
pendingUpdate = null;
|
|
463
|
+
unsubscribe = session.subscribe((event) => {
|
|
464
|
+
try {
|
|
465
|
+
relayEvent(event);
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
logBroker(`event relay threw: ${String(err)}`);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
};
|
|
472
|
+
// Register the rebind BEFORE the first bind so a replacement triggered during
|
|
473
|
+
// startup is honored; then do the initial bind + subscribe.
|
|
474
|
+
runtime?.setRebindSession(async () => {
|
|
475
|
+
await rebindSession();
|
|
476
|
+
});
|
|
477
|
+
await rebindSession();
|
|
478
|
+
// -------------------------------------------------------------------------
|
|
479
|
+
// Drive the engine on behalf of the single controller.
|
|
480
|
+
// -------------------------------------------------------------------------
|
|
481
|
+
const driveEngine = (client, frame) => {
|
|
482
|
+
const relayError = (err) => sendFrame(client, { type: 'error', code: 'engine_error', message: String(err) });
|
|
483
|
+
switch (frame.type) {
|
|
484
|
+
case 'prompt': {
|
|
485
|
+
// C1: forward any pasted/attached images (frame.images) to the engine —
|
|
486
|
+
// they ride as a field on PromptOptions (pi: `prompt(text, { images })`).
|
|
487
|
+
// m-B (streaming-safe): the controller routes prompt-vs-steer off a
|
|
488
|
+
// possibly-STALE `isStreaming` snapshot, so a `prompt` frame can arrive
|
|
489
|
+
// mid-stream; PromptOptions.streamingBehavior is "Required if streaming"
|
|
490
|
+
// (prompt() throws without it). Make the broker authoritative — when the
|
|
491
|
+
// LIVE session is streaming, supply it so the client's routing is a HINT,
|
|
492
|
+
// not a correctness requirement. "steer" mirrors pi interactive-mode's
|
|
493
|
+
// own Enter-while-streaming submit (interactive-mode.js:
|
|
494
|
+
// `prompt(text, { streamingBehavior: "steer" })`).
|
|
495
|
+
const options = {};
|
|
496
|
+
if (frame.images !== undefined)
|
|
497
|
+
options.images = frame.images;
|
|
498
|
+
if (session.isStreaming)
|
|
499
|
+
options.streamingBehavior = 'steer';
|
|
500
|
+
void session.prompt(frame.text, options).catch(relayError);
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
case 'steer':
|
|
504
|
+
// C1: pi's steer() takes images as a POSITIONAL 2nd arg (`steer(text, images?)`).
|
|
505
|
+
void session.steer(frame.text, frame.images).catch(relayError);
|
|
506
|
+
break;
|
|
507
|
+
case 'follow_up':
|
|
508
|
+
// C1: pi's followUp() takes images as a POSITIONAL 2nd arg (`followUp(text, images?)`).
|
|
509
|
+
void session.followUp(frame.text, frame.images).catch(relayError);
|
|
510
|
+
break;
|
|
511
|
+
case 'abort':
|
|
512
|
+
void session.abort().catch(relayError);
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
// -------------------------------------------------------------------------
|
|
517
|
+
// Command-op helpers (T3, §2.3). The controller guard is hoisted here (it was
|
|
518
|
+
// inlined twice) and reused by all controller-only ops; the ack/error replies
|
|
519
|
+
// and a few resolvers keep the per-op cases one-liners.
|
|
520
|
+
// -------------------------------------------------------------------------
|
|
521
|
+
/** Reject a non-controller for a controller-only op. Returns true when rejected
|
|
522
|
+
* (the caller should `break`). */
|
|
523
|
+
const notController = (client, what) => {
|
|
524
|
+
if (client.id === controllerId)
|
|
525
|
+
return false;
|
|
526
|
+
sendFrame(client, {
|
|
527
|
+
type: 'error',
|
|
528
|
+
code: 'not_controller',
|
|
529
|
+
message: `only the controlling client may ${what}`,
|
|
530
|
+
});
|
|
531
|
+
return true;
|
|
532
|
+
};
|
|
533
|
+
const ackTo = (client, op, ok = true, detail) => sendFrame(client, { type: 'ack', for: op, ok, ...(detail !== undefined ? { detail } : {}) });
|
|
534
|
+
const engineErrorTo = (client) => (err) => sendFrame(client, { type: 'error', code: 'engine_error', message: String(err) });
|
|
535
|
+
/** Resolve a `provider/id` model spec against the LIVE services registry (which
|
|
536
|
+
* carries any extension-registered providers). Pure: returns undefined on a
|
|
537
|
+
* malformed spec or an unknown model. */
|
|
538
|
+
const findModelSpec = (spec) => {
|
|
539
|
+
const slash = spec.indexOf('/');
|
|
540
|
+
if (slash <= 0)
|
|
541
|
+
return undefined;
|
|
542
|
+
return services.modelRegistry.find(spec.slice(0, slash), spec.slice(slash + 1));
|
|
543
|
+
};
|
|
544
|
+
/** The merged command list for `get_commands` (C6/M9): the engine's registered
|
|
545
|
+
* extension + skill commands and file-based prompt templates, MERGED with the
|
|
546
|
+
* vendored BUILTIN_SLASH_COMMANDS (RPC omits builtins). Returned to the viewer
|
|
547
|
+
* as JSON in `ack.detail` (see the get_commands case). */
|
|
548
|
+
const buildCommandList = () => {
|
|
549
|
+
const out = [];
|
|
550
|
+
for (const b of BUILTIN_SLASH_COMMANDS) {
|
|
551
|
+
out.push({ name: b.name, description: b.description, source: 'builtin' });
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
// Skill commands surface here too — pi registers each skill as a command.
|
|
555
|
+
for (const c of session.extensionRunner.getRegisteredCommands()) {
|
|
556
|
+
out.push({ name: c.invocationName, description: c.description ?? '', source: 'command' });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
/* an engine without a live extensionRunner (e.g. the fake) — builtins only */
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
for (const t of session.promptTemplates) {
|
|
564
|
+
out.push({ name: t.name, description: t.description, source: 'template' });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
/* ignore */
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
};
|
|
572
|
+
/** Run a session-replacing op (new_session/switch_session/fork). The runtime
|
|
573
|
+
* rebinds extensions + re-subscribes via setRebindSession before it resolves,
|
|
574
|
+
* so on success we just re-snapshot every viewer onto the new session. */
|
|
575
|
+
const runReplacement = (client, op, run) => {
|
|
576
|
+
if (runtime === undefined) {
|
|
577
|
+
sendFrame(client, {
|
|
578
|
+
type: 'error',
|
|
579
|
+
code: 'engine_error',
|
|
580
|
+
message: 'session replacement unsupported at this engine pin',
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
void run(runtime)
|
|
585
|
+
.then((r) => {
|
|
586
|
+
if (!r.cancelled)
|
|
587
|
+
reWelcomeAll();
|
|
588
|
+
ackTo(client, op, !r.cancelled, r.cancelled ? 'cancelled by extension' : undefined);
|
|
589
|
+
})
|
|
590
|
+
.catch(engineErrorTo(client));
|
|
591
|
+
};
|
|
592
|
+
const handleFrame = (client, frame) => {
|
|
593
|
+
switch (frame.type) {
|
|
594
|
+
case 'hello': {
|
|
595
|
+
client.id = frame.client_id;
|
|
596
|
+
client.helloed = true;
|
|
597
|
+
// First-attach-wins controller (§5.3): admit as controller iff one was
|
|
598
|
+
// requested and none is currently held; otherwise read-only observer.
|
|
599
|
+
if (frame.role === 'controller' && controllerId === null) {
|
|
600
|
+
client.role = 'controller';
|
|
601
|
+
controllerId = client.id;
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
client.role = 'observer';
|
|
605
|
+
}
|
|
606
|
+
sendWelcome(client);
|
|
607
|
+
persistAttachState(); // a helloed viewer arrived
|
|
608
|
+
if (client.role === 'controller') {
|
|
609
|
+
// welcome carried the FIRST pending dialog (T4); forward any extras so a
|
|
610
|
+
// controller attaching mid-dialog can answer every in-flight dialog.
|
|
611
|
+
const pend = [...pendingDialogs.values()];
|
|
612
|
+
for (let i = 1; i < pend.length; i++)
|
|
613
|
+
sendFrame(client, pend[i].request);
|
|
614
|
+
broadcastControlChanged();
|
|
615
|
+
}
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
case 'prompt':
|
|
619
|
+
case 'steer':
|
|
620
|
+
case 'follow_up':
|
|
621
|
+
case 'abort': {
|
|
622
|
+
if (notController(client, 'drive the engine'))
|
|
623
|
+
break;
|
|
624
|
+
driveEngine(client, frame);
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
case 'extension_ui_response': {
|
|
628
|
+
if (notController(client, 'answer dialogs'))
|
|
629
|
+
break;
|
|
630
|
+
pendingDialogs.get(frame.id)?.resolve(frame);
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
case 'request_control': {
|
|
634
|
+
if (controllerId === null) {
|
|
635
|
+
controllerId = client.id;
|
|
636
|
+
client.role = 'controller';
|
|
637
|
+
broadcastControlChanged();
|
|
638
|
+
reroutePendingDialogsTo(client); // T4: hand the new controller pending dialogs
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
sendFrame(client, {
|
|
642
|
+
type: 'error',
|
|
643
|
+
code: 'control_held',
|
|
644
|
+
message: 'another client holds control',
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
case 'release_control': {
|
|
650
|
+
if (client.id === controllerId) {
|
|
651
|
+
controllerId = null;
|
|
652
|
+
client.role = 'observer';
|
|
653
|
+
// M2 (T4): do NOT cancel in-flight dialogs on release — keep them pending
|
|
654
|
+
// under the broker-side default timeout so a brief release/reattach (or a
|
|
655
|
+
// handoff to another observer) never loses an answerable dialog.
|
|
656
|
+
broadcastControlChanged();
|
|
657
|
+
}
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
// --- extended engine-command ops (T3, §1.2 floor set) ------------------
|
|
661
|
+
case 'set_model': {
|
|
662
|
+
if (notController(client, 'set the model'))
|
|
663
|
+
break;
|
|
664
|
+
let model;
|
|
665
|
+
try {
|
|
666
|
+
model = findModelSpec(frame.model);
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
// N2: registry.find should never throw on the real SDK, but a degenerate
|
|
670
|
+
// engine must still get a reply rather than a silently-dropped frame.
|
|
671
|
+
engineErrorTo(client)(err);
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
if (model === undefined) {
|
|
675
|
+
sendFrame(client, {
|
|
676
|
+
type: 'error',
|
|
677
|
+
code: 'engine_error',
|
|
678
|
+
message: `model '${frame.model}' not found in the registry`,
|
|
679
|
+
});
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
void session.setModel(model).then(() => ackTo(client, 'set_model')).catch(engineErrorTo(client));
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
case 'cycle_model': {
|
|
686
|
+
if (notController(client, 'cycle the model'))
|
|
687
|
+
break;
|
|
688
|
+
void session.cycleModel().then(() => ackTo(client, 'cycle_model')).catch(engineErrorTo(client));
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
case 'set_thinking_level': {
|
|
692
|
+
if (notController(client, 'set the thinking level'))
|
|
693
|
+
break;
|
|
694
|
+
try {
|
|
695
|
+
session.setThinkingLevel(frame.level);
|
|
696
|
+
ackTo(client, 'set_thinking_level');
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
engineErrorTo(client)(err);
|
|
700
|
+
}
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
case 'set_auto_retry': {
|
|
704
|
+
if (notController(client, 'set auto-retry'))
|
|
705
|
+
break;
|
|
706
|
+
try {
|
|
707
|
+
session.setAutoRetryEnabled(frame.enabled);
|
|
708
|
+
ackTo(client, 'set_auto_retry');
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
engineErrorTo(client)(err);
|
|
712
|
+
}
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
case 'set_auto_compaction': {
|
|
716
|
+
if (notController(client, 'set auto-compaction'))
|
|
717
|
+
break;
|
|
718
|
+
try {
|
|
719
|
+
session.setAutoCompactionEnabled(frame.enabled);
|
|
720
|
+
ackTo(client, 'set_auto_compaction');
|
|
721
|
+
}
|
|
722
|
+
catch (err) {
|
|
723
|
+
engineErrorTo(client)(err);
|
|
724
|
+
}
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
case 'compact': {
|
|
728
|
+
if (notController(client, 'compact the session'))
|
|
729
|
+
break;
|
|
730
|
+
void session
|
|
731
|
+
.compact(frame.instructions)
|
|
732
|
+
.then(() => ackTo(client, 'compact'))
|
|
733
|
+
.catch(engineErrorTo(client));
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
case 'set_session_name': {
|
|
737
|
+
if (notController(client, 'rename the session'))
|
|
738
|
+
break;
|
|
739
|
+
try {
|
|
740
|
+
session.setSessionName(frame.name);
|
|
741
|
+
ackTo(client, 'set_session_name');
|
|
742
|
+
}
|
|
743
|
+
catch (err) {
|
|
744
|
+
engineErrorTo(client)(err);
|
|
745
|
+
}
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
case 'get_commands': {
|
|
749
|
+
if (notController(client, 'list commands'))
|
|
750
|
+
break;
|
|
751
|
+
// The merged command list rides in ack.detail as JSON (the foundation's
|
|
752
|
+
// AckFrame.detail field) — the viewer (T6) JSON.parses it when for ===
|
|
753
|
+
// 'get_commands'. Keeps every command op a uniform ack reply.
|
|
754
|
+
try {
|
|
755
|
+
ackTo(client, 'get_commands', true, JSON.stringify(buildCommandList()));
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
engineErrorTo(client)(err);
|
|
759
|
+
}
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
case 'navigate_tree': {
|
|
763
|
+
if (notController(client, 'navigate the session tree'))
|
|
764
|
+
break;
|
|
765
|
+
void session
|
|
766
|
+
.navigateTree(frame.targetId, frame.options)
|
|
767
|
+
.then((r) => ackTo(client, 'navigate_tree', !r.cancelled))
|
|
768
|
+
.catch(engineErrorTo(client));
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
case 'reload': {
|
|
772
|
+
if (notController(client, 'reload'))
|
|
773
|
+
break;
|
|
774
|
+
void session.reload().then(() => ackTo(client, 'reload')).catch(engineErrorTo(client));
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
case 'export': {
|
|
778
|
+
if (notController(client, 'export the session'))
|
|
779
|
+
break;
|
|
780
|
+
if (frame.format === 'jsonl') {
|
|
781
|
+
try {
|
|
782
|
+
session.exportToJsonl(frame.path);
|
|
783
|
+
ackTo(client, 'export');
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
engineErrorTo(client)(err);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
void session
|
|
791
|
+
.exportToHtml(frame.path)
|
|
792
|
+
.then(() => ackTo(client, 'export'))
|
|
793
|
+
.catch(engineErrorTo(client));
|
|
794
|
+
}
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
case 'new_session': {
|
|
798
|
+
if (notController(client, 'start a new session'))
|
|
799
|
+
break;
|
|
800
|
+
runReplacement(client, 'new_session', (rt) => rt.newSession());
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
case 'switch_session': {
|
|
804
|
+
if (notController(client, 'switch sessions'))
|
|
805
|
+
break;
|
|
806
|
+
runReplacement(client, 'switch_session', (rt) => rt.switchSession(frame.path));
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
case 'fork': {
|
|
810
|
+
if (notController(client, 'fork the session'))
|
|
811
|
+
break;
|
|
812
|
+
runReplacement(client, 'fork', (rt) => rt.fork(frame.entryId));
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
case 'bye':
|
|
816
|
+
client.socket.end();
|
|
817
|
+
break;
|
|
818
|
+
case 'shutdown':
|
|
819
|
+
disposeAndExit('shutdown-frame');
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
// -------------------------------------------------------------------------
|
|
824
|
+
// 4 (plan): the socket listener — unlink any stale socket first.
|
|
825
|
+
// -------------------------------------------------------------------------
|
|
826
|
+
try {
|
|
827
|
+
if (existsSync(sockPath))
|
|
828
|
+
unlinkSync(sockPath);
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
/* a fresh listen below will surface a real problem */
|
|
832
|
+
}
|
|
833
|
+
server = createServer((socket) => {
|
|
834
|
+
const client = {
|
|
835
|
+
id: '',
|
|
836
|
+
role: 'observer',
|
|
837
|
+
socket,
|
|
838
|
+
decoder: new FrameDecoder(BROKER_READ_CAPS),
|
|
839
|
+
helloed: false,
|
|
840
|
+
pendingBytes: 0,
|
|
841
|
+
queuedFrames: 0,
|
|
842
|
+
};
|
|
843
|
+
clients.add(client);
|
|
844
|
+
socket.on('data', (chunk) => {
|
|
845
|
+
let frames;
|
|
846
|
+
try {
|
|
847
|
+
frames = client.decoder.push(chunk);
|
|
848
|
+
}
|
|
849
|
+
catch (err) {
|
|
850
|
+
// G7: a client frame over the bounded decoder caps (FrameOverflowError) is
|
|
851
|
+
// cap-and-dropped — best-effort error frame, then destroy the peer. The
|
|
852
|
+
// broker survives. The try/catch is around push() itself (not just the
|
|
853
|
+
// per-frame loop) because push() throws BEFORE returning any frames.
|
|
854
|
+
if (err instanceof FrameOverflowError) {
|
|
855
|
+
sendFrame(client, { type: 'error', code: 'frame_overflow', message: err.message });
|
|
856
|
+
dropClient(client, `frame overflow: ${err.message}`);
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
dropClient(client, `decoder error: ${String(err)}`);
|
|
860
|
+
}
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
for (const raw of frames) {
|
|
864
|
+
try {
|
|
865
|
+
handleFrame(client, raw);
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
/* one bad frame never crashes the broker */
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
const drop = () => {
|
|
873
|
+
clients.delete(client);
|
|
874
|
+
persistAttachState(); // a viewer disconnected
|
|
875
|
+
// M2 (T4): controller detach frees control but does NOT cancel in-flight
|
|
876
|
+
// dialogs — they stay pending under the broker-side default timeout so a
|
|
877
|
+
// brief detach/reattach (or a handoff to another observer who takes control)
|
|
878
|
+
// never loses an answerable dialog. Only the timeout or a new controller's
|
|
879
|
+
// answer resolves one.
|
|
880
|
+
releaseControlIfHeldBy(client);
|
|
881
|
+
};
|
|
882
|
+
socket.on('close', drop);
|
|
883
|
+
socket.on('error', () => {
|
|
884
|
+
/* close follows; drop there */
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
server.on('error', (err) => {
|
|
888
|
+
process.stderr.write(`[broker] socket server error: ${String(err)}\n`);
|
|
889
|
+
});
|
|
890
|
+
server.listen(sockPath);
|
|
891
|
+
// Initialize presence to the truthful zero state at boot — this also
|
|
892
|
+
// overwrites a stale attach.json a crashed prior incarnation left behind.
|
|
893
|
+
persistAttachState();
|
|
894
|
+
// OS-signal teardown (daemon kill / T6 fallback) → same clean exit path.
|
|
895
|
+
process.on('SIGTERM', () => disposeAndExit('SIGTERM'));
|
|
896
|
+
process.on('SIGINT', () => disposeAndExit('SIGINT'));
|
|
897
|
+
// -------------------------------------------------------------------------
|
|
898
|
+
// 5 (plan): a fresh start delivers the kickoff prompt. A resume needs none —
|
|
899
|
+
// the bound canvas-inbox-watcher drains the pending inbox entry and feeds it.
|
|
900
|
+
// -------------------------------------------------------------------------
|
|
901
|
+
if (!resuming && cfg.firstPrompt !== undefined && cfg.firstPrompt !== '') {
|
|
902
|
+
void session.prompt(cfg.firstPrompt).catch((err) => {
|
|
903
|
+
process.stderr.write(`[broker] first prompt failed: ${String(err)}\n`);
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
// buildBrokerSession (plan T4 steps 2–4) — turn the launch recipe into a live
|
|
909
|
+
// engine session via the pi SDK SERVICES path. Exported so the C3/C4 real-SDK
|
|
910
|
+
// regression tests can drive the EXACT production wiring (not the mock).
|
|
911
|
+
// ---------------------------------------------------------------------------
|
|
912
|
+
export async function buildBrokerSession(engine, cfg) {
|
|
913
|
+
// Fork-on-spawn is the runtime.fork() path (createAgentSessionRuntime), not
|
|
914
|
+
// wired headless in Phase 3. Fail loud rather than silently mis-resume.
|
|
915
|
+
if (cfg.forkFrom !== undefined && cfg.forkFrom !== '') {
|
|
916
|
+
throw new Error(`[broker] --fork is not supported by the headless broker in Phase 3 ` +
|
|
917
|
+
`(forkFrom=${cfg.forkFrom}); the SDK fork path is createAgentSessionRuntime.fork()`);
|
|
918
|
+
}
|
|
919
|
+
// 2. Build cwd-bound runtime services via the SERVICES path (C3) — NOT plain
|
|
920
|
+
// createAgentSession. createAgentSessionServices builds + reloads the
|
|
921
|
+
// resource loader (the `-e` canvas extensions + --append-system-prompt) and
|
|
922
|
+
// REGISTERS extension-provided model providers into the ModelRegistry (it is
|
|
923
|
+
// also where extension flag values would be applied — the broker recipe
|
|
924
|
+
// carries none today). Plain createAgentSession does NEITHER, so a node whose
|
|
925
|
+
// model comes from a custom-provider extension would get NO model. Mirrors pi
|
|
926
|
+
// main.js (createAgentSessionServices → …FromServices).
|
|
927
|
+
//
|
|
928
|
+
// C4 note: the pinned SDK (0.78.1) has NO project-trust concept at all —
|
|
929
|
+
// project context files (AGENTS.md/CLAUDE.md) load UNCONDITIONALLY (gated
|
|
930
|
+
// only by `noContextFiles`, left default-false), so the headless "trust
|
|
931
|
+
// resolves false → context silently dropped" gap cannot occur here. WHEN the
|
|
932
|
+
// SDK pin bumps to 0.79.0+ (which adds project-trust), pass an explicit
|
|
933
|
+
// `settingsManager: SettingsManager.create(cfg.cwd, getAgentDir(), { projectTrusted: true })`
|
|
934
|
+
// so headless trust defaults TRUSTED — and do NOT re-introduce the CLI's
|
|
935
|
+
// resolveProjectTrust, which returns false with no TTY.
|
|
936
|
+
//
|
|
937
|
+
// T3 session-rebind: services + model + session creation is factored into
|
|
938
|
+
// `buildForManager` so it can serve BOTH the initial boot AND the
|
|
939
|
+
// AgentSessionRuntime factory it is reused as below (new_session/
|
|
940
|
+
// switch_session/fork rebuild the session by re-invoking it for a new
|
|
941
|
+
// SessionManager). Mirrors pi main.js's `createRuntime`.
|
|
942
|
+
const agentDir = getAgentDir();
|
|
943
|
+
const buildForManager = async (o) => {
|
|
944
|
+
const services = await engine.createAgentSessionServices({
|
|
945
|
+
cwd: o.cwd,
|
|
946
|
+
agentDir: o.agentDir,
|
|
947
|
+
resourceLoaderOptions: {
|
|
948
|
+
additionalExtensionPaths: cfg.extensionPaths,
|
|
949
|
+
appendSystemPrompt: cfg.appendSystemPromptPath !== undefined ? [cfg.appendSystemPromptPath] : undefined,
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
// 3. Resolve the model spec (`anthropic/sonnet` → a Model) against the
|
|
953
|
+
// SERVICES registry — which has any extension-provided providers (C3).
|
|
954
|
+
// Undefined ⇒ the SDK picks the settings default.
|
|
955
|
+
let model;
|
|
956
|
+
if (cfg.model !== undefined && cfg.model !== '') {
|
|
957
|
+
const slash = cfg.model.indexOf('/');
|
|
958
|
+
if (slash > 0) {
|
|
959
|
+
model = services.modelRegistry.find(cfg.model.slice(0, slash), cfg.model.slice(slash + 1));
|
|
960
|
+
if (model === undefined) {
|
|
961
|
+
process.stderr.write(`[broker] WARNING: model '${cfg.model}' not found in registry — ` +
|
|
962
|
+
`falling back to the SDK default model.\n`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
process.stderr.write(`[broker] WARNING: model '${cfg.model}' has no 'provider/id' form — ` +
|
|
967
|
+
`using the SDK default model.\n`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const created = await engine.createAgentSessionFromServices({
|
|
971
|
+
services,
|
|
972
|
+
sessionManager: o.sessionManager,
|
|
973
|
+
model,
|
|
974
|
+
tools: cfg.tools,
|
|
975
|
+
sessionStartEvent: o.sessionStartEvent,
|
|
976
|
+
});
|
|
977
|
+
return { ...created, services };
|
|
978
|
+
};
|
|
979
|
+
// 4. Open (resume) or create (fresh) the session — the broker is the sole writer.
|
|
980
|
+
const resumePath = cfg.resumeSessionPath;
|
|
981
|
+
if ((resumePath === undefined || resumePath === '') &&
|
|
982
|
+
cfg.resumeSessionId !== undefined &&
|
|
983
|
+
cfg.resumeSessionId !== '') {
|
|
984
|
+
// SessionManager.open() takes a FILE path — it only path-normalizes; it does
|
|
985
|
+
// NOT resolve a bare uuid to its .jsonl the way pi's CLI does (via
|
|
986
|
+
// SessionManager.list). A bare-id-only resume would open a nonexistent
|
|
987
|
+
// <cwd>/<uuid> and silently start an EMPTY session. Fail loud: revive always
|
|
988
|
+
// passes the .jsonl path, so a bare id means an old node that never captured
|
|
989
|
+
// pi_session_file — mis-resuming it silently is worse than a crash.
|
|
990
|
+
throw new Error(`[broker] resume requires a session .jsonl PATH; got only a bare id ` +
|
|
991
|
+
`'${cfg.resumeSessionId}' (pi_session_file was never captured for this node)`);
|
|
992
|
+
}
|
|
993
|
+
const resuming = resumePath !== undefined && resumePath !== '';
|
|
994
|
+
const sessionManager = resuming
|
|
995
|
+
? engine.SessionManager.open(resumePath)
|
|
996
|
+
: engine.SessionManager.create(cfg.cwd);
|
|
997
|
+
// When the engine exposes the replacement API (real SDK), wrap the builder in an
|
|
998
|
+
// AgentSessionRuntime so new_session/switch_session/fork work; the runtime
|
|
999
|
+
// re-invokes `buildForManager` for each new SessionManager. The fake-engine
|
|
1000
|
+
// fixture omits it — fall back to a single direct build (those three ops then
|
|
1001
|
+
// reply error{engine_error}).
|
|
1002
|
+
if (engine.createAgentSessionRuntime !== undefined) {
|
|
1003
|
+
const factory = async (o) => {
|
|
1004
|
+
const r = await buildForManager({
|
|
1005
|
+
cwd: o.cwd,
|
|
1006
|
+
agentDir: o.agentDir,
|
|
1007
|
+
sessionManager: o.sessionManager,
|
|
1008
|
+
sessionStartEvent: o.sessionStartEvent,
|
|
1009
|
+
});
|
|
1010
|
+
return { ...r, diagnostics: r.services.diagnostics ?? [] };
|
|
1011
|
+
};
|
|
1012
|
+
const runtime = await engine.createAgentSessionRuntime(factory, {
|
|
1013
|
+
cwd: cfg.cwd,
|
|
1014
|
+
agentDir,
|
|
1015
|
+
sessionManager,
|
|
1016
|
+
});
|
|
1017
|
+
return { session: runtime.session, services: runtime.services, resuming, runtime };
|
|
1018
|
+
}
|
|
1019
|
+
const r = await buildForManager({ cwd: cfg.cwd, agentDir, sessionManager });
|
|
1020
|
+
return { session: r.session, services: r.services, resuming, runtime: undefined };
|
|
1021
|
+
}
|
|
1022
|
+
export function makeBrokerUiContext(deps) {
|
|
1023
|
+
// The dialog router. C2 (scout mq5thyli): the design §5.4 premise — that an
|
|
1024
|
+
// unattended dialog auto-resolves on its OWN timeout — is FALSE. `timeout` is
|
|
1025
|
+
// OPTIONAL on dialog opts, editor() takes none, and almost no real extension
|
|
1026
|
+
// passes one (permission-gate / confirm-destructive / plan-mode / subagent all
|
|
1027
|
+
// omit it). A timeout-reliant unattended node therefore deadlocks the agent
|
|
1028
|
+
// turn FOREVER. So with ZERO viewers attached we fall back to the SDK's noOp UI
|
|
1029
|
+
// behavior — resolve to the default (deny / cancel / undefined) IMMEDIATELY,
|
|
1030
|
+
// never arming a timer, never waiting. (Phase 4 adds the WITH-viewer forwarding
|
|
1031
|
+
// path, wrapped in a broker-side timeout+abort so a controller that attaches
|
|
1032
|
+
// but never answers cannot hang the turn either.)
|
|
1033
|
+
const dialogPromise = (defaultValue, request, parse, opts) => {
|
|
1034
|
+
if (opts?.signal?.aborted)
|
|
1035
|
+
return Promise.resolve(defaultValue);
|
|
1036
|
+
const controller = deps.controller();
|
|
1037
|
+
// C2 (Wave-0, KEEP): no controller at raise time → noOp, resolved at once. No
|
|
1038
|
+
// timer, no wait, no deadlock. This is the genuine zero-controller path.
|
|
1039
|
+
if (controller === null)
|
|
1040
|
+
return Promise.resolve(defaultValue);
|
|
1041
|
+
// A controller is attached: forward the dialog, register it (so a re-routed /
|
|
1042
|
+
// re-attaching controller can answer it — T4), and ALWAYS arm a broker-side
|
|
1043
|
+
// timeout (T4/C2 anti-deadlock): a controller that never answers — or detaches
|
|
1044
|
+
// and is never replaced — can never hang the turn. Honor a shorter per-dialog
|
|
1045
|
+
// timeout if the extension passed one; otherwise the broker default. On fire
|
|
1046
|
+
// it resolves to the SAFE default (deny/cancel/undefined). NOTE: controller
|
|
1047
|
+
// detach does NOT cancel this (M2) — only an answer, the timeout, or an abort.
|
|
1048
|
+
return new Promise((resolve) => {
|
|
1049
|
+
let timer;
|
|
1050
|
+
const cleanup = () => {
|
|
1051
|
+
if (timer !== undefined)
|
|
1052
|
+
clearTimeout(timer);
|
|
1053
|
+
opts?.signal?.removeEventListener('abort', onAbort);
|
|
1054
|
+
deps.pending.delete(request.id);
|
|
1055
|
+
};
|
|
1056
|
+
const onAbort = () => {
|
|
1057
|
+
cleanup();
|
|
1058
|
+
resolve(defaultValue);
|
|
1059
|
+
};
|
|
1060
|
+
opts?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
1061
|
+
// N1: honor the extension's EXPLICIT per-dialog timeout if it passed one
|
|
1062
|
+
// (longer or shorter than the broker default is fine — both are bounded);
|
|
1063
|
+
// otherwise fall back to the broker default. The broker never RELIES on the
|
|
1064
|
+
// extension having passed one (C2).
|
|
1065
|
+
const ms = opts?.timeout !== undefined ? opts.timeout : DEFAULT_DIALOG_TIMEOUT_MS;
|
|
1066
|
+
timer = setTimeout(() => {
|
|
1067
|
+
cleanup();
|
|
1068
|
+
resolve(defaultValue);
|
|
1069
|
+
}, ms);
|
|
1070
|
+
if (typeof timer.unref === 'function')
|
|
1071
|
+
timer.unref();
|
|
1072
|
+
deps.pending.set(request.id, {
|
|
1073
|
+
request,
|
|
1074
|
+
resolve: (r) => {
|
|
1075
|
+
cleanup();
|
|
1076
|
+
resolve(parse(r));
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
deps.forward(controller, request);
|
|
1080
|
+
});
|
|
1081
|
+
};
|
|
1082
|
+
const noop = () => { };
|
|
1083
|
+
const ctx = {
|
|
1084
|
+
// pi's ExtensionUIContext exposes `theme`; package extensions (e.g. the OAuth
|
|
1085
|
+
// billing adapter's status rendering) read it from ANY lifecycle hook. The
|
|
1086
|
+
// SDK's noOpUIContext returns the process-global theme proxy here; we mirror
|
|
1087
|
+
// that by reading the same global symbol (populated by initTheme() at broker
|
|
1088
|
+
// boot — see runBroker). Without this property `ctx.ui.theme` is undefined and
|
|
1089
|
+
// the hook throws, which the runner silently swallows while dropping the hook's
|
|
1090
|
+
// returned payload (the OAuth 400 root cause).
|
|
1091
|
+
get theme() {
|
|
1092
|
+
const t = globalThis[Symbol.for('@earendil-works/pi-coding-agent:theme')];
|
|
1093
|
+
if (t === undefined || t === null) {
|
|
1094
|
+
throw new Error('Theme not initialized. Call initTheme() first.');
|
|
1095
|
+
}
|
|
1096
|
+
return t;
|
|
1097
|
+
},
|
|
1098
|
+
select: (title, options, opts) => dialogPromise(undefined, { type: 'extension_ui_request', id: randomUUID(), method: 'select', title, options, timeout: opts?.timeout }, (r) => ('cancelled' in r && r.cancelled ? undefined : 'value' in r ? r.value : undefined), opts),
|
|
1099
|
+
confirm: (title, message, opts) => dialogPromise(false, { type: 'extension_ui_request', id: randomUUID(), method: 'confirm', title, message, timeout: opts?.timeout }, (r) => ('cancelled' in r && r.cancelled ? false : 'confirmed' in r ? r.confirmed : false), opts),
|
|
1100
|
+
input: (title, placeholder, opts) => dialogPromise(undefined, { type: 'extension_ui_request', id: randomUUID(), method: 'input', title, placeholder, timeout: opts?.timeout }, (r) => ('cancelled' in r && r.cancelled ? undefined : 'value' in r ? r.value : undefined), opts),
|
|
1101
|
+
editor: (title, prefill) => dialogPromise(undefined, { type: 'extension_ui_request', id: randomUUID(), method: 'editor', title, prefill }, (r) => ('cancelled' in r && r.cancelled ? undefined : 'value' in r ? r.value : undefined)),
|
|
1102
|
+
// Inert print-mode surface (never reached by a Model hook in Phase 3).
|
|
1103
|
+
notify: noop,
|
|
1104
|
+
onTerminalInput: () => noop,
|
|
1105
|
+
setStatus: noop,
|
|
1106
|
+
setWorkingMessage: noop,
|
|
1107
|
+
setWorkingVisible: noop,
|
|
1108
|
+
setWorkingIndicator: noop,
|
|
1109
|
+
setHiddenThinkingLabel: noop,
|
|
1110
|
+
setWidget: noop,
|
|
1111
|
+
setFooter: noop,
|
|
1112
|
+
setHeader: noop,
|
|
1113
|
+
setTitle: noop,
|
|
1114
|
+
custom: () => Promise.resolve(undefined),
|
|
1115
|
+
pasteToEditor: noop,
|
|
1116
|
+
setEditorText: noop,
|
|
1117
|
+
getEditorText: () => '',
|
|
1118
|
+
addAutocompleteProvider: noop,
|
|
1119
|
+
setEditorComponent: noop,
|
|
1120
|
+
getEditorComponent: () => undefined,
|
|
1121
|
+
getAllThemes: () => [],
|
|
1122
|
+
getTheme: () => undefined,
|
|
1123
|
+
setTheme: noop,
|
|
1124
|
+
getToolsExpanded: () => false,
|
|
1125
|
+
setToolsExpanded: noop,
|
|
1126
|
+
};
|
|
1127
|
+
return ctx;
|
|
1128
|
+
}
|