@staix/agent-hub 0.12.21 → 0.12.23
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 +29 -0
- package/README.md +27 -12
- package/docs/agent-notes/adapters.md +15 -7
- package/docs/agent-notes/benchmarks.md +4 -1
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +12 -1
- package/docs/agent-notes/local-worker.md +4 -1
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +4 -0
- package/docs/events.md +17 -4
- package/docs/operations.md +478 -41
- package/docs/quickstart.md +14 -6
- package/docs/security.md +23 -11
- package/docs/smoke.md +133 -7
- package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +32 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +105 -17
- package/src/adapters/codex-appserver.ts +70 -35
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +25 -19
- package/src/cli/identity.ts +3 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +409 -177
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +80 -9
- package/src/cli/upgrade.ts +50 -15
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +15 -14
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +483 -83
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +7 -5
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +43 -4
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +158 -43
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +71 -2
- package/src/local/sandbox.ts +47 -6
- package/src/local/tools.ts +25 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +19 -2
- package/src/ui/index.html +172 -21
|
@@ -37,7 +37,7 @@ export interface CodexOptions {
|
|
|
37
37
|
onItem?: (item: any, nativeTurn?: string) => void;
|
|
38
38
|
/** How often to ask app-server for the rate limits while a TUI is attached. */
|
|
39
39
|
usagePollMs?: number;
|
|
40
|
-
/**
|
|
40
|
+
/** Facts wait from send; durable steers wait only after their original turn ends (default 10 s). */
|
|
41
41
|
steerTimeoutMs?: number;
|
|
42
42
|
cwd: string;
|
|
43
43
|
/** Optional launch environment; recovery authority is always removed before spawn. */
|
|
@@ -63,9 +63,9 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
|
|
|
63
63
|
*/
|
|
64
64
|
export class CodexPeer extends BasePeer {
|
|
65
65
|
private permissionMode: PermissionMode;
|
|
66
|
-
private
|
|
67
|
-
private
|
|
68
|
-
private readonly restoringApprovals = new
|
|
66
|
+
private readonly nativeApprovalPolicies = new Map<string, unknown>();
|
|
67
|
+
private readonly overriddenApprovals = new Set<string>();
|
|
68
|
+
private readonly restoringApprovals = new Map<string | number, string>();
|
|
69
69
|
private proc: ChildProcess | undefined;
|
|
70
70
|
private server: Server<Link> | undefined;
|
|
71
71
|
private link: Link | undefined; // the connection that owns the current thread
|
|
@@ -84,7 +84,7 @@ export class CodexPeer extends BasePeer {
|
|
|
84
84
|
private freshThread = false;
|
|
85
85
|
private readonly activeTurns = new Set<string>();
|
|
86
86
|
private nextId = -1;
|
|
87
|
-
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer" }>();
|
|
87
|
+
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void }>();
|
|
88
88
|
private usageTimer: ReturnType<typeof setInterval> | undefined;
|
|
89
89
|
private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
|
|
90
90
|
private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
|
|
@@ -191,7 +191,6 @@ export class CodexPeer extends BasePeer {
|
|
|
191
191
|
this.pending.set(id, {
|
|
192
192
|
deliveryId, kind: "deliver",
|
|
193
193
|
resolve: (result) => {
|
|
194
|
-
if (this.restoringApprovals.delete(id) && this.permissionMode === "ask") this.approvalOverridden = false;
|
|
195
194
|
this.primed = true;
|
|
196
195
|
this.injected = replyParent(envs);
|
|
197
196
|
this.answering = [...envs];
|
|
@@ -229,12 +228,35 @@ export class CodexPeer extends BasePeer {
|
|
|
229
228
|
return Promise.reject(new Error(`${this.id} has no steerable turn`));
|
|
230
229
|
}
|
|
231
230
|
const id = this.nextId--;
|
|
231
|
+
const expectedEpoch = this.contextEpoch;
|
|
232
232
|
this.steers.add(id);
|
|
233
233
|
return new Promise<void>((resolve, reject) => {
|
|
234
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
+
const settle = () => { clearTimeout(timer); this.steers.delete(id); };
|
|
236
|
+
const fail = (e: Error) => {
|
|
237
|
+
settle();
|
|
238
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
|
|
239
|
+
reject(e);
|
|
240
|
+
};
|
|
234
241
|
this.pending.set(id, {
|
|
235
|
-
deliveryId, kind: "steer",
|
|
236
|
-
|
|
237
|
-
|
|
242
|
+
deliveryId, kind: "steer", expectedTurnId,
|
|
243
|
+
afterTurn: deliveryId ? () => {
|
|
244
|
+
if (timer !== undefined) return;
|
|
245
|
+
timer = setTimeout(() => {
|
|
246
|
+
const pending = this.pending.get(id);
|
|
247
|
+
this.pending.delete(id);
|
|
248
|
+
pending?.reject(new Error("steer unanswered: RPC timeout"));
|
|
249
|
+
}, this.opts.steerTimeoutMs ?? 10_000);
|
|
250
|
+
timer.unref?.();
|
|
251
|
+
} : undefined,
|
|
252
|
+
resolve: (result) => {
|
|
253
|
+
// Native completion can precede the RPC answer. A late refusal is definitive; a late acceptance is uncertain.
|
|
254
|
+
if (deliveryId && (this.link !== link || this.contextEpoch !== expectedEpoch || !this.activeTurns.has(expectedTurnId) ||
|
|
255
|
+
(typeof result?.turnId === "string" && result.turnId !== expectedTurnId))) {
|
|
256
|
+
fail(new Error("steer accepted after its original native turn ended or changed"));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
settle();
|
|
238
260
|
this.primed = true;
|
|
239
261
|
// The turn now answers these too; the highest hop wins so a steer cannot reset the hop cap.
|
|
240
262
|
this.injected = replyParent(this.injected ? [this.injected, ...envs] : envs);
|
|
@@ -245,11 +267,7 @@ export class CodexPeer extends BasePeer {
|
|
|
245
267
|
}
|
|
246
268
|
resolve();
|
|
247
269
|
},
|
|
248
|
-
reject:
|
|
249
|
-
this.steers.delete(id);
|
|
250
|
-
if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
|
|
251
|
-
reject(e);
|
|
252
|
-
},
|
|
270
|
+
reject: fail,
|
|
253
271
|
});
|
|
254
272
|
const input = [{ type: "text", text: this.render(envs) }];
|
|
255
273
|
link.up.send(JSON.stringify({ method: "turn/steer", id, params: { threadId: this.threadId, expectedTurnId, input } }));
|
|
@@ -300,6 +318,16 @@ export class CodexPeer extends BasePeer {
|
|
|
300
318
|
}
|
|
301
319
|
}
|
|
302
320
|
|
|
321
|
+
/** An ended native turn bounds only its unanswered durable steers; legacy steers retain their immediate retry. */
|
|
322
|
+
private endSteers(turnId: string | undefined): void {
|
|
323
|
+
for (const id of this.steers) {
|
|
324
|
+
const pending = this.pending.get(id);
|
|
325
|
+
if (!pending || pending.expectedTurnId !== turnId) continue;
|
|
326
|
+
if (pending.deliveryId) pending.afterTurn?.();
|
|
327
|
+
else { this.pending.delete(id); pending.reject(new Error("steer unanswered: turn completed")); }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
303
331
|
private addTurnDelivery(turnId: string, deliveryId: string): void {
|
|
304
332
|
const ids = this.turnDeliveries.get(turnId) ?? new Set<string>();
|
|
305
333
|
ids.add(deliveryId); this.turnDeliveries.set(turnId, ids); this.unboundDeliveries.delete(deliveryId);
|
|
@@ -364,8 +392,8 @@ export class CodexPeer extends BasePeer {
|
|
|
364
392
|
this.contextEpoch++;
|
|
365
393
|
this.activeTurns.clear();
|
|
366
394
|
for (const p of this.pending.values()) {
|
|
367
|
-
if (p.deliveryId) this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
|
|
368
|
-
p.reject(new Error("codex TUI detached"));
|
|
395
|
+
if (p.deliveryId && p.kind !== "steer") this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
|
|
396
|
+
p.reject(new Error(p.kind === "steer" ? "Codex TUI detached" : "codex TUI detached"));
|
|
369
397
|
}
|
|
370
398
|
this.pending.clear();
|
|
371
399
|
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
|
|
@@ -388,44 +416,50 @@ export class CodexPeer extends BasePeer {
|
|
|
388
416
|
|
|
389
417
|
/** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
|
|
390
418
|
setStartupPermissionMode(mode: PermissionMode): void {
|
|
391
|
-
if (this.link || this.state !== "offline" || this.
|
|
419
|
+
if (this.link || this.state !== "offline" || this.overriddenApprovals.size) throw new Error("codex startup mode reconciliation requires a fresh unattached proxy");
|
|
392
420
|
this.permissionMode = mode;
|
|
393
421
|
}
|
|
394
422
|
|
|
395
|
-
/**
|
|
396
|
-
* The hub's choice was cleared while no TUI is attached: this proxy sends no override from now on. Returns false
|
|
397
|
-
* when an override it already sent cannot be undone on that thread (its native policy was never reported): a thread
|
|
398
|
-
* started or resumed through the proxy afterwards reports its own policy, and that one is left alone.
|
|
399
|
-
*/
|
|
423
|
+
/** Clear the overlay while detached, retaining each thread's restoration debt across a resume. */
|
|
400
424
|
clearPermissionMode(): boolean {
|
|
401
|
-
const restorable =
|
|
425
|
+
const restorable = [...this.overriddenApprovals].every(thread => this.nativeApprovalPolicies.has(thread));
|
|
402
426
|
this.permissionMode = "ask";
|
|
403
427
|
return restorable;
|
|
404
428
|
}
|
|
405
429
|
|
|
406
430
|
async setPermissionMode(mode: PermissionMode): Promise<void> {
|
|
407
431
|
if (!this.link || this.link.up.readyState !== WebSocket.OPEN) throw new Error("codex permission mode unavailable: attach the TUI through ahub codex first");
|
|
408
|
-
if (mode === "ask" && this.
|
|
432
|
+
if (mode === "ask" && [...this.overriddenApprovals].some(thread => !this.nativeApprovalPolicies.has(thread))) throw new Error("codex cannot restore ask: native approval policy unavailable; close the TUI, then run ahub permission codex ask before you restart the Codex session with ahub codex");
|
|
409
433
|
this.permissionMode = mode;
|
|
410
434
|
}
|
|
411
435
|
|
|
412
436
|
private applyPermissionPolicy(params: Record<string, unknown>, id: string | number): Record<string, unknown> {
|
|
437
|
+
const thread = typeof params.threadId === "string" ? params.threadId : this.threadId;
|
|
413
438
|
if (this.permissionMode !== "ask") {
|
|
414
|
-
this.
|
|
439
|
+
this.overriddenApprovals.add(thread);
|
|
415
440
|
return { ...params, approvalPolicy: this.permissionMode === "never-ask" ? "never" : "on-request" };
|
|
416
441
|
}
|
|
417
|
-
if (this.
|
|
418
|
-
this.restoringApprovals.
|
|
419
|
-
return { ...params, approvalPolicy: this.
|
|
442
|
+
if (this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread)) {
|
|
443
|
+
this.restoringApprovals.set(id, thread);
|
|
444
|
+
return { ...params, approvalPolicy: this.nativeApprovalPolicies.get(thread) };
|
|
420
445
|
}
|
|
421
446
|
return params;
|
|
422
447
|
}
|
|
423
448
|
|
|
449
|
+
private settlePermissionRestore(id: string | number, failed: boolean): void {
|
|
450
|
+
const thread = this.restoringApprovals.get(id);
|
|
451
|
+
this.restoringApprovals.delete(id);
|
|
452
|
+
if (thread !== undefined && !failed && this.permissionMode === "ask") this.overriddenApprovals.delete(thread);
|
|
453
|
+
}
|
|
454
|
+
|
|
424
455
|
private fromTui(link: Link, raw: string): void {
|
|
425
456
|
const msg = parse(raw);
|
|
426
457
|
if (msg?.method === "turn/start" && msg.params && link === this.link) {
|
|
427
|
-
|
|
428
|
-
|
|
458
|
+
const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
|
|
459
|
+
// A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
|
|
460
|
+
const restoring = this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread);
|
|
461
|
+
if (msg.params.approvalPolicy != null && !restoring) this.nativeApprovalPolicies.set(thread, msg.params.approvalPolicy);
|
|
462
|
+
if (this.permissionMode !== "ask" || this.overriddenApprovals.has(thread)) {
|
|
429
463
|
// Native turn overrides persist: ask restores the captured native policy once on successful admission.
|
|
430
464
|
msg.params = this.applyPermissionPolicy(msg.params, msg.id);
|
|
431
465
|
raw = JSON.stringify(msg);
|
|
@@ -441,6 +475,7 @@ export class CodexPeer extends BasePeer {
|
|
|
441
475
|
if (this.state === "busy") this.touch();
|
|
442
476
|
if (!msg) return void link.tui.send(raw);
|
|
443
477
|
|
|
478
|
+
if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
|
|
444
479
|
if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
|
|
445
480
|
const p = this.pending.get(msg.id);
|
|
446
481
|
this.pending.delete(msg.id);
|
|
@@ -448,14 +483,13 @@ export class CodexPeer extends BasePeer {
|
|
|
448
483
|
else p?.resolve(msg.result);
|
|
449
484
|
return; // ours: the TUI never asked for it
|
|
450
485
|
}
|
|
451
|
-
if (msg.id !== undefined && !msg.method && this.restoringApprovals.delete(msg.id) && !msg.error && this.permissionMode === "ask") this.approvalOverridden = false;
|
|
452
486
|
if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
|
|
453
487
|
const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
|
|
454
488
|
if (tracked && link.tracked.delete(msg.id)) {
|
|
455
489
|
if (typeof msg.result?.thread?.id === "string") {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
this.
|
|
490
|
+
// A resumed response may report the sticky hub override, not the original native choice.
|
|
491
|
+
const thread = msg.result.thread.id;
|
|
492
|
+
if (!this.overriddenApprovals.has(thread) && msg.result.approvalPolicy != null) this.nativeApprovalPolicies.set(thread, msg.result.approvalPolicy);
|
|
459
493
|
this.adopt(link, msg.result.thread.id, tracked === "thread/start");
|
|
460
494
|
}
|
|
461
495
|
}
|
|
@@ -465,6 +499,7 @@ export class CodexPeer extends BasePeer {
|
|
|
465
499
|
|
|
466
500
|
private adopt(link: Link, threadId: unknown, fresh = false): void {
|
|
467
501
|
if (typeof threadId !== "string" || !threadId) return;
|
|
502
|
+
this.abandonSteers("thread changed");
|
|
468
503
|
this.tokenTotal = undefined;
|
|
469
504
|
this.freshThread = fresh;
|
|
470
505
|
this.link = link;
|
|
@@ -554,8 +589,8 @@ export class CodexPeer extends BasePeer {
|
|
|
554
589
|
if (params.turn?.status === "failed") {
|
|
555
590
|
this.opts.log?.(`[${this.id}] turn failed: ${params.turn.error?.message ?? "unknown error"}`);
|
|
556
591
|
}
|
|
592
|
+
this.endSteers(nativeTurn);
|
|
557
593
|
if (this.activeTurns.size) return;
|
|
558
|
-
this.abandonSteers("turn completed");
|
|
559
594
|
if (typeof nativeTurn === "string") {
|
|
560
595
|
for (const id of this.turnDeliveries.get(nativeTurn) ?? []) this.delivery({ id, state: params.turn?.status === "failed" ? "needs_review" : "completed", ...(params.turn?.status === "failed" ? { reason: params.turn?.error?.message ?? "Codex turn failed" } : {}) });
|
|
561
596
|
this.turnDeliveries.delete(nativeTurn);
|
|
@@ -8,7 +8,7 @@ import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, t
|
|
|
8
8
|
import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
|
|
9
9
|
import { BasePeer } from "../hub/peers.ts";
|
|
10
10
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
11
|
-
import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
|
|
11
|
+
import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
|
|
12
12
|
import type { Capture } from "../memory/capture.ts";
|
|
13
13
|
import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/client.ts";
|
|
14
14
|
import { safeModelLabel } from "../omniroute/usage.ts";
|
|
@@ -32,6 +32,7 @@ export interface LocalOptions {
|
|
|
32
32
|
onRouteOutcome?: (event: RouteLabelEvent) => void;
|
|
33
33
|
/** The daemon turn id created synchronously on busy, shared with turn_start/end. */
|
|
34
34
|
turnId?: () => string | undefined;
|
|
35
|
+
onApprovalStop?: (reason: string) => void;
|
|
35
36
|
onTool?: (observation: ToolObservation, task?: string) => void;
|
|
36
37
|
tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[] };
|
|
37
38
|
capture?: Capture;
|
|
@@ -85,6 +86,9 @@ export class LocalPeer extends BasePeer {
|
|
|
85
86
|
private abort: AbortController | undefined;
|
|
86
87
|
private budgetTimer?: ReturnType<typeof setTimeout>;
|
|
87
88
|
private budgetStopReason = "";
|
|
89
|
+
private approvalExpiries = 0;
|
|
90
|
+
private approvalAnswerEpoch: number | undefined;
|
|
91
|
+
private approvalStopReason = "";
|
|
88
92
|
private activeDeliveryId: string | undefined;
|
|
89
93
|
private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
|
|
90
94
|
/** What served the last call, for `ahub status`. */
|
|
@@ -127,15 +131,15 @@ export class LocalPeer extends BasePeer {
|
|
|
127
131
|
this.setState("idle");
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
async stop(): Promise<void> {
|
|
134
|
+
async stop(reason?: string): Promise<void> {
|
|
131
135
|
this.endRouteTurn(this.labelTurnId, "failed");
|
|
132
|
-
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn stopped before settlement" });
|
|
136
|
+
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "turn stopped before settlement" });
|
|
133
137
|
this.activeDeliveryId = undefined;
|
|
134
138
|
this.turn++;
|
|
135
139
|
clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
|
|
136
140
|
this.abort?.abort();
|
|
141
|
+
this.setState("offline"); // fence direct delivery before the bounded memory request awaits
|
|
137
142
|
await this.opts.capture?.end();
|
|
138
|
-
this.setState("offline");
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/** Resolves once the turn is claimed; a turn that cannot reach any model hands the envelopes back through onFailed. */
|
|
@@ -146,6 +150,7 @@ export class LocalPeer extends BasePeer {
|
|
|
146
150
|
}
|
|
147
151
|
const turn = ++this.turn;
|
|
148
152
|
this.budgetStopReason = "";
|
|
153
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = "";
|
|
149
154
|
clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
|
|
150
155
|
this.activeDeliveryId = deliveryId;
|
|
151
156
|
this.abort = new AbortController();
|
|
@@ -172,6 +177,14 @@ export class LocalPeer extends BasePeer {
|
|
|
172
177
|
})
|
|
173
178
|
.catch((e: Error) => {
|
|
174
179
|
if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
|
|
180
|
+
if (e instanceof ApprovalWaitStop) {
|
|
181
|
+
this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
|
|
182
|
+
msgs.push({ role: "assistant", content: e.message });
|
|
183
|
+
if (!policy?.pii) this.commit(msgs);
|
|
184
|
+
this.onMessage?.(e.message, reply);
|
|
185
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: e.message });
|
|
186
|
+
return; // even without effects, an unanswered approval must not trigger safe replay
|
|
187
|
+
}
|
|
175
188
|
if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
|
|
176
189
|
this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
|
|
177
190
|
if (e instanceof ExecutionBudgetStop) {
|
|
@@ -263,6 +276,7 @@ export class LocalPeer extends BasePeer {
|
|
|
263
276
|
if (SIDE_EFFECTS.has(call.function.name) && !output.startsWith("error:")) progress.sideEffects++;
|
|
264
277
|
const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
|
|
265
278
|
msgs.push(result); results.push(result);
|
|
279
|
+
if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
|
|
266
280
|
this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
267
281
|
// Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
|
|
268
282
|
if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
@@ -277,14 +291,34 @@ export class LocalPeer extends BasePeer {
|
|
|
277
291
|
return {
|
|
278
292
|
cwd: this.opts.cwd,
|
|
279
293
|
deny: this.opts.tools.deny,
|
|
280
|
-
permit: async (title, tool, _signal,
|
|
281
|
-
if (turnSignal.aborted) return
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
294
|
+
permit: async (title, tool, _signal, canonicalTarget) => {
|
|
295
|
+
if (turnSignal.aborted) return "aborted";
|
|
296
|
+
const generation = this.turn;
|
|
297
|
+
let observed = false;
|
|
298
|
+
const observe = (provenance: ApprovalProvenance) => {
|
|
299
|
+
observed = true;
|
|
300
|
+
if (generation !== this.turn || turnSignal.aborted || this.approvalStopReason) return;
|
|
301
|
+
if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
|
|
302
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
|
|
303
|
+
}
|
|
304
|
+
if (provenance.source === "person") this.approvalExpiries = 0;
|
|
305
|
+
if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
|
|
306
|
+
this.approvalStopReason = "Local turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
|
|
307
|
+
try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const picked = await new Promise<ToolApproval>((resolve, reject) => {
|
|
311
|
+
const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
|
|
312
|
+
const onAbort = () => finish("aborted");
|
|
285
313
|
turnSignal.addEventListener("abort", onAbort, { once: true });
|
|
286
|
-
this.opts.tools.permit(title, tool, turnSignal,
|
|
314
|
+
this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
|
|
287
315
|
});
|
|
316
|
+
if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
|
|
317
|
+
// Legacy permit callbacks can represent a person's denial without provenance. A true
|
|
318
|
+
// result may be automatic, so it must never reset the unanswered-request streak.
|
|
319
|
+
if (!observed && picked === false) observe({ source: "person", answerEpoch: (this.approvalAnswerEpoch ?? 0) + 1 });
|
|
320
|
+
if (!observed && picked === "expired") observe({ source: "expired", answerEpoch: this.approvalAnswerEpoch ?? 0, eligibleExpiry: true });
|
|
321
|
+
return picked;
|
|
288
322
|
},
|
|
289
323
|
sandboxProfile: this.sandboxProfile,
|
|
290
324
|
sandboxEnv: { ...proxyEnv(this.opts.tools.bashNetwork ?? false), AGENTHUB_PEER_ID: this.id },
|