@staix/agent-hub 0.30.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 +39 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +9 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +139 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +22 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +307 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +18 -5
- package/docs/security.md +154 -60
- 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/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/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 +4393 -3609
- 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 +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -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/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +77 -16
- 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 +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -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/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +493 -53
- 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/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1569 -134
- 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/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -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/peers.ts +25 -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/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- 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 +138 -7
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
|
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
3
3
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import type { PermissionMode } from "../hub/permission-mode.ts";
|
|
5
|
+
import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
|
|
5
6
|
import { BasePeer, type FrontendProxyPort } from "../hub/peers.ts";
|
|
6
7
|
import { codexContext, type ContextReading } from "../hub/context-window.ts";
|
|
7
8
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
@@ -135,7 +136,7 @@ export class CodexPeer extends BasePeer {
|
|
|
135
136
|
}
|
|
136
137
|
private unboundAccepted = false;
|
|
137
138
|
private nextId = -1;
|
|
138
|
-
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string) => void }>();
|
|
139
|
+
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error, failureCause?: DeliveryFailureCause) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string, failureCause?: DeliveryFailureCause) => void }>();
|
|
139
140
|
private usageTimer: ReturnType<typeof setInterval> | undefined;
|
|
140
141
|
private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
|
|
141
142
|
private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
|
|
@@ -260,12 +261,12 @@ export class CodexPeer extends BasePeer {
|
|
|
260
261
|
return new Promise<void>((resolve, reject) => {
|
|
261
262
|
let invalidated = false;
|
|
262
263
|
const retiredAtCancel = new Set<string>();
|
|
263
|
-
const invalidate = (reason: string) => {
|
|
264
|
+
const invalidate = (reason: string, failureCause?: DeliveryFailureCause) => {
|
|
264
265
|
if (invalidated) return;
|
|
265
266
|
invalidated = true;
|
|
266
267
|
for (const turn of [...this.activeTurns, ...this.turnDeliveries.keys()]) if (!turn.startsWith("unknown:")) retiredAtCancel.add(turn);
|
|
267
268
|
envs = []; // only the late native-id callback survives, never the cancelled audience/prompt
|
|
268
|
-
if (deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason });
|
|
269
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason, ...(failureCause ? { failureCause } : {}) });
|
|
269
270
|
reject(new Error(reason));
|
|
270
271
|
};
|
|
271
272
|
this.pending.set(id, {
|
|
@@ -330,9 +331,9 @@ export class CodexPeer extends BasePeer {
|
|
|
330
331
|
return new Promise<void>((resolve, reject) => {
|
|
331
332
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
332
333
|
const settle = () => { clearTimeout(timer); this.steers.delete(id); };
|
|
333
|
-
const fail = (e: Error) => {
|
|
334
|
+
const fail = (e: Error, failureCause?: DeliveryFailureCause) => {
|
|
334
335
|
settle();
|
|
335
|
-
if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
|
|
336
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message, ...(failureCause ? { failureCause } : {}) });
|
|
336
337
|
reject(e);
|
|
337
338
|
};
|
|
338
339
|
this.pending.set(id, {
|
|
@@ -342,7 +343,7 @@ export class CodexPeer extends BasePeer {
|
|
|
342
343
|
timer = setTimeout(() => {
|
|
343
344
|
const pending = this.pending.get(id);
|
|
344
345
|
this.pending.delete(id);
|
|
345
|
-
pending?.reject(new Error("steer unanswered: RPC timeout"));
|
|
346
|
+
pending?.reject(new Error("steer unanswered: RPC timeout"), "timeout");
|
|
346
347
|
}, this.opts.steerTimeoutMs ?? 10_000);
|
|
347
348
|
timer.unref?.();
|
|
348
349
|
} : undefined,
|
|
@@ -410,17 +411,17 @@ export class CodexPeer extends BasePeer {
|
|
|
410
411
|
this.opts.log?.(UNIDENTIFIED_CANCELLATION_NOTICE);
|
|
411
412
|
}
|
|
412
413
|
for (const turnId of known) this.retireTurn(turnId);
|
|
413
|
-
for (const pending of pendingStarts) pending.invalidate?.("Codex turn watchdog timeout before native acceptance was confirmed");
|
|
414
|
+
for (const pending of pendingStarts) pending.invalidate?.("Codex turn watchdog timeout before native acceptance was confirmed", "timeout");
|
|
414
415
|
this.activeTurns.clear();
|
|
415
416
|
this.unboundAccepted = false;
|
|
416
|
-
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
|
|
417
|
-
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
|
|
417
|
+
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout", failureCause: "timeout" });
|
|
418
|
+
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout", failureCause: "timeout" });
|
|
418
419
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
419
420
|
this.injected = undefined;
|
|
420
421
|
this.answering = [];
|
|
421
422
|
this.lastAnswer = "";
|
|
422
423
|
this.deltas.clear();
|
|
423
|
-
this.abandonSteers("turn went silent");
|
|
424
|
+
this.abandonSteers("turn went silent", "timeout");
|
|
424
425
|
if (this.unidentifiedCancellation) this.setState("offline");
|
|
425
426
|
else super.onWatchdog();
|
|
426
427
|
}
|
|
@@ -432,11 +433,11 @@ export class CodexPeer extends BasePeer {
|
|
|
432
433
|
}
|
|
433
434
|
|
|
434
435
|
/** A steer nobody answered must not vanish: rejecting it sends the envelope back to the queue. */
|
|
435
|
-
private abandonSteers(reason: string): void {
|
|
436
|
+
private abandonSteers(reason: string, failureCause?: DeliveryFailureCause): void {
|
|
436
437
|
for (const id of this.steers) {
|
|
437
438
|
const p = this.pending.get(id);
|
|
438
439
|
this.pending.delete(id);
|
|
439
|
-
p?.reject(new Error(`steer unanswered: ${reason}`));
|
|
440
|
+
p?.reject(new Error(`steer unanswered: ${reason}`), failureCause);
|
|
440
441
|
}
|
|
441
442
|
}
|
|
442
443
|
|
package/src/adapters/compat.ts
CHANGED
|
@@ -34,11 +34,11 @@ export const COMPAT = {
|
|
|
34
34
|
parse: out => plain(out.trim()),
|
|
35
35
|
trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0 and 1.5.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
|
|
36
36
|
receipt: "docs/verification/2026-11-03-pi-150-compat.md" },
|
|
37
|
-
// Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; host-wrapper receipt below repeats with them.
|
|
38
|
-
"claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316"], gate: "contract", probe: ["claude", "--version"],
|
|
37
|
+
// Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; 2.1.318: docs/verification/2026-11-05-claude-implementer-2.1.318.md; host-wrapper receipt below repeats with them.
|
|
38
|
+
"claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316", "2.1.318"], gate: "contract", probe: ["claude", "--version"],
|
|
39
39
|
parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
|
|
40
40
|
trustNote: "The native prompt-tool transport and host-wrapper rewrite, environment isolation and replay contract have isolated receipts. No dontAsk/bypass fallback exists; another version's permission or rewrite behavior is unverified.",
|
|
41
|
-
receipt: "docs/verification/2026-
|
|
41
|
+
receipt: "docs/verification/2026-11-05-claude-implementer-2.1.318.md" },
|
|
42
42
|
// 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md; 0.166.0: docs/verification/2026-10-31-codex-0166-reviewer-isolation.md
|
|
43
43
|
"codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0", "0.166.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
|
|
44
44
|
parse: out => plain(out.trim().replace(/^codex-cli /, "")),
|
|
@@ -65,7 +65,7 @@ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
|
|
|
65
65
|
identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"], modelSelect: true },
|
|
66
66
|
permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools, proven read-only shell commands and bound task commands inside the owned worktree may run automatically; protected paths and other commands ask" } },
|
|
67
67
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: false, launchKind: "claude-implementer", crashPlan: () => ({ resume: {}, how: "Claude implementer resumes its recorded per-task sessions" }) },
|
|
68
|
-
turnEnd: "adapter", budget: { account: "claude" },
|
|
68
|
+
turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "claude" },
|
|
69
69
|
cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
|
|
70
70
|
};
|
|
71
71
|
|
|
@@ -74,7 +74,7 @@ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
|
|
|
74
74
|
export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
|
|
75
75
|
{
|
|
76
76
|
projection: { roleRank: 0, helpRank: 0, plannerEligible: true },
|
|
77
|
-
api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
|
|
77
|
+
api: 1, kind: "claude", questionWaitMs: 45000, displayName: "Claude Code", family: "claude", transport: "claude-channel",
|
|
78
78
|
roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
79
79
|
start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true, upStart: { allowed: true, default: true },
|
|
80
80
|
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
|
|
@@ -84,27 +84,27 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
84
84
|
"ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
|
|
85
85
|
"never-ask": "every tool runs without asking" } },
|
|
86
86
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true, ...CLAUDE_RECOVERY_SESSION, crashPlan: claudeCrashPlan, unrecordedTerminal: "always" },
|
|
87
|
-
turnEnd: "native-hook", budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
|
|
87
|
+
turnEnd: "native-hook", settle: { signal: "native-stop" }, budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
|
|
88
88
|
cli: { command: "claude", entry: { lifecycleAdmission: true, execute: runClaudeEntry }, preview: previewClaude, usage: [["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"]], retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY, hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
|
|
89
89
|
reviewerVariant: CLAUDE_REVIEWER_VARIANT, headlessVariant: CLAUDE_IMPLEMENTER_VARIANT,
|
|
90
90
|
},
|
|
91
91
|
{
|
|
92
92
|
reviewerVariant: CODEX_REVIEWER_VARIANT,
|
|
93
93
|
projection: { roleRank: 1, startRank: 1, helpRank: 1, plannerEligible: true },
|
|
94
|
-
api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
94
|
+
api: 1, kind: "codex", questionWaitMs: 45000, displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
95
95
|
roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
96
96
|
start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true, upStart: { allowed: true, default: true },
|
|
97
97
|
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
|
|
98
98
|
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
99
99
|
permission: { trackUnattended: true, grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
|
|
100
100
|
session: { nativeRevert: true, idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true, ...CODEX_RECOVERY_SESSION, crashPlan: codexCrashPlan, unrecordedTerminal: "always" },
|
|
101
|
-
turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
|
|
101
|
+
turnEnd: "adapter", settle: { signal: "turn-completed" }, budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
|
|
102
102
|
cli: { command: "codex", entry: { lifecycleAdmission: true, execute: runCodexEntry }, preview: previewCodex, usage: [["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"]], launchConfigKeys: ["codex_bin"], retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
reviewerVariant: KIMI_REVIEWER_VARIANT,
|
|
106
106
|
projection: { roleRank: 2, startRank: 2, helpRank: 2, plannerEligible: true },
|
|
107
|
-
api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
|
|
107
|
+
api: 1, kind: "kimi", questionWaitMs: 45000, displayName: "Kimi Code", family: "kimi", transport: "acp",
|
|
108
108
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
109
109
|
start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
|
|
110
110
|
// ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
|
|
@@ -114,45 +114,45 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
114
114
|
permission: { grants: { ask: "Kimi asks as it does on its own", "ask-when-needed": "Kimi's yolo mode: it asks only where it judges it must", "never-ask": "Kimi's auto mode: nothing asks" },
|
|
115
115
|
unmanagedForms: Object.freeze(["tui"] as const), unmanagedStatusNote: "Kimi approves tools in its own TUI; no hub permission mode", unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
|
|
116
116
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
|
|
117
|
-
turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
|
|
117
|
+
turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
|
|
118
118
|
cli: { command: "kimi", entry: { lifecycleAdmission: true, execute: runKimiEntry }, preview: previewKimi, usage: [["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"]], launchConfigKeys: ["kimi_cmd", "peers.kimi.start_mode"], stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
|
|
122
|
-
api: 1, kind: "pi", title: "hub", titlePersistence: "native", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
122
|
+
api: 1, kind: "pi", questionWaitMs: 300000, title: "hub", titlePersistence: "native", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
123
123
|
roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
|
|
124
124
|
start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
|
|
125
125
|
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
126
126
|
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
127
127
|
permission: { grants: { ...hubGrants } },
|
|
128
128
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
|
|
129
|
-
turnEnd: "adapter", budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
129
|
+
turnEnd: "adapter", settle: { signal: "agent-settled" }, budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
130
130
|
routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
|
|
131
131
|
cli: { command: "pi", entry: { lifecycleAdmission: false, execute: runPiEntry }, preview: previewPi, usage: [["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"]], launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
|
|
135
|
-
api: 1, kind: "local", title: "hub", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
135
|
+
api: 1, kind: "local", questionWaitMs: 300000, title: "hub", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
136
136
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
|
|
137
137
|
start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
|
|
138
138
|
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
139
139
|
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
140
140
|
permission: { grants: { ...hubGrants } },
|
|
141
141
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
|
|
142
|
-
turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
142
|
+
turnEnd: "adapter", settle: { signal: "route-outcome" }, budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
143
143
|
routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
|
|
144
144
|
cli: { command: "local", entry: { lifecycleAdmission: false, execute: runLocalEntry }, usage: [["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"]], launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
145
145
|
},
|
|
146
146
|
{
|
|
147
147
|
projection: { roleRank: 6, startRank: 3, helpRank: 3, plannerEligible: true },
|
|
148
|
-
api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
|
|
148
|
+
api: 1, kind: "jcode", questionWaitMs: 45000, displayName: "Jcode", family: "jcode", transport: "acp",
|
|
149
149
|
roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
|
|
150
150
|
start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", explicitHeadlessMode: true, conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
|
|
151
151
|
// #685: an extra Jcode is a headless instance with its own native home, socket and state (instances.<id>.jcode_home).
|
|
152
152
|
identity: { mcp: "project-file" }, instances: { mode: "headless", nativeHome: true },
|
|
153
153
|
permission: { unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
|
|
154
154
|
ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
|
|
155
|
-
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode", context: { source: "jcode_usage_update", read: jcodeContextValue } },
|
|
155
|
+
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "jcode", context: { source: "jcode_usage_update", read: jcodeContextValue } },
|
|
156
156
|
// Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
|
|
157
157
|
cli: { command: "jcode", entry: { lifecycleAdmission: true, execute: runJcodeEntry }, preview: previewJcode, usage: [["ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional Jcode (roster.jcode, default off): its TUI on the hub-owned ACP session, opened once that session is saved; --mode headless for ACP only; native policy unmanaged, no hub sandbox"]], authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: mode => ["jcode", "--mode", mode] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
158
158
|
},
|
|
@@ -71,6 +71,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
|
|
|
71
71
|
},
|
|
72
72
|
},
|
|
73
73
|
turnEnd: "adapter",
|
|
74
|
+
settle: { signal: "message-stop" },
|
|
74
75
|
budget: { account: "qwen" },
|
|
75
76
|
capabilities: { watch: true },
|
|
76
77
|
cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless|tui] [--model <alias>] [--session-id <id>] [--print-command]", `start optional Qwen Code ACP (${describe("qwen")}; roster.qwen, default off); --mode tui runs Qwen's own TUI in this terminal, driven through its dual-output files (${describe("qwen-dual")}); a hub deny ends the turn without an answer and a stalled turn is stopped with Esc in the Qwen terminal`]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isReviewObserverExcluded, reviewCaptureExcluded } from "../hub/memory-policy.ts";
|
|
1
2
|
import { HubRouteRuntime, type RouteEvent, type AdvisorEvent } from "../models/route/runtime.ts";
|
|
2
3
|
import type { HubRoute } from "../models/route/config.ts";
|
|
3
4
|
import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.ts";
|
|
@@ -17,6 +18,7 @@ import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/
|
|
|
17
18
|
import { safeModelLabel } from "../omniroute/usage.ts";
|
|
18
19
|
import { effectiveLocalRoute } from "../hub/routing.ts";
|
|
19
20
|
import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
|
|
21
|
+
import type { OwnedInputContext, OwnedInputObserver } from "./claude-print-child.ts";
|
|
20
22
|
|
|
21
23
|
export interface LocalOptions {
|
|
22
24
|
profile?: import("../hub/profiles.ts").ProfileSelection;
|
|
@@ -36,6 +38,11 @@ export interface LocalOptions {
|
|
|
36
38
|
onRoute?: (event: RouteEvent) => void;
|
|
37
39
|
onAdvisor?: (event: AdvisorEvent) => void;
|
|
38
40
|
onRouteOutcome?: (event: RouteLabelEvent) => void;
|
|
41
|
+
/**
|
|
42
|
+
* #1054: the worker ended its turn at a ceiling it enforces itself, as a closed fact and never its text: `steps` (it ran out of `maxSteps`, or the review asked for more at the last
|
|
43
|
+
* step) or `budget` (an execution budget stopped it). The daemon records it as the run's `limit` evidence; a turn that reports none is `done`.
|
|
44
|
+
*/
|
|
45
|
+
onLimit?: (kind: "steps" | "budget") => void;
|
|
39
46
|
/** The daemon turn id created synchronously on busy, shared with turn_start/end. */
|
|
40
47
|
turnId?: () => string | undefined;
|
|
41
48
|
onApprovalStop?: (reason: string) => void;
|
|
@@ -43,7 +50,11 @@ export interface LocalOptions {
|
|
|
43
50
|
tools: { docRead?: DocReadPort; webFetch?: WebFetchPort; deny: string[]; permit: ToolContext["permit"]; shellExecutionCheck?: ToolContext["shellExecutionCheck"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
|
|
44
51
|
capture?: Capture;
|
|
45
52
|
/** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
|
|
46
|
-
taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
|
|
53
|
+
taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean; signal?: AbortSignal }) => Promise<string>;
|
|
54
|
+
/** Final per-turn input binding after the Bus pre-handoff hold; never a native-session authority port. */
|
|
55
|
+
beforeInput?: OwnedInputObserver;
|
|
56
|
+
/** Actual local model handoff metadata; never a budget, native-session or task-authority port. */
|
|
57
|
+
inputReleased?: OwnedInputObserver;
|
|
47
58
|
/** Successful provider responses only; usage may be absent when the gateway omits it. Never includes prompt data. */
|
|
48
59
|
onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string; task?: number }) => void;
|
|
49
60
|
/** Atomic task/run admission immediately before every model request or tool execution. */
|
|
@@ -87,6 +98,8 @@ export class LocalPeer extends BasePeer {
|
|
|
87
98
|
private title = "";
|
|
88
99
|
get sessionTitle(): string { return this.title; }
|
|
89
100
|
get titleSession(): string { return this.sessionId; }
|
|
101
|
+
/** Privacy-only identity for review-observer provenance; intentionally not PeerAdapter.nativeSession. */
|
|
102
|
+
get observerSessionId(): string { return this.sessionId; }
|
|
90
103
|
async setSessionTitle(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult> {
|
|
91
104
|
if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean") return { ok: false, reason: "invalid", error: "Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again." };
|
|
92
105
|
if (this.state === "offline") return { ok: false, reason: "invalid", error: "Local worker session is offline; start the peer and check ahub status before naming it." };
|
|
@@ -113,6 +126,8 @@ export class LocalPeer extends BasePeer {
|
|
|
113
126
|
private approvalAnswerEpoch: number | undefined;
|
|
114
127
|
private approvalStopReason = "";
|
|
115
128
|
private activeDeliveryId: string | undefined;
|
|
129
|
+
private currentObserverInputContext: OwnedInputContext | undefined;
|
|
130
|
+
private observerInputReleasedId: string | undefined;
|
|
116
131
|
private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
|
|
117
132
|
/** What served the last call, for `ahub status`. */
|
|
118
133
|
lastServedBy = "";
|
|
@@ -130,6 +145,7 @@ export class LocalPeer extends BasePeer {
|
|
|
130
145
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
131
146
|
if (signal.aborted) throw new Error("turn cancelled before model request");
|
|
132
147
|
const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : [])];
|
|
148
|
+
this.releaseObserverInput(this.currentObserverInputContext, envs);
|
|
133
149
|
const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
|
|
134
150
|
this.recordUsage(result, model, task);
|
|
135
151
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
@@ -171,6 +187,14 @@ export class LocalPeer extends BasePeer {
|
|
|
171
187
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
|
|
172
188
|
throw new Error(`${this.id} is ${this.state}`);
|
|
173
189
|
}
|
|
190
|
+
const inputContext: OwnedInputContext = Object.freeze({ sessionId: this.observerSessionId, inputId: randomUUID() });
|
|
191
|
+
try { this.opts.beforeInput?.(inputContext, envs); }
|
|
192
|
+
catch {
|
|
193
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "review input provenance unavailable" });
|
|
194
|
+
throw new Error("review input provenance unavailable");
|
|
195
|
+
}
|
|
196
|
+
this.currentObserverInputContext = inputContext;
|
|
197
|
+
this.observerInputReleasedId = undefined;
|
|
174
198
|
const turn = ++this.turn;
|
|
175
199
|
this.budgetStopReason = "";
|
|
176
200
|
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = "";
|
|
@@ -186,16 +210,17 @@ export class LocalPeer extends BasePeer {
|
|
|
186
210
|
const requestedPolicy = this.opts.turnPolicy?.(envs);
|
|
187
211
|
const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
|
|
188
212
|
const remoteTurn = envs.some(remoteEnvelope);
|
|
213
|
+
const reviewScope = { excluded: envs.some(isReviewObserverExcluded) };
|
|
189
214
|
const preserveHistory = !policy?.pii && !remoteTurn;
|
|
190
215
|
const labelTurn = this.beginRouteTurn(turn, !!policy?.pii || envs.some(env => env.private), policy?.task);
|
|
191
216
|
let completed = false;
|
|
192
217
|
// A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
|
|
193
218
|
// The task id travels with it, so the board can keep the text (`ahub task show`) while tail and log show a stub.
|
|
194
219
|
const reply: EnvelopeOpts = { inReplyTo: replyParent(envs), to: replyAudience(envs), ...(policy?.pii ? { to: [USER], private: true, priority: "important" as const, ...(policy.task ? { refs: { task: policy.task } } : {}) } : {}) };
|
|
195
|
-
this.run(envs, turn, msgs, progress, policy, reply)
|
|
220
|
+
this.run(envs, turn, msgs, progress, policy, reply, reviewScope)
|
|
196
221
|
.then((answer) => {
|
|
197
222
|
if (turn !== this.turn) return;
|
|
198
|
-
if (preserveHistory) this.commit(msgs); // Private and
|
|
223
|
+
if (preserveHistory && !reviewScope.excluded) this.commit(msgs); // Private, remote and review turns never enter later local capture through model history.
|
|
199
224
|
if (answer) this.onMessage?.(answer, reply);
|
|
200
225
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed" });
|
|
201
226
|
completed = true;
|
|
@@ -203,19 +228,19 @@ export class LocalPeer extends BasePeer {
|
|
|
203
228
|
.catch((e: Error) => {
|
|
204
229
|
if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
|
|
205
230
|
if (e instanceof ApprovalWaitStop) {
|
|
206
|
-
const message = remoteTurn ? "remote turn failed" : e.message;
|
|
231
|
+
const message = remoteTurn ? "remote turn failed" : reviewScope.excluded ? "review turn failed" : e.message;
|
|
207
232
|
this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
|
|
208
233
|
msgs.push({ role: "assistant", content: message });
|
|
209
|
-
if (preserveHistory) this.commit(msgs);
|
|
234
|
+
if (preserveHistory && !reviewScope.excluded) this.commit(msgs);
|
|
210
235
|
this.onMessage?.(message, reply);
|
|
211
236
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: message });
|
|
212
237
|
return; // even without effects, an unanswered approval must not trigger safe replay
|
|
213
238
|
}
|
|
214
239
|
if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
|
|
215
|
-
const message = remoteTurn ? "remote turn failed" : e.message;
|
|
240
|
+
const message = remoteTurn ? "remote turn failed" : reviewScope.excluded ? "review turn failed" : e.message;
|
|
216
241
|
this.opts.log?.(`[${this.id}] turn failed: ${message}`);
|
|
217
242
|
if (e instanceof ExecutionBudgetStop) {
|
|
218
|
-
this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory, deliveryId, remoteTurn);
|
|
243
|
+
this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory || reviewScope.excluded, deliveryId, remoteTurn, reviewScope.excluded);
|
|
219
244
|
return;
|
|
220
245
|
}
|
|
221
246
|
if (!progress.sideEffects) {
|
|
@@ -225,9 +250,9 @@ export class LocalPeer extends BasePeer {
|
|
|
225
250
|
}
|
|
226
251
|
// Tools already changed things. Redelivering would redo approved writes and commits, so report instead.
|
|
227
252
|
// A model call is the only thing that throws here, and every tool call before it has its result: msgs is consistent.
|
|
228
|
-
const note = remoteTurn ? message : `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
|
|
253
|
+
const note = remoteTurn || reviewScope.excluded ? message : `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
|
|
229
254
|
msgs.push({ role: "assistant", content: note });
|
|
230
|
-
if (preserveHistory) this.commit(msgs);
|
|
255
|
+
if (preserveHistory && !reviewScope.excluded) this.commit(msgs);
|
|
231
256
|
this.onMessage?.(note, reply);
|
|
232
257
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: message });
|
|
233
258
|
})
|
|
@@ -241,7 +266,7 @@ export class LocalPeer extends BasePeer {
|
|
|
241
266
|
|
|
242
267
|
protected override onWatchdog(): void {
|
|
243
268
|
this.endRouteTurn(this.labelTurnId, "failed");
|
|
244
|
-
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn watchdog timeout" });
|
|
269
|
+
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn watchdog timeout", failureCause: "timeout" });
|
|
245
270
|
this.activeDeliveryId = undefined;
|
|
246
271
|
this.turn++;
|
|
247
272
|
clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
|
|
@@ -256,6 +281,7 @@ export class LocalPeer extends BasePeer {
|
|
|
256
281
|
progress: { sideEffects: number; last: string },
|
|
257
282
|
policy: { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined,
|
|
258
283
|
reply: EnvelopeOpts,
|
|
284
|
+
reviewScope: { excluded: boolean },
|
|
259
285
|
): Promise<string> {
|
|
260
286
|
const { maxSteps = 30 } = this.opts;
|
|
261
287
|
const turnSignal = this.abort!.signal;
|
|
@@ -282,9 +308,10 @@ export class LocalPeer extends BasePeer {
|
|
|
282
308
|
const concluded = await this.conclude(msgs, policy, turn, step, maxSteps, progress.last, turnSignal, res.routeDecision);
|
|
283
309
|
if (turn !== this.turn) return "";
|
|
284
310
|
if (concluded === undefined) continue;
|
|
285
|
-
if (usedTools) capture?.summarize(concluded);
|
|
311
|
+
if (usedTools && !reviewScope.excluded) capture?.summarize(concluded);
|
|
286
312
|
return concluded;
|
|
287
313
|
}
|
|
314
|
+
if (res.message.tool_calls.some(call => call.function.name.replace(/^mcp__[^ ]+__/, "") === "hub_review" && reviewCaptureExcluded(call.function.name, call.function.arguments))) reviewScope.excluded = true;
|
|
288
315
|
const results: ChatMessage[] = [];
|
|
289
316
|
try {
|
|
290
317
|
for (const call of res.message.tool_calls) {
|
|
@@ -295,7 +322,13 @@ export class LocalPeer extends BasePeer {
|
|
|
295
322
|
await this.requireBudget(envs, "tool_calls");
|
|
296
323
|
if (turnSignal.aborted) throw new ExecutionBudgetStop(this.budgetStopReason || "turn cancelled before tool execution");
|
|
297
324
|
} catch (error) { clearInterval(alive); throw error; }
|
|
298
|
-
const
|
|
325
|
+
const taskToolName = name.replace(/^mcp__[^ ]+__/, "");
|
|
326
|
+
const reviewNoteWrite = taskToolName === "hub_remember" || taskToolName === "hub_task_propose";
|
|
327
|
+
const running = reviewScope.excluded && reviewNoteWrite
|
|
328
|
+
? Promise.resolve("error: anchored review in this batch; save notes in a later turn")
|
|
329
|
+
: (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) && this.opts.taskTool
|
|
330
|
+
? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii, signal: turnSignal }).catch((e: Error) => `error: ${e.message}`)
|
|
331
|
+
: runTool(name, call.function.arguments, ctx);
|
|
299
332
|
const output = await running.finally(() => clearInterval(alive));
|
|
300
333
|
if (turn !== this.turn) return "";
|
|
301
334
|
this.touch();
|
|
@@ -304,15 +337,16 @@ export class LocalPeer extends BasePeer {
|
|
|
304
337
|
const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
|
|
305
338
|
msgs.push(result); results.push(result);
|
|
306
339
|
if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
|
|
307
|
-
if (!remoteTurn && name !== "hub_set_title") this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
340
|
+
if (!remoteTurn && !reviewScope.excluded && name !== "hub_set_title" && name !== "hub_ask" && !reviewCaptureExcluded(name, call.function.arguments)) this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
308
341
|
// Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
|
|
309
342
|
// doc_read text is converted document content (a converter can surface hidden text), never memory evidence: not captured, whatever the turn.
|
|
310
343
|
// web_fetch text is a third party's page and its URL is the model's own request: neither is memory evidence either (#515b).
|
|
311
|
-
if (name !== "hub_send" && name !== "doc_read" && name !== "web_fetch" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
344
|
+
if (!reviewScope.excluded && name !== "hub_send" && name !== "doc_read" && name !== "web_fetch" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
312
345
|
}
|
|
313
346
|
} finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
|
|
314
347
|
}
|
|
315
|
-
if (usedTools) capture?.summarize(progress.last);
|
|
348
|
+
if (usedTools && !reviewScope.excluded) capture?.summarize(progress.last);
|
|
349
|
+
this.opts.onLimit?.("steps");
|
|
316
350
|
return `(stopped after ${maxSteps} steps) ${progress.last}`.trim();
|
|
317
351
|
}
|
|
318
352
|
|
|
@@ -369,10 +403,11 @@ export class LocalPeer extends BasePeer {
|
|
|
369
403
|
};
|
|
370
404
|
}
|
|
371
405
|
|
|
372
|
-
private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, privateHistory: boolean, deliveryId?: string, remoteTurn = false): void {
|
|
373
|
-
|
|
406
|
+
private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, privateHistory: boolean, deliveryId?: string, remoteTurn = false, reviewTurn = false): void {
|
|
407
|
+
this.opts.onLimit?.("budget");
|
|
408
|
+
const message = remoteTurn ? "remote turn failed" : reviewTurn ? "review turn failed" : e.message;
|
|
374
409
|
this.completeMissingToolResults(msgs, `not run: ${message}`);
|
|
375
|
-
const detail = remoteTurn ? message : progress.sideEffects
|
|
410
|
+
const detail = remoteTurn || reviewTurn ? message : progress.sideEffects
|
|
376
411
|
? `(execution budget stopped after ${progress.sideEffects} tool call(s) with side effects; partial work may exist and needs review) ${progress.last}`.trim()
|
|
377
412
|
: `(execution budget stopped before any tool side effects; no work was repeated) ${progress.last}`.trim();
|
|
378
413
|
msgs.push({ role: "assistant", content: detail });
|
|
@@ -423,12 +458,19 @@ export class LocalPeer extends BasePeer {
|
|
|
423
458
|
const routed = await this.hubCall(route, messages, policy, signal);
|
|
424
459
|
if (routed) return routed;
|
|
425
460
|
await this.requireBudget(envs, "model_calls");
|
|
461
|
+
this.releaseObserverInput(this.currentObserverInputContext, envs);
|
|
426
462
|
const res = await omni.chat({ model: fixedModel, messages, tools }, { signal, ...(policy?.pii ? { onCampusOnly: true } : {}) });
|
|
427
463
|
this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
|
|
428
464
|
this.recordUsage(res, fixedModel, task);
|
|
429
465
|
return res;
|
|
430
466
|
}
|
|
431
467
|
|
|
468
|
+
private releaseObserverInput(context: OwnedInputContext | undefined, envs: Envelope[]): void {
|
|
469
|
+
if (!context || this.currentObserverInputContext !== context || this.observerInputReleasedId === context.inputId) return;
|
|
470
|
+
this.observerInputReleasedId = context.inputId;
|
|
471
|
+
try { this.opts.inputReleased?.(context, envs); } catch { /* observer metadata never changes the local model result */ }
|
|
472
|
+
}
|
|
473
|
+
|
|
432
474
|
private beginRouteTurn(generation: number, pii: boolean, task?: string): string {
|
|
433
475
|
let id: string | undefined;
|
|
434
476
|
try { id = this.opts.turnId?.(); } catch { /* optional host metadata */ }
|
|
@@ -473,6 +515,7 @@ export class LocalPeer extends BasePeer {
|
|
|
473
515
|
const feedback = await this.routes.review(route, config, messages, this.routeScope(!!policy?.pii, turn), !!policy?.pii, signal, decisionId);
|
|
474
516
|
if (turn !== this.turn || !feedback) return answer;
|
|
475
517
|
msgs.push({ role: "user", content: feedback });
|
|
518
|
+
if (step + 1 >= maxSteps) this.opts.onLimit?.("steps");
|
|
476
519
|
return step + 1 < maxSteps ? undefined : `(review requested changes; step limit reached) ${answer}`;
|
|
477
520
|
}
|
|
478
521
|
|
package/src/adapters/pi.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
|
|
|
5
5
|
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
6
6
|
import { renderDigest, remoteEnvelope, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
7
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; }
|
|
@@ -329,6 +331,35 @@ export class PiPeer extends BasePeer {
|
|
|
329
331
|
this.starting = this.startImpl();
|
|
330
332
|
try { await this.starting; } finally { this.starting = undefined; }
|
|
331
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
|
+
|
|
332
363
|
private async handleToolRequest(body: any): Promise<Response> {
|
|
333
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 });
|
|
334
365
|
if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
|
|
@@ -494,7 +525,18 @@ export class PiPeer extends BasePeer {
|
|
|
494
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")));
|
|
495
526
|
return Response.json({ ok: true });
|
|
496
527
|
}
|
|
497
|
-
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
|
+
}
|
|
498
540
|
if (url.pathname === "/budget") return this.handleBudgetRequest(body);
|
|
499
541
|
return new Response("not found", { status: 404 });
|
|
500
542
|
} });
|
|
@@ -621,12 +663,12 @@ export class PiPeer extends BasePeer {
|
|
|
621
663
|
if (!snapshot.idle) this.clearWatchdog();
|
|
622
664
|
}
|
|
623
665
|
|
|
624
|
-
async stop(reason?: string): Promise<void> {
|
|
666
|
+
async stop(reason?: string, failureCause?: DeliveryFailureCause): Promise<void> {
|
|
625
667
|
if (reason !== undefined) this.requestedStopReason = reason;
|
|
626
668
|
this.rosterEpoch++;
|
|
627
669
|
this.terminalOwnerObservation = undefined;
|
|
628
670
|
this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
|
|
629
|
-
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 } : {}) });
|
|
630
672
|
this.activeDeliveryIds.clear();
|
|
631
673
|
const settling = this.abortTools();
|
|
632
674
|
let unsettled: unknown;
|
|
@@ -1047,11 +1089,11 @@ export class PiPeer extends BasePeer {
|
|
|
1047
1089
|
await this.terminateFailedTurn(new ApprovalWaitStop(reason));
|
|
1048
1090
|
}
|
|
1049
1091
|
|
|
1050
|
-
private async terminateFailedTurn(error: Error): Promise<void> {
|
|
1092
|
+
private async terminateFailedTurn(error: Error, failureCause?: DeliveryFailureCause): Promise<void> {
|
|
1051
1093
|
const envs = this.activeEnvs.slice(), reply = this.currentReply;
|
|
1052
1094
|
this.stopping = true;
|
|
1053
1095
|
try {
|
|
1054
|
-
await this.stop();
|
|
1096
|
+
await this.stop(undefined, failureCause);
|
|
1055
1097
|
// The process exit handler may already have reported this failure.
|
|
1056
1098
|
if (this.activeEnvs.length) this.fail(error);
|
|
1057
1099
|
} catch (stopError) {
|
|
@@ -1069,7 +1111,7 @@ export class PiPeer extends BasePeer {
|
|
|
1069
1111
|
protected override onWatchdog(): void {
|
|
1070
1112
|
if (this.stopping || this.rosterDetached || this.rosterUnownedRun) return;
|
|
1071
1113
|
if (this.activeTools) { this.touch(); return; }
|
|
1072
|
-
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");
|
|
1073
1115
|
}
|
|
1074
1116
|
}
|
|
1075
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;
|
|
@@ -208,6 +212,14 @@ export interface AdapterDefinition {
|
|
|
208
212
|
title?: "native" | "hub";
|
|
209
213
|
/** Fixed builtins only: the title slot survives native-session restoration. Absent means ephemeral. */
|
|
210
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 };
|
|
211
223
|
/** External module data-only opt-in. The verified loader derives the internal instances policy from it (#754). */
|
|
212
224
|
headlessInstances?: { form: "headless"; roles: readonly ("implementer" | "verifier" | "reviewer")[] };
|
|
213
225
|
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|