@staix/agent-hub 0.12.20 → 0.12.22
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 +32 -0
- package/README.md +17 -9
- package/docs/agent-notes/adapters.md +13 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +7 -3
- package/docs/agent-notes/daemon.md +12 -2
- package/docs/agent-notes/local-worker.md +5 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +5 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +15 -5
- package/docs/operations.md +510 -54
- package/docs/quickstart.md +16 -11
- package/docs/research.md +92 -0
- package/docs/security.md +49 -11
- package/docs/smoke.md +138 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +77 -10
- package/src/adapters/codex-appserver.ts +72 -2
- package/src/adapters/local-worker.ts +46 -11
- package/src/adapters/pi.ts +215 -22
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +16 -5
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +272 -18
- package/src/cli/main.ts +290 -30
- package/src/cli/preview.ts +13 -3
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +123 -20
- package/src/cli/upgrade.ts +108 -19
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +24 -7
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +477 -86
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +4 -2
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -2
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +59 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +99 -55
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +34 -11
- package/src/pi/extension.ts +43 -20
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +24 -2
- package/src/ui/index.html +98 -17
- package/src/ui/task-progress.ts +33 -0
package/src/adapters/pi.ts
CHANGED
|
@@ -8,17 +8,30 @@ import { BasePeer } from "../hub/peers.ts";
|
|
|
8
8
|
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
9
9
|
import { processSignature } from "../pi/process-signature.ts";
|
|
10
10
|
import { realPath } from "../hub/project.ts";
|
|
11
|
-
import { toolResultFailed } from "../local/tools.ts";
|
|
11
|
+
import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
|
|
12
12
|
import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
|
|
13
13
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
14
14
|
|
|
15
15
|
export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
|
|
16
16
|
export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
|
|
17
17
|
export interface PiRelay { url: string; token: string; models: PiModelDescriptor[]; }
|
|
18
|
+
export interface PiExit {
|
|
19
|
+
cause: "process_exit" | "spawn_error" | "session_shutdown" | "owner_lost" | "owner_stopped";
|
|
20
|
+
code: number | null;
|
|
21
|
+
signal: NodeJS.Signals | null;
|
|
22
|
+
expected: boolean;
|
|
23
|
+
started: boolean;
|
|
24
|
+
turnActive: boolean;
|
|
25
|
+
toolActive: boolean;
|
|
26
|
+
lastToolName?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface PiToolContext { userBash: boolean; isCurrent: () => boolean; onApproval: (provenance: ApprovalProvenance) => void; }
|
|
29
|
+
export interface PiToolResult { text: string; exitCode: number | null; }
|
|
30
|
+
class PiToolSettlementFailure extends Error {}
|
|
18
31
|
export interface PiOptions {
|
|
19
32
|
cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
|
|
20
33
|
model?: string;
|
|
21
|
-
relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal) => Promise<string>; tools: PiToolSchema[];
|
|
34
|
+
relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
|
|
22
35
|
preamble?: string;
|
|
23
36
|
selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
|
|
24
37
|
/** Atomic task/run admission immediately before provider requests or tool execution. */
|
|
@@ -28,6 +41,9 @@ export interface PiOptions {
|
|
|
28
41
|
/** `ceiling` is the validated, session/turn-bound tool-step ceiling signal (#179), present only when the trusted extension emitted one for this turn. */
|
|
29
42
|
onTurnFailure?: (envs: Envelope[], reason: string, ceiling?: PiToolStepCeiling) => Promise<void>;
|
|
30
43
|
watchdogMs?: number; log?: (line: string) => void;
|
|
44
|
+
/** Once per owner, after it is offline; a TUI owner may have unknown OS exit status. */
|
|
45
|
+
onExit?: (exit: PiExit) => void;
|
|
46
|
+
onApprovalStop?: (reason: string) => void;
|
|
31
47
|
/** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
|
|
32
48
|
stopGraceMs?: number;
|
|
33
49
|
}
|
|
@@ -65,8 +81,17 @@ export class PiPeer extends BasePeer {
|
|
|
65
81
|
private persistedOnce = false;
|
|
66
82
|
private activeEnvs: Envelope[] = [];
|
|
67
83
|
private activeTools = 0;
|
|
84
|
+
private readonly pendingToolGenerations = new Map<string, { generation: number; abort: AbortController; settled: Promise<void> }>();
|
|
85
|
+
private readonly abortedToolIds = new Set<string>();
|
|
86
|
+
private abortIdsOverflow = false;
|
|
68
87
|
private readonly activeDeliveryIds = new Set<string>();
|
|
69
88
|
private agentRunning = false;
|
|
89
|
+
private approvalExpiries = 0;
|
|
90
|
+
private approvalAnswerEpoch: number | undefined;
|
|
91
|
+
private approvalTurnAbort = false;
|
|
92
|
+
private approvalAbortSent = false;
|
|
93
|
+
private approvalStopReason = "";
|
|
94
|
+
private requestedStopReason: string | undefined;
|
|
70
95
|
private owner = randomUUID();
|
|
71
96
|
private _tuiLaunch?: PiTuiLaunch;
|
|
72
97
|
private requestedModel = "";
|
|
@@ -74,6 +99,10 @@ export class PiPeer extends BasePeer {
|
|
|
74
99
|
private ownerClaimed = false;
|
|
75
100
|
private ownerToken = "";
|
|
76
101
|
private stopping = true;
|
|
102
|
+
private exitReported = false;
|
|
103
|
+
private started = false;
|
|
104
|
+
private lastToolName?: string;
|
|
105
|
+
private shutdownExit?: PiExit;
|
|
77
106
|
private ownerPid?: number;
|
|
78
107
|
private ownerSignature?: string;
|
|
79
108
|
private ownerMonitor?: ReturnType<typeof setInterval>;
|
|
@@ -164,11 +193,18 @@ export class PiPeer extends BasePeer {
|
|
|
164
193
|
}
|
|
165
194
|
private async handleToolRequest(body: any): Promise<Response> {
|
|
166
195
|
if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
|
|
196
|
+
if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
|
|
197
|
+
const text = "error: Pi tool lineage is missing, stale or unverified; restart Pi with the current hub extension before retrying";
|
|
198
|
+
this.opts.log?.(`[${this.id}] ${text}`);
|
|
199
|
+
return Response.json({ text, failed: true }, { status: 409 });
|
|
200
|
+
}
|
|
201
|
+
if (this.abortIdsOverflow || this.abortedToolIds.has(String(body.toolCallId))) return Response.json({ text: "error: tool call cancelled before execution", failed: true });
|
|
202
|
+
if (this.pendingToolGenerations.has(String(body.toolCallId ?? ""))) return Response.json({ text: "error: this Pi tool call is already in progress", failed: true }, { status: 409 });
|
|
167
203
|
let executionSignal = this.executionAbort?.signal;
|
|
168
204
|
let idleBashTimer: ReturnType<typeof setTimeout> | undefined;
|
|
169
205
|
if (body.purpose === "idle_user_bash") {
|
|
170
206
|
const reservation = typeof body.reservation === "string" ? this.idleBashReservations.get(body.reservation) : undefined;
|
|
171
|
-
if (!reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
|
|
207
|
+
if (this.opts.mode !== "tui" || !this.verifiedOwner() || !reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
|
|
172
208
|
if (reservation.deadlineAt !== undefined && reservation.deadlineAt <= Date.now()) {
|
|
173
209
|
this.idleBashReservations.delete(String(body.reservation));
|
|
174
210
|
return Response.json({ text: "error: Pi user shell execution budget expired" }, { status: 409 });
|
|
@@ -179,17 +215,60 @@ export class PiPeer extends BasePeer {
|
|
|
179
215
|
if (reservation.deadlineAt !== undefined) idleBashTimer = setTimeout(() => idleBashAbort.abort(), Math.max(0, reservation.deadlineAt - Date.now()));
|
|
180
216
|
}
|
|
181
217
|
this.noteActivity();
|
|
218
|
+
const generation = this.budgetGeneration;
|
|
219
|
+
const turnSignal = this.executionAbort?.signal;
|
|
220
|
+
const parentSignal = executionSignal;
|
|
221
|
+
const toolAbort = new AbortController();
|
|
222
|
+
const abortTool = () => toolAbort.abort();
|
|
223
|
+
parentSignal?.addEventListener("abort", abortTool, { once: true });
|
|
224
|
+
if (parentSignal?.aborted) abortTool();
|
|
225
|
+
executionSignal = toolAbort.signal;
|
|
226
|
+
const callId = String(body.toolCallId ?? "");
|
|
227
|
+
let settled!: () => void;
|
|
228
|
+
const settlement = new Promise<void>((resolve) => { settled = resolve; });
|
|
229
|
+
this.pendingToolGenerations.set(callId, { generation, abort: toolAbort, settled: settlement });
|
|
230
|
+
const name = String(body.name);
|
|
231
|
+
this.lastToolName = this.opts.tools.some((tool) => tool.name === name) && /^[a-zA-Z0-9_.-]{1,128}$/.test(name) ? name : "unknown";
|
|
182
232
|
this.activeTools++;
|
|
183
233
|
if (this.state === "idle") this.setState("busy");
|
|
184
234
|
if (this.state === "busy") this.touch();
|
|
185
235
|
// `failed` is the managed-tool failure verdict (toolResultFailed, the one contract): Pi 1.0.1
|
|
186
236
|
// classifies a native tool result by isError === true alone, so the extension needs the flag,
|
|
187
237
|
// not just the text (#181). An older extension simply ignores the extra field.
|
|
188
|
-
try {
|
|
238
|
+
try {
|
|
239
|
+
const result = await this.opts.executeTool(name, body.args, callId, this.sessionId, executionSignal, {
|
|
240
|
+
userBash: body.purpose === "idle_user_bash",
|
|
241
|
+
isCurrent: () => generation === this.budgetGeneration && !this.stopping && this.state !== "offline" && !executionSignal?.aborted && !this.approvalStopReason && this.verifiedOwner() && (body.purpose === "idle_user_bash" ? this.opts.mode === "tui" : turnSignal === this.executionAbort?.signal),
|
|
242
|
+
onApproval: (provenance) => {
|
|
243
|
+
if (!this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
|
|
244
|
+
if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
|
|
245
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
|
|
246
|
+
}
|
|
247
|
+
if (provenance.source === "person") this.approvalExpiries = 0;
|
|
248
|
+
if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
|
|
249
|
+
this.approvalStopReason = "Pi turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
|
|
250
|
+
try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
const text = typeof result === "string" ? result : result.text;
|
|
255
|
+
return Response.json({ text, failed: typeof result !== "string" && result.exitCode !== null ? result.exitCode !== 0 : toolResultFailed(name, text), ...(typeof result === "string" ? {} : { exitCode: result.exitCode }) });
|
|
256
|
+
}
|
|
189
257
|
catch (error) { return Response.json({ text: `error: ${(error as Error).message}`, failed: true }, { status: 200 }); }
|
|
190
258
|
finally {
|
|
191
259
|
clearTimeout(idleBashTimer);
|
|
192
260
|
this.activeTools--;
|
|
261
|
+
parentSignal?.removeEventListener("abort", abortTool);
|
|
262
|
+
if (this.pendingToolGenerations.get(callId)?.abort === toolAbort) this.pendingToolGenerations.delete(callId);
|
|
263
|
+
settled();
|
|
264
|
+
if (this.approvalStopReason && !this.approvalAbortSent) {
|
|
265
|
+
this.approvalAbortSent = true;
|
|
266
|
+
const reason = this.approvalStopReason;
|
|
267
|
+
// Finish this request's expiry reply before withdrawing siblings and aborting the native turn.
|
|
268
|
+
setTimeout(() => {
|
|
269
|
+
if (this.agentRunning && generation === this.budgetGeneration && this.approvalStopReason === reason && !this.stopping) void this.endApprovalTurn(generation, reason);
|
|
270
|
+
}, 10);
|
|
271
|
+
}
|
|
193
272
|
if (this.state === "busy") {
|
|
194
273
|
if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
|
|
195
274
|
else this.touch();
|
|
@@ -200,12 +279,14 @@ export class PiPeer extends BasePeer {
|
|
|
200
279
|
private async handleBudgetRequest(body: any): Promise<Response> {
|
|
201
280
|
if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
|
|
202
281
|
const idleUserBash = body.idleUserBash === true;
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
282
|
+
const generation = body.generation, controller = this.executionAbort, session = this.sessionId, ownerPid = this.ownerPid, ownerSignature = this.ownerSignature;
|
|
283
|
+
const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature && !this.stopping && this.verifiedOwner();
|
|
284
|
+
const idleOwner = this.opts.mode === "tui" && !this.stopping && this.verifiedOwner() && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
|
|
285
|
+
if (!current() || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
|
|
206
286
|
// An interactive user shell has no task delivery. It may spend an eligible run budget only.
|
|
207
287
|
const envs = idleUserBash ? [] : this.budgetEnvelopes;
|
|
208
288
|
const decisions = this.opts.admitBudget ? await this.opts.admitBudget(envs, body.unit as ExecutionUnit) : [];
|
|
289
|
+
if (!current() || (idleUserBash ? this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "Pi owner or turn changed during admission" }, { status: 409 });
|
|
209
290
|
const denied = decisions.find((decision) => !decision.allowed);
|
|
210
291
|
const remaining = decisions.filter((decision) => decision.unit === "elapsed_ms" && decision.remaining !== null).reduce<number | undefined>((min, decision) => min === undefined ? decision.remaining! : Math.min(min, decision.remaining!), undefined);
|
|
211
292
|
let reservation: string | undefined;
|
|
@@ -230,6 +311,10 @@ export class PiPeer extends BasePeer {
|
|
|
230
311
|
|
|
231
312
|
private async startImpl(): Promise<void> {
|
|
232
313
|
this.stopping = false;
|
|
314
|
+
this.requestedStopReason = undefined;
|
|
315
|
+
this.approvalTurnAbort = false;
|
|
316
|
+
this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
|
|
317
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
233
318
|
mkdirSync(this.opts.stateDir, { recursive: true });
|
|
234
319
|
const sessions = join(this.opts.stateDir, "pi-sessions");
|
|
235
320
|
mkdirSync(sessions, { recursive: true, mode: 0o700 });
|
|
@@ -275,22 +360,65 @@ export class PiPeer extends BasePeer {
|
|
|
275
360
|
trackGroup(this.proc);
|
|
276
361
|
this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
|
|
277
362
|
this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
|
|
278
|
-
|
|
279
|
-
|
|
363
|
+
const proc = this.proc;
|
|
364
|
+
proc.on("error", (error) => {
|
|
365
|
+
const exit = this.exitMetadata("spawn_error");
|
|
366
|
+
this.fail(error);
|
|
367
|
+
this.reportExit(exit);
|
|
368
|
+
});
|
|
369
|
+
proc.on("exit", (code, signal) => {
|
|
370
|
+
const exit = { ...(this.shutdownExit ?? this.exitMetadata("process_exit")), code, signal };
|
|
371
|
+
this.fail(new Error(`pi exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
|
|
372
|
+
this.reportExit(exit);
|
|
373
|
+
});
|
|
280
374
|
const state = await this.waitRpc("get_state", 15_000);
|
|
281
375
|
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");
|
|
282
376
|
if ((this.opts.sessionId && state.data.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && state.data.sessionFile !== this.opts.sessionFile)) {
|
|
283
377
|
throw new Error("Pi get_state session identity does not match the requested recovery session");
|
|
284
378
|
}
|
|
285
379
|
this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
|
|
380
|
+
// The hub spawned this headless owner and get_state proved its session. Pin the same
|
|
381
|
+
// identity a native extension claim uses, including fakes that do not load the extension.
|
|
382
|
+
if (!this.ownerClaimed) {
|
|
383
|
+
const pid = this.proc?.pid, signature = pid ? processSignature(pid) : undefined;
|
|
384
|
+
if (!pid || !signature) throw new Error("Pi headless owner process identity is not verified");
|
|
385
|
+
this.ownerPid = pid; this.ownerSignature = signature;
|
|
386
|
+
this.ownerToken = String(this._tuiLaunch?.env.AGENTHUB_PI_OWNER_TOKEN ?? ""); this.ownerClaimed = true;
|
|
387
|
+
}
|
|
286
388
|
this.persistedOnce = existsSync(this.sessionFile);
|
|
287
389
|
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");
|
|
390
|
+
this.started = true;
|
|
288
391
|
this.setState("idle");
|
|
289
392
|
}
|
|
290
393
|
|
|
291
|
-
|
|
292
|
-
|
|
394
|
+
private verifiedOwner(): boolean {
|
|
395
|
+
return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && processSignature(this.ownerPid) === this.ownerSignature;
|
|
396
|
+
}
|
|
397
|
+
private abortTools(): Promise<void>[] {
|
|
398
|
+
this.executionAbort?.abort();
|
|
399
|
+
this.idleBashReservations.clear();
|
|
400
|
+
const pending = [...this.pendingToolGenerations.values()];
|
|
401
|
+
for (const call of pending) call.abort.abort();
|
|
402
|
+
return pending.map((call) => call.settled);
|
|
403
|
+
}
|
|
404
|
+
get mode(): "headless" | "tui" { return this.opts.mode; }
|
|
405
|
+
|
|
406
|
+
async stop(reason?: string): Promise<void> {
|
|
407
|
+
if (reason !== undefined) this.requestedStopReason = reason;
|
|
408
|
+
this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
|
|
409
|
+
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
|
|
293
410
|
this.activeDeliveryIds.clear();
|
|
411
|
+
const settling = this.abortTools();
|
|
412
|
+
let unsettled: unknown;
|
|
413
|
+
if (settling.length) {
|
|
414
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
415
|
+
try {
|
|
416
|
+
await Promise.race([Promise.all(settling), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new PiToolSettlementFailure("Pi managed tools did not settle after cancellation")), this.opts.stopGraceMs ?? 5_000); })]);
|
|
417
|
+
} catch (error) { unsettled = error; } // a tool past its grace must not block the teardown (#253): stop reports it at the end
|
|
418
|
+
finally { clearTimeout(timer); }
|
|
419
|
+
}
|
|
420
|
+
const tuiExit = this.opts.mode === "tui" && this.ownerClaimed && this.ownerPid && this.ownerSignature
|
|
421
|
+
? { ...this.exitMetadata("owner_stopped"), expected: true } : undefined;
|
|
294
422
|
this.stopping = true;
|
|
295
423
|
this.clearOwnerMonitor();
|
|
296
424
|
if (this.opts.mode === "tui") {
|
|
@@ -329,6 +457,8 @@ export class PiPeer extends BasePeer {
|
|
|
329
457
|
this.pending.clear();
|
|
330
458
|
this.server?.stop(true); this.server = undefined; this.setState("offline");
|
|
331
459
|
if (failed) throw failed;
|
|
460
|
+
if (tuiExit) this.reportExit(tuiExit); // the verified owner is gone; never invent an exit for an unclaimed launch
|
|
461
|
+
if (unsettled) throw unsettled;
|
|
332
462
|
}
|
|
333
463
|
|
|
334
464
|
/**
|
|
@@ -411,19 +541,40 @@ export class PiPeer extends BasePeer {
|
|
|
411
541
|
}
|
|
412
542
|
|
|
413
543
|
private handleBridgeEvent(event: any): void {
|
|
544
|
+
if (this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) return;
|
|
414
545
|
if (event.type === "session_start") {
|
|
415
546
|
if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
|
|
547
|
+
this.approvalTurnAbort = event.approvalTurnAbort === true;
|
|
416
548
|
this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
|
|
417
549
|
this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
|
|
418
550
|
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; }
|
|
419
|
-
this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
|
|
551
|
+
this.startOwnerMonitor(); if (this.opts.mode === "tui") { this.started = true; this.setState("idle"); }
|
|
552
|
+
}
|
|
553
|
+
if (event.type === "session_shutdown") {
|
|
554
|
+
const exit = this.exitMetadata("session_shutdown");
|
|
555
|
+
this.shutdownExit ??= exit; // headless waits for the OS exit, retaining the pre-cleanup turn and stop facts
|
|
556
|
+
this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
557
|
+
this.fail(new Error("Pi session shut down before settlement"));
|
|
558
|
+
if (this.opts.mode === "tui") this.reportExit(exit);
|
|
559
|
+
}
|
|
560
|
+
if (event.type === "tool_abort" && this.agentRunning && this.ownerClaimed && this.ownerPid && this.ownerSignature
|
|
561
|
+
&& processSignature(this.ownerPid) === this.ownerSignature && event.sessionId === this.sessionId && event.generation === this.budgetGeneration
|
|
562
|
+
&& typeof event.toolCallId === "string" && event.toolCallId.length > 0 && event.toolCallId.length <= 512) {
|
|
563
|
+
const pending = this.pendingToolGenerations.get(event.toolCallId);
|
|
564
|
+
if (pending && pending.generation === event.generation) pending.abort.abort();
|
|
565
|
+
else { // a native abort may arrive before its HTTP tool request; fence that call in this generation
|
|
566
|
+
// ponytail: 64 early aborts per turn; fence further calls rather than forget a cancellation.
|
|
567
|
+
// A native per-call acknowledgement would replace this bounded early-arrival cache.
|
|
568
|
+
if (this.abortedToolIds.size >= 64 && !this.abortedToolIds.has(event.toolCallId)) this.abortIdsOverflow = true;
|
|
569
|
+
else this.abortedToolIds.add(event.toolCallId);
|
|
570
|
+
}
|
|
420
571
|
}
|
|
421
|
-
if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.fail(new Error("Pi session shut down before settlement")); }
|
|
422
572
|
if (event.type === "agent_start") {
|
|
423
573
|
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
|
|
424
574
|
if (generation <= this.budgetGeneration) return;
|
|
425
575
|
this.modelStep = 0;
|
|
426
|
-
this.
|
|
576
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false; this.abortedToolIds.clear(); this.abortIdsOverflow = false;
|
|
577
|
+
this.usageSeen.clear(); this.ceilingStops.clear(); this.abortTools(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
|
|
427
578
|
}
|
|
428
579
|
// #179: the extension's tool-step ceiling signal. Only a schema-valid event bound to THIS session,
|
|
429
580
|
// THIS turn generation and a running turn is stored; the first signal of a turn stands. Anything
|
|
@@ -457,7 +608,8 @@ export class PiPeer extends BasePeer {
|
|
|
457
608
|
this.agentRunning = false;
|
|
458
609
|
if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
|
|
459
610
|
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
|
|
460
|
-
const
|
|
611
|
+
const approvalStopReason = this.approvalStopReason;
|
|
612
|
+
const text = this.settledText.trim(); const error = approvalStopReason || this.budgetStops.get(generation) || this.settledError; const cancelled = !error && this.settledCancelled;
|
|
461
613
|
const ceiling = this.ceilingStops.get(generation);
|
|
462
614
|
this.budgetStops.delete(generation);
|
|
463
615
|
this.ceilingStops.delete(generation);
|
|
@@ -467,12 +619,16 @@ export class PiPeer extends BasePeer {
|
|
|
467
619
|
const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
|
|
468
620
|
if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
|
|
469
621
|
else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
|
|
622
|
+
else if (approvalStopReason) this.onMessage?.(approvalStopReason, reply);
|
|
470
623
|
else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
|
|
471
624
|
else if (text) this.onMessage?.(text, reply);
|
|
472
|
-
for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
|
|
625
|
+
for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
|
|
473
626
|
this.activeDeliveryIds.clear();
|
|
474
|
-
this.currentReply = undefined; this.activeEnvs = [];
|
|
475
|
-
this.executionAbort = undefined;
|
|
627
|
+
this.currentReply = undefined; this.activeEnvs = [];
|
|
628
|
+
this.executionAbort?.abort(); this.executionAbort = undefined;
|
|
629
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
630
|
+
this.abortedToolIds.clear(); this.abortIdsOverflow = false;
|
|
631
|
+
if (this.state === "busy" && !this.activeTools) this.setState("idle");
|
|
476
632
|
}
|
|
477
633
|
}
|
|
478
634
|
|
|
@@ -507,15 +663,34 @@ export class PiPeer extends BasePeer {
|
|
|
507
663
|
try { return await Promise.race([this.sendRpc({ type: command }), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`Pi ${command} timed out`)), timeout); })]); }
|
|
508
664
|
finally { clearTimeout(timer); }
|
|
509
665
|
}
|
|
666
|
+
private exitMetadata(cause: PiExit["cause"]): PiExit {
|
|
667
|
+
return { cause, code: null, signal: null, expected: this.stopping, started: this.started,
|
|
668
|
+
turnActive: this.agentRunning || this.activeEnvs.length > 0, toolActive: this.activeTools > 0,
|
|
669
|
+
...(this.lastToolName ? { lastToolName: this.lastToolName } : {}) };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private reportExit(exit: PiExit): void {
|
|
673
|
+
if (this.exitReported) return;
|
|
674
|
+
this.exitReported = true;
|
|
675
|
+
const line = `[${this.id}] Pi exit: cause=${exit.cause} code=${exit.code ?? "unknown"} signal=${exit.signal ?? "unknown"} expected=${exit.expected} started=${exit.started} turnActive=${exit.turnActive} toolActive=${exit.toolActive} lastTool=${exit.lastToolName ?? "none"}`;
|
|
676
|
+
try { this.opts.log?.(line); } catch { /* reporting must not prevent owner settlement */ }
|
|
677
|
+
try { this.opts.onExit?.(exit); } catch { /* the owner is already offline */ }
|
|
678
|
+
}
|
|
679
|
+
|
|
510
680
|
private fail(error: Error): void {
|
|
681
|
+
const approvalStopReason = this.approvalStopReason;
|
|
682
|
+
this.abortTools();
|
|
683
|
+
this.agentRunning = false;
|
|
684
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
685
|
+
this.abortedToolIds.clear(); this.abortIdsOverflow = false;
|
|
511
686
|
for (const pending of this.pending.values()) pending.reject(error);
|
|
512
687
|
this.pending.clear();
|
|
513
688
|
const envs = this.activeEnvs; this.activeEnvs = [];
|
|
514
689
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: error.message });
|
|
515
690
|
this.activeDeliveryIds.clear();
|
|
516
|
-
if (envs.length) {
|
|
517
|
-
this.onMessage?.("Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
|
|
518
|
-
void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
|
|
691
|
+
if (envs.length && !this.requestedStopReason) {
|
|
692
|
+
this.onMessage?.(approvalStopReason ? `${approvalStopReason} Pi is offline because its extension could not abort the turn; restart with ahub pi and settle the held delivery after inspecting prior work.` : "Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
|
|
693
|
+
if (!approvalStopReason && !(error instanceof ApprovalWaitStop)) void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
|
|
519
694
|
}
|
|
520
695
|
this.currentReply = undefined;
|
|
521
696
|
this.setState("offline");
|
|
@@ -525,15 +700,28 @@ export class PiPeer extends BasePeer {
|
|
|
525
700
|
if (this.opts.mode !== "tui" || !this.ownerPid || !this.ownerSignature) return;
|
|
526
701
|
this.ownerMonitor = setInterval(() => {
|
|
527
702
|
if (!this.ownerPid || ownerStillAlive(this.ownerPid, this.ownerSignature)) return;
|
|
703
|
+
const exit = this.exitMetadata("owner_lost");
|
|
528
704
|
this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
|
|
529
705
|
this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
530
706
|
for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
|
|
531
707
|
this.tuiCommands.clear(); this.tuiQueue = [];
|
|
532
708
|
this.fail(new Error("Pi owner process exited without session_shutdown"));
|
|
709
|
+
this.reportExit(exit);
|
|
533
710
|
}, 500);
|
|
534
711
|
this.ownerMonitor.unref?.();
|
|
535
712
|
}
|
|
536
713
|
private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
|
|
714
|
+
private async endApprovalTurn(generation: number, reason: string): Promise<void> {
|
|
715
|
+
if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || this.stopping || this.state === "offline") return;
|
|
716
|
+
this.executionAbort?.abort();
|
|
717
|
+
if (this.approvalTurnAbort) {
|
|
718
|
+
try { await this.sendTui({ type: "abort_budget", generation, cause: "approval", reason }); return; }
|
|
719
|
+
catch { if (generation !== this.budgetGeneration || !this.agentRunning) return; }
|
|
720
|
+
}
|
|
721
|
+
this.opts.log?.(`[${this.id}] approval turn abort unsupported; Pi goes offline. Restart with ahub pi and settle the held delivery after inspecting prior work.`);
|
|
722
|
+
await this.terminateFailedTurn(new ApprovalWaitStop(reason));
|
|
723
|
+
}
|
|
724
|
+
|
|
537
725
|
private async terminateFailedTurn(error: Error): Promise<void> {
|
|
538
726
|
const envs = this.activeEnvs.slice(), reply = this.currentReply;
|
|
539
727
|
this.stopping = true;
|
|
@@ -541,7 +729,12 @@ export class PiPeer extends BasePeer {
|
|
|
541
729
|
await this.stop();
|
|
542
730
|
// The process exit handler may already have reported this failure.
|
|
543
731
|
if (this.activeEnvs.length) this.fail(error);
|
|
544
|
-
} catch {
|
|
732
|
+
} catch (stopError) {
|
|
733
|
+
if (stopError instanceof PiToolSettlementFailure && this.state === "offline") {
|
|
734
|
+
this.opts.log?.(`Pi teardown completed with a managed-tool settlement failure: ${(stopError as Error).message}`);
|
|
735
|
+
if (this.activeEnvs.length) this.fail(error);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
545
738
|
this.activeEnvs = envs; this.currentReply = reply;
|
|
546
739
|
this.stopping = true;
|
|
547
740
|
this.setState("busy");
|