@staix/agent-hub 0.29.0 → 0.31.0
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/CHANGELOG.md +51 -0
- package/README.md +15 -7
- package/docs/agent-notes/README.md +13 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +10 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +10 -4
- package/docs/agent-notes/tests.md +8 -4
- package/docs/design/adapters.md +108 -0
- package/docs/design/agent-hub-architecture.md +58 -0
- package/docs/design/bus.md +147 -1
- package/docs/design/task-board.md +100 -0
- package/docs/dogfood.md +29 -8
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +410 -86
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +25 -5
- package/docs/sdlc.md +1 -1
- package/docs/security.md +182 -62
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4565 -3617
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +13 -13
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +82 -23
- package/src/adapters/pi.ts +140 -16
- package/src/adapters/registry-types.ts +16 -0
- package/src/adapters/registry.ts +4 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +82 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +10 -5
- package/src/cli/identity.ts +21 -4
- package/src/cli/main.ts +167 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +396 -20
- package/src/hub/bus.ts +497 -57
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +8 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1795 -142
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +66 -11
- package/src/hub/manager.ts +67 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +32 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +607 -71
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/pi/extension.ts +43 -3
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +271 -17
- package/src/ui/needs.ts +10 -4
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +219 -28
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/adapters/pi.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
6
|
-
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
7
|
-
import { BasePeer } from "../hub/peers.ts";
|
|
6
|
+
import { renderDigest, remoteEnvelope, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
7
|
+
import { BasePeer, type SessionTitleResult } from "../hub/peers.ts";
|
|
8
|
+
import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
|
|
8
9
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
9
10
|
import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
|
|
10
11
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
@@ -12,6 +13,7 @@ import { PiSessionBookmarkMismatch, validatePiSession } from "../pi/session.ts";
|
|
|
12
13
|
import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
|
|
13
14
|
import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
|
|
14
15
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
16
|
+
import { QuestionWaitDetached } from "../hub/question-detach.ts";
|
|
15
17
|
import type { StartForm } from "./registry-types.ts";
|
|
16
18
|
|
|
17
19
|
export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
|
|
@@ -41,6 +43,8 @@ export interface PiOptions {
|
|
|
41
43
|
model?: string;
|
|
42
44
|
relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
|
|
43
45
|
preamble?: string;
|
|
46
|
+
/** Host-owned conditional instruction, sampled once per delivered turn. */
|
|
47
|
+
titlePreface?: () => string | undefined;
|
|
44
48
|
selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
|
|
45
49
|
/** Atomic task/run admission immediately before provider requests or tool execution. */
|
|
46
50
|
admitBudget?: (envs: Envelope[], unit: ExecutionUnit) => Promise<ExecutionBudgetDecision[]>;
|
|
@@ -113,6 +117,66 @@ export class PiPeer extends BasePeer {
|
|
|
113
117
|
private currentReply?: Envelope;
|
|
114
118
|
private sessionId = "";
|
|
115
119
|
private sessionFile = "";
|
|
120
|
+
/** A read-only observation of Pi's native slot, never a second writable title store. */
|
|
121
|
+
private observedTitle: string | undefined;
|
|
122
|
+
private titleRevision = -1;
|
|
123
|
+
private titleEpoch = 0;
|
|
124
|
+
private nativeTitleEpoch: string | undefined;
|
|
125
|
+
get sessionTitle(): string | undefined { return this.acceptingTools && this.verifiedOwner() ? this.observedTitle : undefined; }
|
|
126
|
+
get titleSession(): string | undefined { return this.nativeSession; }
|
|
127
|
+
async setSessionTitle(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult> {
|
|
128
|
+
const invalid = (error: string, mayHaveApplied = false): SessionTitleResult => ({ ok: false, reason: "invalid", error, ...(mayHaveApplied ? { mayHaveApplied: true as const } : {}) });
|
|
129
|
+
if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean") return invalid("Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again.");
|
|
130
|
+
if (this.stopping || this.state === "offline") return invalid("Pi session is offline or stopped; start or reconnect Pi and check ahub status before naming it.");
|
|
131
|
+
const session = this.sessionId, generation = this.budgetGeneration, owner = this.sessionGeneration, epoch = this.titleEpoch;
|
|
132
|
+
if (!owner || !this.verifiedOwner()) return invalid("Pi native session owner is unverified; reconnect the retained Pi session and check ahub status before naming it.");
|
|
133
|
+
if (!this.acceptingTools) return invalid("Pi session is detached or running a personal turn; reconnect its current hub session and check ahub status before naming it.");
|
|
134
|
+
const valid = () => {
|
|
135
|
+
try { return this.acceptingTools && this.verifiedOwner() && this.sessionGeneration === owner && this.sessionId === session && this.budgetGeneration === generation && this.titleEpoch === epoch && current?.() !== false; }
|
|
136
|
+
catch { return false; }
|
|
137
|
+
};
|
|
138
|
+
if (!valid()) return invalid("Pi session, turn or title admission changed before dispatch; check ahub status before retrying in the current public session.");
|
|
139
|
+
let dispatched = false;
|
|
140
|
+
try {
|
|
141
|
+
const result = await this.sendTui({ type: "set_session_title", title, onlyIfUnnamed, sessionId: session, generation, ...(this.nativeTitleEpoch ? { titleEpoch: this.nativeTitleEpoch } : {}) }, valid, () => { dispatched = true; });
|
|
142
|
+
if (!valid()) return invalid("Pi session, turn or title admission changed after dispatch; the title may have been applied; check ahub status before retrying.", true);
|
|
143
|
+
if (result?.ok === false && result.reason === "invalid") {
|
|
144
|
+
if (result.cause === "session") return invalid("Pi native session changed before the title write; check ahub status and reconnect the current session before retrying.");
|
|
145
|
+
if (result.cause === "generation") return invalid("Pi native turn changed before the title write; wait for the current turn and check ahub status before retrying.");
|
|
146
|
+
if (result.cause === "arguments") return invalid("Pi rejected invalid title command arguments; use a 1-80 character normalized title and retry in the current session.");
|
|
147
|
+
if (result.cause === "native_unavailable") return invalid("Pi native title API is unavailable; restart Pi with the supported hub extension and check ahub status before retrying.");
|
|
148
|
+
if (result.cause === "native_failed") return invalid("Pi native title API failed without confirming the write; the title may have been applied; check ahub status before retrying.", true);
|
|
149
|
+
if (result.cause === "native_readback") return invalid("Pi native title readback did not confirm the write; the title may have been applied; check ahub status before retrying.", true);
|
|
150
|
+
}
|
|
151
|
+
if (result?.sessionId !== session || typeof result.sessionName !== "string" || this.nativeTitleEpoch !== undefined && result.titleEpoch !== this.nativeTitleEpoch) return invalid("Pi title acknowledgement did not confirm the current native slot; the title may have been applied; check ahub status before retrying.", true);
|
|
152
|
+
this.observeTitle(result.sessionName, result.titleRevision, result.titleEpoch);
|
|
153
|
+
if (result.ok === false && result.reason === "already_named") return { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title." };
|
|
154
|
+
if (result.ok === true && result.sessionName === title) return { ok: true };
|
|
155
|
+
return invalid("Pi native title write or readback was not confirmed; the title may have been applied; check ahub status before retrying.", true);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const timeout = error instanceof Error && error.message.includes("acknowledgement timed out");
|
|
158
|
+
if (dispatched) return invalid(`Pi title ${timeout ? "acknowledgement timed out" : "bridge command failed after dispatch"}; the title may have been applied; check ahub status before retrying.`, true);
|
|
159
|
+
return invalid(`Pi title ${timeout ? "command expired" : "admission changed or bridge failed"} before dispatch; check ahub status before retrying in the current session.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
private observeTitle(value: unknown, revision?: unknown, epoch?: unknown): void {
|
|
163
|
+
if (this.nativeTitleEpoch !== undefined && epoch !== this.nativeTitleEpoch) return;
|
|
164
|
+
if (typeof revision === "number" && Number.isSafeInteger(revision) && revision >= 0) {
|
|
165
|
+
if (revision < this.titleRevision) return;
|
|
166
|
+
this.titleRevision = revision;
|
|
167
|
+
}
|
|
168
|
+
if (value === undefined) value = ""; // A verified native session omits its name while unnamed.
|
|
169
|
+
if (typeof value !== "string") return;
|
|
170
|
+
if (value) this.noteActivity(); // Native session_info is history even before Pi creates its session file.
|
|
171
|
+
if (this.observedTitle !== value) { this.observedTitle = value; this.onDoing?.("title", value); }
|
|
172
|
+
}
|
|
173
|
+
private observeSnapshotTitle(snapshot: { sessionName?: unknown; titleRevision?: unknown; titleEpoch?: unknown }, before: number, beforeEpoch: number): boolean {
|
|
174
|
+
if (this.titleEpoch !== beforeEpoch || snapshot.titleEpoch !== undefined && snapshot.titleEpoch !== this.nativeTitleEpoch) return false;
|
|
175
|
+
if (snapshot.titleRevision === undefined && this.titleRevision !== before) return false;
|
|
176
|
+
if (typeof snapshot.titleRevision === "number" && snapshot.titleRevision < this.titleRevision) return false;
|
|
177
|
+
this.observeTitle(snapshot.sessionName, snapshot.titleRevision, snapshot.titleEpoch ?? this.nativeTitleEpoch);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
116
180
|
private executionBudgetTimer?: ReturnType<typeof setTimeout>;
|
|
117
181
|
private executionAbort?: AbortController;
|
|
118
182
|
private readonly budgetStops = new Map<number, string>();
|
|
@@ -164,7 +228,7 @@ export class PiPeer extends BasePeer {
|
|
|
164
228
|
private terminalOwnerObservation?: TerminalOwnerObservation;
|
|
165
229
|
private tuiExit?: Promise<void>;
|
|
166
230
|
private resolveTuiExit?: () => void;
|
|
167
|
-
private tuiCommands = new Map<string, { command: Record<string, unknown>; resolve: (value: any) => void; reject: (error: Error) => void }>();
|
|
231
|
+
private tuiCommands = new Map<string, { command: Record<string, unknown>; current?: () => boolean; onDispatch?: () => void; resolve: (value: any) => void; reject: (error: Error) => void }>();
|
|
168
232
|
private tuiQueue: Record<string, unknown>[] = [];
|
|
169
233
|
private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
|
|
170
234
|
|
|
@@ -219,14 +283,16 @@ export class PiPeer extends BasePeer {
|
|
|
219
283
|
this.emptyResumeVerified = false;
|
|
220
284
|
if (this.state !== "idle" || this.stopping || this.activeTools) throw new Error("Pi must settle before session capture");
|
|
221
285
|
if (this.sessionFile && existsSync(this.sessionFile)) return this.recoveryMetadata();
|
|
286
|
+
const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
|
|
222
287
|
const snapshot = this.opts.mode === "headless"
|
|
223
288
|
? (await this.waitRpc("get_state", 15_000)).data
|
|
224
289
|
: await this.sendTui({ type: "get_session_state" });
|
|
225
290
|
if (this.state !== "idle" || this.activeTools || snapshot?.sessionId !== this.sessionId || snapshot?.sessionFile !== this.sessionFile) throw new Error("Pi source session changed during capture");
|
|
291
|
+
if (!this.observeSnapshotTitle(snapshot, titleRevisionBefore, titleEpochBefore)) throw new Error("Pi source session title changed during capture; retry with a fresh native session snapshot");
|
|
226
292
|
const empty = this.opts.mode === "headless"
|
|
227
293
|
? snapshot.messageCount === 0 && snapshot.pendingMessageCount === 0 && snapshot.isStreaming === false && snapshot.isCompacting !== true && !snapshot.sessionName
|
|
228
294
|
: snapshot.empty === true && snapshot.idle === true;
|
|
229
|
-
if (!empty || this.activityObserved || this.persistedOnce) throw new Error("Pi session history is not persisted; refusing to recreate it");
|
|
295
|
+
if (!empty || this.activityObserved || this.persistedOnce) throw new Error("Pi session history is not persisted; refusing to recreate it. Exchange one message or start a new session, then retry");
|
|
230
296
|
this.emptyResumeVerified = true;
|
|
231
297
|
const captured = this.recoveryMetadata();
|
|
232
298
|
if (typeof captured.sessionId === "string" && !captured.sessionFile) this.verifiedEmptyResume = { sessionId: captured.sessionId };
|
|
@@ -265,6 +331,35 @@ export class PiPeer extends BasePeer {
|
|
|
265
331
|
this.starting = this.startImpl();
|
|
266
332
|
try { await this.starting; } finally { this.starting = undefined; }
|
|
267
333
|
}
|
|
334
|
+
/** The keepalive period of a long tool reply; well under every timer between the hub and Pi's runtime (60 s Bun idle, 300 s undici). A test shortens it. */
|
|
335
|
+
toolKeepaliveMs = 15_000;
|
|
336
|
+
/**
|
|
337
|
+
* A tool reply that may take minutes (`hub_ask`): the status line and headers go out at once, a space is written every `toolKeepaliveMs`, and the JSON body
|
|
338
|
+
* follows when the call settles. Whitespace before a JSON value is valid JSON, so the extension's `response.json()` reads it unchanged. When the client goes away
|
|
339
|
+
* the stream is cancelled and the call is aborted through its own signal with `QuestionWaitDetached`, so the wait ends `pending` and the row stays open for the late envelope;
|
|
340
|
+
* nothing else about `handleToolRequest` changes.
|
|
341
|
+
*/
|
|
342
|
+
private streamedToolResponse(body: any, clientGone: AbortSignal): Response {
|
|
343
|
+
const encoder = new TextEncoder();
|
|
344
|
+
let timer: ReturnType<typeof setInterval> | undefined, closed = false;
|
|
345
|
+
const stop = () => { closed = true; if (timer) clearInterval(timer); timer = undefined; };
|
|
346
|
+
return new Response(new ReadableStream({
|
|
347
|
+
start: controller => {
|
|
348
|
+
timer = setInterval(() => { if (!closed) try { controller.enqueue(encoder.encode(" ")); } catch { stop(); } }, this.toolKeepaliveMs);
|
|
349
|
+
timer.unref?.();
|
|
350
|
+
const abort = () => { stop(); this.pendingToolGenerations.get(String(body?.toolCallId ?? ""))?.abort.abort(new QuestionWaitDetached()); };
|
|
351
|
+
clientGone.addEventListener("abort", abort, { once: true });
|
|
352
|
+
void this.handleToolRequest(body).then(async response => {
|
|
353
|
+
const text = await response.text();
|
|
354
|
+
if (closed) return;
|
|
355
|
+
stop();
|
|
356
|
+
try { controller.enqueue(encoder.encode(text)); controller.close(); } catch { /* the client already left */ }
|
|
357
|
+
}, () => { if (closed) return; stop(); try { controller.enqueue(encoder.encode(JSON.stringify({ text: "error: Pi tool call failed", failed: true }))); controller.close(); } catch { /* the client already left */ } });
|
|
358
|
+
},
|
|
359
|
+
cancel: () => { stop(); this.pendingToolGenerations.get(String(body?.toolCallId ?? ""))?.abort.abort(new QuestionWaitDetached()); },
|
|
360
|
+
}), { headers: { "content-type": "application/json" } });
|
|
361
|
+
}
|
|
362
|
+
|
|
268
363
|
private async handleToolRequest(body: any): Promise<Response> {
|
|
269
364
|
if (!this.acceptingTools || this.state === "offline") return Response.json({ text: this.rosterDetached ? "error: Pi is off roster" : this.rosterUnownedRun ? "error: Pi native turn is pending" : "error: Pi owner is stopped" }, { status: 409 });
|
|
270
365
|
if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
|
|
@@ -388,6 +483,7 @@ export class PiPeer extends BasePeer {
|
|
|
388
483
|
if (this.preflight) await this.preflight();
|
|
389
484
|
this.terminalOwnerObservation = undefined;
|
|
390
485
|
this.stopping = false;
|
|
486
|
+
this.observedTitle = undefined; this.titleRevision = -1; this.nativeTitleEpoch = undefined; this.onDoing?.("reset");
|
|
391
487
|
this.rosterDetached = false; this.rosterUnownedRun = false;
|
|
392
488
|
this.requestedStopReason = undefined;
|
|
393
489
|
this.approvalTurnAbort = false;
|
|
@@ -429,7 +525,18 @@ export class PiPeer extends BasePeer {
|
|
|
429
525
|
: boundRefusal && refusal.kind === "prompt_uncertain" ? new PiPromptUncertain("Pi native prompt insertion is uncertain; inspect its retained terminal before retrying") : new Error(String(body.error ?? "Pi TUI command failed")));
|
|
430
526
|
return Response.json({ ok: true });
|
|
431
527
|
}
|
|
432
|
-
if (url.pathname === "/tool")
|
|
528
|
+
if (url.pathname === "/tool") {
|
|
529
|
+
// #1053: `hub_ask` holds this request open for the person's wait window. Two timers would cut it: Bun's 60 s bridge idle timeout (lifted for this one call with
|
|
530
|
+
// `timeout(request, 0)`) and the extension's own fetch, whose undici dispatcher Pi installs with headersTimeout and bodyTimeout of 300 s (Pi 1.4.0
|
|
531
|
+
// `configureHttpDispatcher`, measured: UND_ERR_HEADERS_TIMEOUT). So the reply is streamed: headers go out at once and a space every 15 s keeps both timers
|
|
532
|
+
// quiet, then the JSON follows (`response.json()` ignores leading whitespace). A client that gives up anyway cancels the stream, which aborts the call; the
|
|
533
|
+
// question row stays open and its answer takes the late-envelope path.
|
|
534
|
+
if (body?.name === "hub_ask") {
|
|
535
|
+
bridge.timeout(request, 0);
|
|
536
|
+
return this.streamedToolResponse(body, request.signal);
|
|
537
|
+
}
|
|
538
|
+
return this.handleToolRequest(body);
|
|
539
|
+
}
|
|
433
540
|
if (url.pathname === "/budget") return this.handleBudgetRequest(body);
|
|
434
541
|
return new Response("not found", { status: 404 });
|
|
435
542
|
} });
|
|
@@ -456,6 +563,7 @@ export class PiPeer extends BasePeer {
|
|
|
456
563
|
this.fail(new Error(`pi exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
|
|
457
564
|
this.reportExit(exit);
|
|
458
565
|
});
|
|
566
|
+
const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
|
|
459
567
|
const state = await this.waitRpc("get_state", 15_000);
|
|
460
568
|
if (state.success !== true || typeof state.data?.sessionId !== "string" || typeof state.data?.sessionFile !== "string" || !state.data.sessionId || !state.data.sessionFile) throw new Error("Pi get_state did not prove session identity");
|
|
461
569
|
if ((this.opts.sessionId && state.data.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && state.data.sessionFile !== this.opts.sessionFile)) {
|
|
@@ -472,6 +580,7 @@ export class PiPeer extends BasePeer {
|
|
|
472
580
|
}
|
|
473
581
|
this.persistedOnce = existsSync(this.sessionFile);
|
|
474
582
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) throw new Error("Pi startup session identity mismatch");
|
|
583
|
+
this.observeSnapshotTitle(state.data, titleRevisionBefore, titleEpochBefore);
|
|
475
584
|
this.started = true;
|
|
476
585
|
this.setState("idle");
|
|
477
586
|
}
|
|
@@ -542,9 +651,11 @@ export class PiPeer extends BasePeer {
|
|
|
542
651
|
if (!this.fencedPrompt) throw new Error("Pi roster return needs a fenced-prompt extension; restart the retained terminal with the current hub extension");
|
|
543
652
|
if (this.pendingPromptId) throw new Error("Pi prompt admission is still pending; wait before reverifying the retained terminal");
|
|
544
653
|
const epoch = this.rosterEpoch, lifecycle = this.rosterLifecycleRevision, session = this.sessionId, file = this.sessionFile;
|
|
654
|
+
const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
|
|
545
655
|
const snapshot = await this.sendTui({ type: "get_session_state" });
|
|
546
656
|
if (epoch !== this.rosterEpoch || this.stopping || !this.verifiedOwner() || this.activeTools || snapshot?.sessionId !== session || snapshot?.sessionFile !== file || typeof snapshot?.idle !== "boolean") throw new Error("Pi roster return could not verify the current native session");
|
|
547
657
|
if (lifecycle !== this.rosterLifecycleRevision) throw new Error("Pi native activity changed during roster return; retry with a fresh session snapshot");
|
|
658
|
+
this.observeSnapshotTitle(snapshot, titleRevisionBefore, titleEpochBefore);
|
|
548
659
|
this.rosterDetached = false;
|
|
549
660
|
this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
|
|
550
661
|
this.rosterUnownedRun = !snapshot.idle; this.agentRunning = !snapshot.idle;
|
|
@@ -552,12 +663,12 @@ export class PiPeer extends BasePeer {
|
|
|
552
663
|
if (!snapshot.idle) this.clearWatchdog();
|
|
553
664
|
}
|
|
554
665
|
|
|
555
|
-
async stop(reason?: string): Promise<void> {
|
|
666
|
+
async stop(reason?: string, failureCause?: DeliveryFailureCause): Promise<void> {
|
|
556
667
|
if (reason !== undefined) this.requestedStopReason = reason;
|
|
557
668
|
this.rosterEpoch++;
|
|
558
669
|
this.terminalOwnerObservation = undefined;
|
|
559
670
|
this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
|
|
560
|
-
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
|
|
671
|
+
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement", ...(failureCause ? { failureCause } : {}) });
|
|
561
672
|
this.activeDeliveryIds.clear();
|
|
562
673
|
const settling = this.abortTools();
|
|
563
674
|
let unsettled: unknown;
|
|
@@ -650,8 +761,9 @@ export class PiPeer extends BasePeer {
|
|
|
650
761
|
this.noteActivity();
|
|
651
762
|
attempted = true;
|
|
652
763
|
if (deliveryId) this.activeDeliveryIds.add(deliveryId);
|
|
653
|
-
|
|
654
|
-
|
|
764
|
+
const message = [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n");
|
|
765
|
+
if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message }); promptId = this.pendingPromptId; await pending; }
|
|
766
|
+
else await this.sendRpc({ type: "prompt", message });
|
|
655
767
|
if (promptId !== undefined && promptId !== this.pendingPromptId) return;
|
|
656
768
|
if (this.fencedPrompt && this.rosterDetached) {
|
|
657
769
|
if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi native activity changed before prompt acceptance was confirmed" });
|
|
@@ -780,10 +892,15 @@ export class PiPeer extends BasePeer {
|
|
|
780
892
|
this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
|
|
781
893
|
this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
|
|
782
894
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
|
|
895
|
+
this.titleRevision = -1; this.titleEpoch++;
|
|
896
|
+
this.nativeTitleEpoch = typeof event.titleEpoch === "string" && event.titleEpoch ? event.titleEpoch : undefined;
|
|
897
|
+
this.observeTitle(event.sessionName, event.titleRevision, event.titleEpoch);
|
|
783
898
|
this.rememberTerminalOwner();
|
|
784
899
|
this.startOwnerMonitor(); if (this.opts.mode === "tui") { this.started = true; this.setState("idle"); }
|
|
785
900
|
}
|
|
901
|
+
if ((event.type === "session_title" || event.type === "agent_start" && Object.hasOwn(event, "sessionName")) && event.sessionId === this.sessionId && this.verifiedOwner()) this.observeTitle(event.sessionName, event.titleRevision, event.titleEpoch);
|
|
786
902
|
if (event.type === "session_shutdown") {
|
|
903
|
+
this.observedTitle = undefined; this.titleRevision = -1; this.nativeTitleEpoch = undefined; this.onDoing?.("reset");
|
|
787
904
|
const exit = this.exitMetadata("session_shutdown");
|
|
788
905
|
this.shutdownExit ??= exit; // headless waits for the OS exit, retaining the pre-cleanup turn and stop facts
|
|
789
906
|
this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
@@ -872,10 +989,17 @@ export class PiPeer extends BasePeer {
|
|
|
872
989
|
while ((index = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, index).replace(/\r$/, ""); this.buffer = this.buffer.slice(index + 1); if (!line) continue; try { this.handleRpc(JSON.parse(line) as RpcMessage); } catch (error) { this.opts.log?.(`[${this.id}] invalid Pi RPC output: ${(error as Error).message}`); } }
|
|
873
990
|
}
|
|
874
991
|
private nextTuiCommand(timeout: number): Promise<Record<string, unknown> | undefined> {
|
|
875
|
-
|
|
992
|
+
let next = this.tuiQueue.shift();
|
|
993
|
+
while (next) {
|
|
994
|
+
const pending = this.tuiCommands.get(String(next.id));
|
|
995
|
+
if (pending && pending.current?.() !== false) { pending.onDispatch?.(); return Promise.resolve(next); }
|
|
996
|
+
if (pending) { this.tuiCommands.delete(String(next.id)); pending.reject(new Error("Pi session title admission changed before dispatch")); }
|
|
997
|
+
next = this.tuiQueue.shift();
|
|
998
|
+
}
|
|
876
999
|
return new Promise((resolvePromise) => { const entry = { resolve: resolvePromise, timer: setTimeout(() => { const i = this.tuiWaiters.indexOf(entry); if (i >= 0) this.tuiWaiters.splice(i, 1); resolvePromise(undefined); }, timeout) }; this.tuiWaiters.push(entry); });
|
|
877
1000
|
}
|
|
878
|
-
private sendTui(command: Record<string, unknown
|
|
1001
|
+
private sendTui(command: Record<string, unknown>, current?: () => boolean, onDispatch?: () => void): Promise<any> {
|
|
1002
|
+
if (current?.() === false) return Promise.reject(new Error("Pi session title admission changed before dispatch"));
|
|
879
1003
|
const id = `ahub-${++this.seq}`;
|
|
880
1004
|
const fenced = this.fencedPrompt && command.type === "prompt";
|
|
881
1005
|
if (fenced) { this.pendingPromptId = id; this.hubPromptId = id; this.hubPromptGeneration = undefined; }
|
|
@@ -886,9 +1010,9 @@ export class PiPeer extends BasePeer {
|
|
|
886
1010
|
this.tuiQueue = this.tuiQueue.filter((queued) => queued.id !== id);
|
|
887
1011
|
reject(new Error(`Pi TUI ${String(command.type)} acknowledgement timed out`));
|
|
888
1012
|
}, 30_000);
|
|
889
|
-
this.tuiCommands.set(id, { command: wire, resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
|
|
1013
|
+
this.tuiCommands.set(id, { command: wire, current, onDispatch, resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
|
|
890
1014
|
const waiter = this.tuiWaiters.shift();
|
|
891
|
-
if (waiter) { clearTimeout(waiter.timer); waiter.resolve(wire); } else this.tuiQueue.push(wire);
|
|
1015
|
+
if (waiter) { clearTimeout(waiter.timer); onDispatch?.(); waiter.resolve(wire); } else this.tuiQueue.push(wire);
|
|
892
1016
|
});
|
|
893
1017
|
}
|
|
894
1018
|
private handleRpc(message: RpcMessage): void { if (this.acceptingTools && ["message_update", "tool_execution_update"].includes(message.type ?? "") && this.state === "busy") this.touch(); if (message.type === "agent_settled") return; // The authenticated extension is the single lifecycle source.
|
|
@@ -965,11 +1089,11 @@ export class PiPeer extends BasePeer {
|
|
|
965
1089
|
await this.terminateFailedTurn(new ApprovalWaitStop(reason));
|
|
966
1090
|
}
|
|
967
1091
|
|
|
968
|
-
private async terminateFailedTurn(error: Error): Promise<void> {
|
|
1092
|
+
private async terminateFailedTurn(error: Error, failureCause?: DeliveryFailureCause): Promise<void> {
|
|
969
1093
|
const envs = this.activeEnvs.slice(), reply = this.currentReply;
|
|
970
1094
|
this.stopping = true;
|
|
971
1095
|
try {
|
|
972
|
-
await this.stop();
|
|
1096
|
+
await this.stop(undefined, failureCause);
|
|
973
1097
|
// The process exit handler may already have reported this failure.
|
|
974
1098
|
if (this.activeEnvs.length) this.fail(error);
|
|
975
1099
|
} catch (stopError) {
|
|
@@ -987,7 +1111,7 @@ export class PiPeer extends BasePeer {
|
|
|
987
1111
|
protected override onWatchdog(): void {
|
|
988
1112
|
if (this.stopping || this.rosterDetached || this.rosterUnownedRun) return;
|
|
989
1113
|
if (this.activeTools) { this.touch(); return; }
|
|
990
|
-
void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
|
|
1114
|
+
void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"), "timeout");
|
|
991
1115
|
}
|
|
992
1116
|
}
|
|
993
1117
|
|
|
@@ -81,6 +81,10 @@ export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
|
|
|
81
81
|
daemon: Ports;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** `native-stop`: Claude's transcript-verified Stop. `turn-completed`: Codex's `turn/completed` with no active turn. `agent-settled`: Pi's own settled event. `route-outcome`: the local worker's per-decision outcome. `prompt-response`: an ACP `session/prompt` response. `message-stop`: Qwen's TUI `message_stop`. */
|
|
85
|
+
export type SettleSignal = "native-stop" | "turn-completed" | "agent-settled" | "route-outcome" | "prompt-response" | "message-stop";
|
|
86
|
+
export const SETTLE_SIGNALS: readonly SettleSignal[] = Object.freeze(["native-stop", "turn-completed", "agent-settled", "route-outcome", "prompt-response", "message-stop"]);
|
|
87
|
+
|
|
84
88
|
export interface StartRequest {
|
|
85
89
|
instance: string;
|
|
86
90
|
form?: StartForm;
|
|
@@ -204,6 +208,18 @@ export interface AdapterDefinition {
|
|
|
204
208
|
conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
|
|
205
209
|
};
|
|
206
210
|
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin"; currentChild?: true };
|
|
211
|
+
/** Who owns the session title; only hub-owned slots receive hub_set_title. */
|
|
212
|
+
title?: "native" | "hub";
|
|
213
|
+
/** Fixed builtins only: the title slot survives native-session restoration. Absent means ephemeral. */
|
|
214
|
+
titlePersistence?: "native";
|
|
215
|
+
/** #1053: how long a `hub_ask` call waits for the person before it answers `pending` (milliseconds). MCP peers 45 s, hub-native peers 300 s; config `questions.wait_ms.<kind>` overrides it. Fixed builtins only. */
|
|
216
|
+
questionWaitMs?: number;
|
|
217
|
+
/**
|
|
218
|
+
* #1054: the code-verified signal that ends a peer's logical run, declared per definition. ABSENT means the adapter creates no completion: nothing is guessed (owner
|
|
219
|
+
* decision 2026-11-04). Fixed builtins only (`assertNativeCapabilityScope`): a project or external definition cannot claim one. A definition with a pull-only form (Kimi's TUI)
|
|
220
|
+
* declares it for its headless form only, because the daemon starts no run for a peer that attached pull-only.
|
|
221
|
+
*/
|
|
222
|
+
settle?: { signal: SettleSignal };
|
|
207
223
|
/** External module data-only opt-in. The verified loader derives the internal instances policy from it (#754). */
|
|
208
224
|
headlessInstances?: { form: "headless"; roles: readonly ("implementer" | "verifier" | "reviewer")[] };
|
|
209
225
|
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
package/src/adapters/registry.ts
CHANGED
|
@@ -239,6 +239,10 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
239
239
|
if (isFixedBuiltinDefinition(definition)) return;
|
|
240
240
|
const declared = (block: object | undefined, key: string): boolean => block !== undefined && key in block;
|
|
241
241
|
const restricted: [string, boolean][] = [
|
|
242
|
+
["title", definition.title !== undefined],
|
|
243
|
+
["titlePersistence", definition.titlePersistence !== undefined],
|
|
244
|
+
["questionWaitMs", definition.questionWaitMs !== undefined],
|
|
245
|
+
["settle", definition.settle !== undefined],
|
|
242
246
|
["start.acpCommand", declared(definition.start, "acpCommand")],
|
|
243
247
|
["attach.admission", declared(definition.attach, "admission")],
|
|
244
248
|
["budget.resetSoftTokensOnResume", declared(definition.budget, "resetSoftTokensOnResume")],
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
|
-
import { OwnedPrintChild, PrintChildCleanupError } from "./claude-print-child.ts";
|
|
2
|
+
import { OwnedPrintChild, PrintChildCleanupError, type OwnedInputContext, type OwnedInputObserver } from "./claude-print-child.ts";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { HUB, frame, sanitize } from "../hub/envelope.ts";
|
|
5
5
|
import { BasePeer } from "../hub/peers.ts";
|
|
6
|
+
import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
|
|
6
7
|
import type { Envelope } from "../hub/envelope.ts";
|
|
7
8
|
import type { ReviewerMcpSession, ReviewerToolName } from "./reviewer-mcp.ts";
|
|
8
9
|
|
|
@@ -31,6 +32,7 @@ export class ReviewerUnavailableError extends Error {
|
|
|
31
32
|
class ReviewerAdmissionError extends Error {
|
|
32
33
|
constructor(readonly reason: "stale_binding" | "policy_changed") { super("review admission changed"); }
|
|
33
34
|
}
|
|
35
|
+
class ReviewerInputAdmissionError extends Error {}
|
|
34
36
|
|
|
35
37
|
export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
36
38
|
export interface ReviewerIsolationReceipt {
|
|
@@ -136,6 +138,13 @@ export interface ReviewerPrintOptions {
|
|
|
136
138
|
/** Synchronous host task/privacy/policy fence, checked again after awaited preparation. */
|
|
137
139
|
admit?(env: Envelope): boolean;
|
|
138
140
|
bindingCurrent?(task: number, binding: ReviewerReviewBinding): boolean;
|
|
141
|
+
/** Private-profile observer input provenance only; never a native resume or budget identity. */
|
|
142
|
+
beforeInput?: OwnedInputObserver;
|
|
143
|
+
inputReleased?: OwnedInputObserver;
|
|
144
|
+
/** Host privacy state for the currently attached reviewer input, read only for diagnostics. */
|
|
145
|
+
reviewObserverHeld?(): boolean;
|
|
146
|
+
/** Runs after this fresh child group, task MCP and launch cleanup are confirmed closed. */
|
|
147
|
+
inputFinished?: OwnedInputObserver;
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
type ReviewTarget = Omit<ReviewerFailure, "cause">;
|
|
@@ -166,9 +175,13 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
166
175
|
private batch: ReviewBatch | undefined;
|
|
167
176
|
private preparation: { abort: AbortController; done: Promise<ReviewerLaunch> } | undefined;
|
|
168
177
|
private receipt: ReviewerIsolationReceipt | undefined;
|
|
178
|
+
private observerSessionIdValue: string | undefined;
|
|
169
179
|
get isolationReceipt(): ReviewerIsolationReceipt | undefined { return this.receipt; }
|
|
180
|
+
reviewObserverInputHeld(): boolean { return this.opts.reviewObserverHeld?.() ?? false; }
|
|
170
181
|
/** The task whose review the running child was started for (R4: the hub records `fresh` for its verdict). */
|
|
171
182
|
reviewing: number | undefined;
|
|
183
|
+
/** Privacy-only identity for a fresh review profile; never part of resume or budget authority. */
|
|
184
|
+
get observerSessionId(): string | undefined { return this.observerSessionIdValue; }
|
|
172
185
|
private reviewSeq = 0;
|
|
173
186
|
private runningBinding: { task: number; binding: ReviewerReviewBinding } | undefined;
|
|
174
187
|
private activeTrace: { task: number; generation: number; value: ReviewerTraceState } | undefined;
|
|
@@ -234,16 +247,16 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
234
247
|
this.setState("offline");
|
|
235
248
|
}
|
|
236
249
|
|
|
237
|
-
protected override onWatchdog(): void { this.abortReview("review watchdog timeout"); }
|
|
250
|
+
protected override onWatchdog(): void { this.abortReview("review watchdog timeout", "timeout"); }
|
|
238
251
|
|
|
239
|
-
private abortReview(reason: "review watchdog timeout" | "review output limit exceeded" | "review policy changed"): void {
|
|
252
|
+
private abortReview(reason: "review watchdog timeout" | "review output limit exceeded" | "review policy changed", failureCause?: DeliveryFailureCause): void {
|
|
240
253
|
const generation = ++this.generation, batch = this.batch, preparation = this.preparation;
|
|
241
254
|
this.reviewing = undefined;
|
|
242
255
|
this.reviewMcpClose?.();
|
|
243
256
|
this.receipt = undefined;
|
|
244
257
|
this.clearWatchdog();
|
|
245
258
|
preparation?.abort.abort();
|
|
246
|
-
this.settle("needs_review", reason);
|
|
259
|
+
this.settle("needs_review", reason, failureCause);
|
|
247
260
|
// Revoke first, then confirm both preparation and the model's whole group are gone before fallback.
|
|
248
261
|
void (async () => {
|
|
249
262
|
if (preparation) await preparation.done.catch(() => {});
|
|
@@ -288,6 +301,11 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
288
301
|
try { result = await this.review(env, task, generation); }
|
|
289
302
|
catch (error) {
|
|
290
303
|
if (generation !== this.generation) return;
|
|
304
|
+
if (error instanceof ReviewerInputAdmissionError) {
|
|
305
|
+
failure = { state: started ? "needs_review" : "failed_safe", reason: "review input provenance unavailable" };
|
|
306
|
+
batch.next = envs.length;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
291
309
|
if (error instanceof ReviewerAdmissionError) {
|
|
292
310
|
this.opts.log?.(`[${this.id}] review of task #${task} skipped: ${error.reason === "stale_binding" ? "stale binding" : "review policy changed"}`);
|
|
293
311
|
if (error.reason === "policy_changed") {
|
|
@@ -395,18 +413,30 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
395
413
|
let facts: string | undefined;
|
|
396
414
|
try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
|
|
397
415
|
const input = `${frame(env)}${facts ? `\n\n${facts}` : ""}`;
|
|
416
|
+
const sessionId = randomUUID();
|
|
417
|
+
const inputContext: OwnedInputContext = Object.freeze({ sessionId, inputId: randomUUID() });
|
|
418
|
+
this.observerSessionIdValue = sessionId;
|
|
419
|
+
try { this.opts.beforeInput?.(inputContext, [env]); }
|
|
420
|
+
catch { this.observerSessionIdValue = undefined; throw new ReviewerInputAdmissionError(); }
|
|
398
421
|
let launch: ReviewerLaunch | undefined;
|
|
399
422
|
const trace: ReviewerTraceState = { reviewCalled: false };
|
|
400
423
|
this.activeTrace = { task, generation, value: trace };
|
|
401
424
|
const mcp = this.opts.backend.taskMcp ? this.opts.taskMcp?.(task, () => generation === this.generation && this.reviewToken(task) !== undefined && this.opts.bindingCurrent?.(task,binding) !== false) : undefined;
|
|
402
|
-
let mcpClosed=false;
|
|
403
|
-
const closeMcp
|
|
425
|
+
let mcpClosed = false, mcpCloseConfirmed = false;
|
|
426
|
+
const closeMcp = () => {
|
|
427
|
+
if (mcpClosed) return;
|
|
428
|
+
mcpClosed = true;
|
|
429
|
+
try { mcp?.close(); mcpCloseConfirmed = true; }
|
|
430
|
+
catch { this.opts.log?.(`[${this.id}] reviewer task MCP cleanup incomplete`); }
|
|
431
|
+
if (this.reviewMcpClose === closeMcp) this.reviewMcpClose = undefined;
|
|
432
|
+
};
|
|
404
433
|
this.reviewMcpClose=closeMcp;
|
|
405
434
|
let ownedChild: OwnedPrintChild | undefined, releasing: Promise<void> | undefined;
|
|
435
|
+
let inputReleased = false, childClosed = false, launchCleanupConfirmed = false;
|
|
406
436
|
let policyTimer: ReturnType<typeof setInterval> | undefined;
|
|
407
437
|
const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
|
|
408
438
|
try {
|
|
409
|
-
launch = await this.prepare(
|
|
439
|
+
launch = await this.prepare(sessionId, input, task, mcp?.config);
|
|
410
440
|
if (generation !== this.generation) throw new ReviewerAdmissionError("stale_binding");
|
|
411
441
|
this.assertAdmission(env, task, binding);
|
|
412
442
|
if (launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation changed before child spawn", "binary");
|
|
@@ -415,6 +445,7 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
415
445
|
const child = new OwnedPrintChild({ peer: this.id, command: launch.command, args: launch.args,
|
|
416
446
|
cwd: launch.cwd ?? this.opts.cwd, env: launch.env, envMode: launch.envMode, input,
|
|
417
447
|
current: () => generation === this.generation, stop: launch.stop,
|
|
448
|
+
onInputReleased: () => { inputReleased = true; this.opts.inputReleased?.(inputContext, [env]); },
|
|
418
449
|
onError: () => { if (this.child === child) this.child = undefined; },
|
|
419
450
|
onSpawn: () => {
|
|
420
451
|
this.runningBinding={task,binding};
|
|
@@ -441,21 +472,29 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
441
472
|
this.childCleanup = release;
|
|
442
473
|
this.childCurrent = launch.watchCurrent ? launch.current : undefined;
|
|
443
474
|
const code = await child.done;
|
|
475
|
+
childClosed = true;
|
|
444
476
|
if (this.child === child) this.child = undefined;
|
|
445
477
|
return { code, quota, trace };
|
|
446
478
|
} finally {
|
|
447
479
|
if (this.activeTrace?.value === trace) this.activeTrace = undefined;
|
|
448
480
|
closeMcp();
|
|
449
481
|
if (policyTimer) clearInterval(policyTimer);
|
|
450
|
-
try {
|
|
451
|
-
|
|
482
|
+
try {
|
|
483
|
+
if (!ownedChild || this.child !== ownedChild) {
|
|
484
|
+
await release(); if (this.childCleanup === release) this.childCleanup = undefined;
|
|
485
|
+
launchCleanupConfirmed = true;
|
|
486
|
+
}
|
|
487
|
+
} catch { this.opts.log?.(`[${this.id}] reviewer isolation cleanup incomplete`); }
|
|
488
|
+
const closedWithoutChild = !ownedChild || ownedChild.process.pid === undefined;
|
|
489
|
+
if (mcpCloseConfirmed && launchCleanupConfirmed && (closedWithoutChild || inputReleased && childClosed))
|
|
490
|
+
try { this.opts.inputFinished?.(inputContext, [env]); } catch { /* receipt cleanup never changes the review verdict */ }
|
|
452
491
|
}
|
|
453
492
|
}
|
|
454
493
|
|
|
455
|
-
private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
|
|
494
|
+
private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string, failureCause?: DeliveryFailureCause): void {
|
|
456
495
|
const id = this.activeDeliveryId;
|
|
457
496
|
this.activeDeliveryId = undefined;
|
|
458
|
-
if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
|
|
497
|
+
if (id) this.delivery({ id, state, ...(reason ? { reason } : {}), ...(failureCause ? { failureCause } : {}) });
|
|
459
498
|
}
|
|
460
499
|
|
|
461
500
|
/** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
|
package/src/broker/server.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface BrokerOptions {
|
|
|
25
25
|
interface Client {
|
|
26
26
|
ws?: ServerWebSocket<Client>;
|
|
27
27
|
room: string;
|
|
28
|
+
ephemeral: boolean;
|
|
28
29
|
challenge: BrokerChallenge;
|
|
29
30
|
phase: "challenge" | "authenticating" | "roster" | "ready" | "closed";
|
|
30
31
|
busy: boolean;
|
|
@@ -35,6 +36,9 @@ interface Client {
|
|
|
35
36
|
rosterDigest?: string;
|
|
36
37
|
rosterDeadline?: number;
|
|
37
38
|
inFlight?: string;
|
|
39
|
+
inFlightEphemeral?: boolean;
|
|
40
|
+
inFlightExpiresAt?: number;
|
|
41
|
+
inFlightEpoch?: string;
|
|
38
42
|
}
|
|
39
43
|
export interface BrokerRuntime {
|
|
40
44
|
readonly port: number;
|
|
@@ -104,8 +108,12 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
104
108
|
function close(client: Client, code: string): void {
|
|
105
109
|
if (client.phase === "closed") return;
|
|
106
110
|
client.phase = "closed";
|
|
111
|
+
client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined;
|
|
107
112
|
queuedBytes -= client.inboxBytes; client.inboxBytes = 0; client.inbox.length = 0;
|
|
108
|
-
if (client.member && members.get(memberKey(client)) === client)
|
|
113
|
+
if (client.member && members.get(memberKey(client)) === client) {
|
|
114
|
+
members.delete(memberKey(client));
|
|
115
|
+
try { store.disconnect(client.room, client.member); } catch { emit("refused", client, "state_unavailable"); }
|
|
116
|
+
}
|
|
109
117
|
if (client.ws) { try { client.ws.close(4403, code); } catch { /* already closed */ } }
|
|
110
118
|
else clients.delete(client);
|
|
111
119
|
}
|
|
@@ -117,9 +125,9 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
117
125
|
close(client, "backpressure"); return false;
|
|
118
126
|
}
|
|
119
127
|
const control = (client: Client, value: unknown) => send(client, canonicalBytes(value));
|
|
120
|
-
function refused(client: Client, code: string, terminal = true): void {
|
|
128
|
+
function refused(client: Client, code: string, terminal = true, digest?: string): void {
|
|
121
129
|
emit("refused", client, code);
|
|
122
|
-
control(client, { v: 1, type: "error", code });
|
|
130
|
+
control(client, { v: 1, type: "error", code, ...(code === "queue_full" && digest ? { digest } : {}) });
|
|
123
131
|
if (terminal) close(client, code);
|
|
124
132
|
}
|
|
125
133
|
function active(client: Client): VerifiedPublicRoster {
|
|
@@ -133,16 +141,29 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
133
141
|
const roster = active(client);
|
|
134
142
|
client.phase = "roster"; client.rosterDigest = roster.digest;
|
|
135
143
|
client.rosterDeadline = now() + BROKER_SOCKET_LIMITS.rosterAckMs;
|
|
136
|
-
|
|
144
|
+
// Legacy hubs have a closed ready grammar; advertise only after HTTP opt-in.
|
|
145
|
+
if (send(client, publicRosterWire(roster))) control(client, { v: 1, type: "ready", room: client.room, member: client.member, epoch: roster.epoch, digest: roster.digest, ...(client.ephemeral ? { ephemeral: true } : {}) });
|
|
137
146
|
}
|
|
138
147
|
function pump(client: Client): void {
|
|
139
148
|
if (stopping || client.phase === "closed" || !client.member) return;
|
|
140
|
-
active(client);
|
|
149
|
+
const roster = active(client);
|
|
141
150
|
const next = store.next(client.room, client.member);
|
|
142
|
-
if (client.
|
|
143
|
-
|
|
151
|
+
if (client.inFlightEphemeral && client.inFlightEpoch !== roster.epoch) {
|
|
152
|
+
const previous = store.current(client.room)?.previous;
|
|
153
|
+
client.inFlightExpiresAt = previous && previous.roster.epoch === client.inFlightEpoch
|
|
154
|
+
? Math.min(client.inFlightExpiresAt ?? 0, previous.expiresAt) : 0;
|
|
155
|
+
}
|
|
156
|
+
// Replacement retires an ephemeral queue row, not the packet already handed
|
|
157
|
+
// to this socket. Keep its slot until ACK or its original TTL/grace deadline.
|
|
158
|
+
if (client.inFlight && (client.inFlightEphemeral && now() < (client.inFlightExpiresAt ?? 0) || next?.digest === client.inFlight)) return;
|
|
159
|
+
client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined; // durable expiry/purge still releases its slot.
|
|
144
160
|
if (client.phase !== "ready" || !next) return;
|
|
145
|
-
if (send(client, next.wire))
|
|
161
|
+
if (send(client, next.wire)) {
|
|
162
|
+
client.inFlight = next.digest;
|
|
163
|
+
const frame = decodeFrame(next.wire);
|
|
164
|
+
client.inFlightEphemeral = frame.ephemeral === true;
|
|
165
|
+
client.inFlightExpiresAt = next.expiresAt; client.inFlightEpoch = frame.epoch;
|
|
166
|
+
}
|
|
146
167
|
}
|
|
147
168
|
function safePump(client: Client): void {
|
|
148
169
|
try { pump(client); } catch (error) { refused(client, errorCode(error)); }
|
|
@@ -195,9 +216,19 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
195
216
|
const selected = state && (header.epoch === state.current.epoch ? state.current : state.previous?.roster);
|
|
196
217
|
if (!selected) throw new BrokerServerError("wrong_epoch");
|
|
197
218
|
const proof = await (ports.verifyFrame ?? verifyPublicFrame)({ wire: bytes, roster: selected });
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
219
|
+
const onlineRecipients = proof.recipients.filter(recipient => {
|
|
220
|
+
const target = members.get(`${client.room}|${recipient}`);
|
|
221
|
+
return target?.phase === "ready" && (!proof.ephemeral || target.ephemeral);
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
active(client);
|
|
225
|
+
const result = store.enqueue(proof, client.member!, onlineRecipients); // latest membership/grace/caps checked transactionally.
|
|
226
|
+
control(client, { v: 1, type: "stored", digest: result.digest, seq: proof.seq, duplicate: result.duplicate });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (errorCode(error) !== "queue_full") throw error;
|
|
229
|
+
refused(client, "queue_full", false, client.ephemeral ? proof.digest : undefined);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
201
232
|
for (const recipient of proof.recipients) {
|
|
202
233
|
const target = members.get(`${client.room}|${recipient}`);
|
|
203
234
|
if (target) safePump(target);
|
|
@@ -215,7 +246,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
215
246
|
if (packet.digest !== client.inFlight) { refused(client, "unexpected_ack", false); return; }
|
|
216
247
|
active(client);
|
|
217
248
|
store.ack(client.room, client.member!, packet.digest);
|
|
218
|
-
client.inFlight = undefined;
|
|
249
|
+
client.inFlight = undefined; client.inFlightEphemeral = undefined; client.inFlightExpiresAt = undefined; client.inFlightEpoch = undefined;
|
|
219
250
|
control(client, { v: 1, type: "queue_ack", digest: packet.digest });
|
|
220
251
|
safePump(client);
|
|
221
252
|
}
|
|
@@ -259,7 +290,7 @@ export async function startBroker(options: BrokerOptions, ports: BrokerPorts = {
|
|
|
259
290
|
const room = roomId(url.pathname.slice("/v1/rooms/".length));
|
|
260
291
|
if (clients.size >= BROKER_SOCKET_LIMITS.connections) return new Response("busy", { status: 503 });
|
|
261
292
|
const current = store.current(room)?.current;
|
|
262
|
-
const client: Client = { room, challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
|
|
293
|
+
const client: Client = { room, ephemeral: req.headers.get("x-agent-hub-ephemeral") === "1", challenge: { v: 1, type: "challenge", room, broker: publicUrl, nonce: encodeBase64url(randomBytes(32)), expiresAt: now() + BROKER_SOCKET_LIMITS.authMs, rosterDigest: current?.digest ?? null }, phase: "challenge", busy: false, inbox: [], inboxBytes: 0 };
|
|
263
294
|
clients.add(client);
|
|
264
295
|
if (host.upgrade(req, { data: client })) return;
|
|
265
296
|
clients.delete(client); return new Response("websocket required", { status: 400 });
|