@staix/agent-hub 0.19.0 → 0.21.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 +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +46 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +7 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
package/src/adapters/acp.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { activityText, updateActivity, type Activity, type ActivityKind } from "../hub/activity.ts";
|
|
2
2
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { renderDigest, replyAudience, replyParent, sanitize, type Envelope, type PeerId, type PeerState } from "../hub/envelope.ts";
|
|
5
6
|
import { acpQuotaRefusal, type ACPQuotaRefusal } from "./acp-quota.ts";
|
|
6
7
|
import type { PermissionMode } from "../hub/permission-mode.ts";
|
|
@@ -10,6 +11,7 @@ export { QwenIdentityError } from "./definitions/qwen-native.ts";
|
|
|
10
11
|
import { BasePeer } from "../hub/peers.ts";
|
|
11
12
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
12
13
|
import { AcpOwnedGroup } from "./acp-owned-group.ts";
|
|
14
|
+
import type { StartForm } from "./registry-types.ts";
|
|
13
15
|
|
|
14
16
|
export interface PermissionOption {
|
|
15
17
|
optionId: string;
|
|
@@ -48,6 +50,23 @@ export interface ACPUsageDiagnostic {
|
|
|
48
50
|
total?: number;
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* One prompt's own token counters from its result's `usage` (Jcode 0.99.1: `inputTokens`, `outputTokens`, optional `cachedReadTokens` and `cachedWriteTokens`; a result with several provider
|
|
55
|
+
* calls carries their sum). Per turn, not a session total, and the provider's `totalTokens` is deliberately not read: Jcode adds the cached read on top of an input that already contains it for
|
|
56
|
+
* OpenAI-style providers, so that total double counts. Input and output must both be non-negative safe integers, else the whole record is unknown; an optional bucket that is present but
|
|
57
|
+
* malformed is dropped alone (unknown, never zero). Counters only: nothing else of the payload escapes.
|
|
58
|
+
*/
|
|
59
|
+
export interface ACPTurnUsage { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number }
|
|
60
|
+
export function acpTurnUsage(result: unknown): ACPTurnUsage | undefined {
|
|
61
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return undefined;
|
|
62
|
+
const usage = (result as Record<string, unknown>).usage;
|
|
63
|
+
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return undefined;
|
|
64
|
+
const u = usage as Record<string, unknown>;
|
|
65
|
+
const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
66
|
+
if (!count(u.inputTokens) || !count(u.outputTokens)) return undefined;
|
|
67
|
+
return { inputTokens: u.inputTokens, outputTokens: u.outputTokens, ...(count(u.cachedReadTokens) ? { cacheReadTokens: u.cachedReadTokens } : {}), ...(count(u.cachedWriteTokens) ? { cacheWriteTokens: u.cachedWriteTokens } : {}) };
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
/** Counter-only projection: no source payload, text, tools or metadata escapes. */
|
|
52
71
|
export function normalizeACPUsage(value: unknown, source: ACPUsageDiagnostic["source"] = "usage_update"): ACPUsageDiagnostic {
|
|
53
72
|
const unknown: ACPUsageDiagnostic = { source, availability: "unsupported", shape: "none" };
|
|
@@ -65,7 +84,8 @@ export function normalizeACPUsage(value: unknown, source: ACPUsageDiagnostic["so
|
|
|
65
84
|
const a = f[input!], b = f[output!];
|
|
66
85
|
return valid(a) && valid(b) && Number.isFinite(a + b) ? { source, availability: "known", shape: "input-output", total: a + b } : { source, availability: "invalid", shape: "input-output" };
|
|
67
86
|
}
|
|
68
|
-
|
|
87
|
+
// Either half of the pair names an occupancy update: one with `size` but no `used` (or the reverse) is a malformed reading, which must invalidate the previous value, not vanish as unrelated.
|
|
88
|
+
if (f.used !== undefined || f.size !== undefined) {
|
|
69
89
|
if (!valid(f.used) || !valid(f.size) || !Number.isSafeInteger(f.used) || !Number.isSafeInteger(f.size) || f.size <= 0) return { source, availability: "invalid", shape: "context-used" };
|
|
70
90
|
// Qwen 0.24.7 derives this from collectContextData: occupancy is not cumulative consumption.
|
|
71
91
|
return { source, availability: "unsupported", shape: "context-used", contextUsed: f.used, contextCapacity: f.size };
|
|
@@ -109,6 +129,8 @@ export interface AcpOptions {
|
|
|
109
129
|
preamble?: string;
|
|
110
130
|
/** The session's running token total from `usage_update` (checked totalTokens or input/output pair; `used` context occupancy is diagnostic only). Cumulative, not a delta. */
|
|
111
131
|
onTokens?: (sessionTotal: number, sessionId: string) => void;
|
|
132
|
+
/** One finished prompt's own counters (see `acpTurnUsage`), once per prompt that reported them; never a session total, so it never feeds `onTokens`. The sessionId is the adapter's own. */
|
|
133
|
+
onTurnUsage?: (usage: ACPTurnUsage, sessionId: string) => void;
|
|
112
134
|
/** The prompt rejected or ended without normal completion, even when it streamed partial text.
|
|
113
135
|
* Same shape as Pi's onTurnFailure; a stale cancelled turn never reports. */
|
|
114
136
|
onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void> | void;
|
|
@@ -137,6 +159,45 @@ const HANDSHAKE_MS = 30_000;
|
|
|
137
159
|
const TOOL_INPUT_CAP = 64;
|
|
138
160
|
const TOOL_CEILING_MS = 30 * 60_000;
|
|
139
161
|
|
|
162
|
+
export interface ManagedAcpLaunch {
|
|
163
|
+
readonly generation: string;
|
|
164
|
+
readonly cwd: string;
|
|
165
|
+
readonly sessionId: string;
|
|
166
|
+
}
|
|
167
|
+
interface ManagedAcpLaunchRecord {
|
|
168
|
+
readonly id: PeerId;
|
|
169
|
+
readonly options: AcpOptions;
|
|
170
|
+
readonly proc: ChildProcessWithoutNullStreams;
|
|
171
|
+
readonly proof: AcpOwnedGroup;
|
|
172
|
+
readonly receipt: Readonly<ManagedAcpLaunch>;
|
|
173
|
+
}
|
|
174
|
+
// Host evidence is deliberately outside runtime properties: copied metadata, private-field names
|
|
175
|
+
// and an overridden start/recovery getter cannot manufacture a verified managed spawn.
|
|
176
|
+
const managedLaunches = new WeakMap<object, ManagedAcpLaunchRecord>();
|
|
177
|
+
function currentManagedLaunch(peer: unknown, expectedOptions: AcpOptions): ManagedAcpLaunchRecord | undefined {
|
|
178
|
+
if (!peer || (typeof peer !== "object" && typeof peer !== "function")) return undefined;
|
|
179
|
+
const record = managedLaunches.get(peer);
|
|
180
|
+
if (!record) return undefined;
|
|
181
|
+
const id = Object.getOwnPropertyDescriptor(peer, "id");
|
|
182
|
+
if (id?.value !== record.id || record.options !== expectedOptions || record.proc.exitCode !== null || record.proc.signalCode !== null) return undefined;
|
|
183
|
+
return record;
|
|
184
|
+
}
|
|
185
|
+
/** Exact host options identity is required; a factory that copies/replaces those options has no host binding. */
|
|
186
|
+
export function managedAcpLaunch(peer: unknown, expectedOptions: AcpOptions): Readonly<ManagedAcpLaunch> | undefined {
|
|
187
|
+
return currentManagedLaunch(peer, expectedOptions)?.receipt;
|
|
188
|
+
}
|
|
189
|
+
/** Recheck the actual captured owned group after an await, then require the same current launch. */
|
|
190
|
+
export async function verifyManagedAcpLaunch(peer: unknown, expectedOptions: AcpOptions): Promise<boolean> {
|
|
191
|
+
const record = currentManagedLaunch(peer, expectedOptions);
|
|
192
|
+
if (!record) return false;
|
|
193
|
+
try { await record.proof.verify(); }
|
|
194
|
+
catch {
|
|
195
|
+
if (peer && typeof peer === "object" && managedLaunches.get(peer) === record) managedLaunches.delete(peer);
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
return currentManagedLaunch(peer, expectedOptions) === record;
|
|
199
|
+
}
|
|
200
|
+
|
|
140
201
|
/**
|
|
141
202
|
* The canonical tool binding (#138): an announced `<tool> (<server> MCP Server)` title resolves to
|
|
142
203
|
* `mcp__<server>__<tool>` only when <server> is one this session was configured with. A title that
|
|
@@ -163,6 +224,9 @@ function jsonObject(text: string | undefined): object | undefined {
|
|
|
163
224
|
|
|
164
225
|
/** ACP client (JSON-RPC 2.0, newline-delimited, over the child's stdio). One session, one prompt in flight. */
|
|
165
226
|
export class AcpPeer extends BasePeer {
|
|
227
|
+
readonly deliveryOnly: boolean = true;
|
|
228
|
+
readonly hubOwned: boolean = true;
|
|
229
|
+
readonly form: StartForm = "headless";
|
|
166
230
|
private permissionMode: PermissionMode;
|
|
167
231
|
private readonly nativePolicy: AcpOptions["nativePolicy"];
|
|
168
232
|
private modeUnknown = false;
|
|
@@ -235,7 +299,7 @@ export class AcpPeer extends BasePeer {
|
|
|
235
299
|
private readonly permissionWaits = new Set<AbortController>();
|
|
236
300
|
|
|
237
301
|
protected override setState(next: PeerState): void {
|
|
238
|
-
if (next === "offline") { this.clearActivity(); this.cancelPermissions(); }
|
|
302
|
+
if (next === "offline") { managedLaunches.delete(this); this.clearActivity(); this.cancelPermissions(); }
|
|
239
303
|
super.setState(next);
|
|
240
304
|
}
|
|
241
305
|
|
|
@@ -256,13 +320,20 @@ export class AcpPeer extends BasePeer {
|
|
|
256
320
|
recoveryMetadata(): Record<string, unknown> {
|
|
257
321
|
return { launch: { kind: "acp", ...(this.opts.launchModel ? { model: this.opts.launchModel } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}) };
|
|
258
322
|
}
|
|
323
|
+
get nativeSession(): string | undefined {
|
|
324
|
+
const session = this.recoveryMetadata().sessionId;
|
|
325
|
+
return typeof session === "string" ? session : undefined;
|
|
326
|
+
}
|
|
259
327
|
|
|
260
328
|
async start(): Promise<void> {
|
|
329
|
+
managedLaunches.delete(this);
|
|
261
330
|
if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
|
|
262
331
|
this.stopping = false;
|
|
332
|
+
const launchedId = this.id;
|
|
333
|
+
const launchedCwd = this.opts.cwd;
|
|
263
334
|
const [bin, ...args] = this.opts.cmd;
|
|
264
335
|
// Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
|
|
265
|
-
const proc = spawn(bin!, args, { cwd:
|
|
336
|
+
const proc = spawn(bin!, args, { cwd: launchedCwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
266
337
|
this.proc = proc;
|
|
267
338
|
trackGroup(proc);
|
|
268
339
|
proc.on("error", (e) => { if (!this.opts.requireOwnedGroup || this.proc === proc) this.down(`spawn failed: ${e.message}`); });
|
|
@@ -302,11 +373,13 @@ export class AcpPeer extends BasePeer {
|
|
|
302
373
|
setTimeout(() => reject(new Error(`${this.id} did not complete the ACP handshake within ${HANDSHAKE_MS / 1000} s`)), HANDSHAKE_MS).unref();
|
|
303
374
|
});
|
|
304
375
|
const session = await Promise.race([handshake(), timeout]);
|
|
376
|
+
const nativeSession = session?.sessionId;
|
|
305
377
|
if (this.nativePolicy !== undefined && (typeof session?.sessionId !== "string" || !session.sessionId.trim()
|
|
306
378
|
|| Buffer.byteLength(session.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(session.sessionId))) {
|
|
307
379
|
throw new Error("Configured ACP did not provide a valid native session identity");
|
|
308
380
|
}
|
|
309
381
|
this.sessionId = session.sessionId;
|
|
382
|
+
this.onDoing?.("reset");
|
|
310
383
|
const modes: unknown = session.modes?.availableModes;
|
|
311
384
|
this.availableModes = new Set(Array.isArray(modes) ? modes.flatMap((mode) => typeof mode?.id === "string" ? [mode.id] : []) : []);
|
|
312
385
|
// A recovered session can retain a previous runtime opt-in. An advertised non-default mode
|
|
@@ -320,8 +393,13 @@ export class AcpPeer extends BasePeer {
|
|
|
320
393
|
if (!owned || owned.proc !== proc || this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
|
|
321
394
|
await owned.proof.verify();
|
|
322
395
|
if (this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
|
|
396
|
+
if (typeof nativeSession !== "string" || !nativeSession.trim() || Buffer.byteLength(nativeSession) > 4096 || /[\p{Cc}\p{Cf}]/u.test(nativeSession)) throw new Error("Configured ACP did not provide a valid native session identity");
|
|
397
|
+
if (this.id !== launchedId || this.opts.cwd !== launchedCwd || this.sessionId !== nativeSession) throw new Error("Configured ACP peer identity changed during startup");
|
|
398
|
+
managedLaunches.set(this, { id: launchedId, options: this.opts, proc, proof: owned.proof,
|
|
399
|
+
receipt: Object.freeze({ generation: randomUUID(), cwd: launchedCwd, sessionId: nativeSession }) });
|
|
323
400
|
}
|
|
324
401
|
} catch (e) {
|
|
402
|
+
managedLaunches.delete(this);
|
|
325
403
|
this.setState("offline");
|
|
326
404
|
if (this.proc === proc) {
|
|
327
405
|
if (this.opts.requireOwnedGroup) {
|
|
@@ -382,6 +460,7 @@ export class AcpPeer extends BasePeer {
|
|
|
382
460
|
}
|
|
383
461
|
|
|
384
462
|
async stop(reason?: string): Promise<void> {
|
|
463
|
+
managedLaunches.delete(this);
|
|
385
464
|
if (this.activityOpen) this.endActivity("stopped", true);
|
|
386
465
|
this.stopping = true;
|
|
387
466
|
this.cancelPermissions();
|
|
@@ -445,6 +524,8 @@ export class AcpPeer extends BasePeer {
|
|
|
445
524
|
if (this.activeLegacy?.turn === turn) this.activeLegacy = undefined;
|
|
446
525
|
this.endActivity(typeof result?.stopReason === "string" ? result.stopReason : "unknown", result?.stopReason === "cancelled");
|
|
447
526
|
this.observeUsage(result, "prompt_result");
|
|
527
|
+
const turnUsage = acpTurnUsage(result);
|
|
528
|
+
if (turnUsage) try { this.opts.onTurnUsage?.(turnUsage, this.sessionId); } catch { /* a counter sink never costs a turn */ }
|
|
448
529
|
this.primed = true;
|
|
449
530
|
const body = this.chunks.join("").trim();
|
|
450
531
|
// What was steered into the turn is answered by it too: its senders hear back, and the hop cap still holds.
|
|
@@ -589,6 +670,7 @@ export class AcpPeer extends BasePeer {
|
|
|
589
670
|
}
|
|
590
671
|
|
|
591
672
|
private down(reason: string): void {
|
|
673
|
+
managedLaunches.delete(this);
|
|
592
674
|
this.clearCancelWait();
|
|
593
675
|
if (this.nativePolicy !== undefined || this.opts.requireOwnedGroup) reason = "Configured ACP transport stopped";
|
|
594
676
|
this.opts.log?.(`[${this.id}] ${reason}`);
|
|
@@ -649,6 +731,8 @@ export class AcpPeer extends BasePeer {
|
|
|
649
731
|
for (const map of [this.toolInputs, this.toolText, this.toolTitles]) while (map.size > TOOL_INPUT_CAP) map.delete(map.keys().next().value as string);
|
|
650
732
|
}
|
|
651
733
|
else if (u?.sessionUpdate === "usage_update") this.observeUsage(u, "usage_update");
|
|
734
|
+
// #878: the plan's in-progress entry, for the hub's DOING column only.
|
|
735
|
+
else if (u?.sessionUpdate === "plan") this.onDoing?.("todo", Array.isArray(u.entries) ? u.entries.find((e: any) => e?.status === "in_progress")?.content : undefined);
|
|
652
736
|
} else if (msg.method === "session/request_permission") {
|
|
653
737
|
if (this.cancelWait || this.state === "offline") { this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }); return; }
|
|
654
738
|
if (this.state === "busy" && (!msg.params?.sessionId || msg.params.sessionId === this.sessionId)) this.promptActivity = true;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BasePeer, type PeerAdapter } from "../hub/peers.ts";
|
|
2
2
|
import type { Envelope } from "../hub/envelope.ts";
|
|
3
3
|
import { definitionOf } from "./registry.ts";
|
|
4
|
+
import type { StartForm } from "./registry-types.ts";
|
|
4
5
|
|
|
5
6
|
/** Only the control socket operations the peer owns; authentication remains in the daemon. */
|
|
6
7
|
export interface ChannelSocket {
|
|
@@ -12,6 +13,7 @@ export interface ChannelSocket {
|
|
|
12
13
|
|
|
13
14
|
/** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
|
|
14
15
|
export class WsPeer extends BasePeer {
|
|
16
|
+
readonly form: StartForm = "tui";
|
|
15
17
|
private generation = crypto.randomUUID();
|
|
16
18
|
// Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
|
|
17
19
|
private claimGeneration = crypto.randomUUID();
|
|
@@ -23,7 +25,7 @@ export class WsPeer extends BasePeer {
|
|
|
23
25
|
private delivered = new Set<string>();
|
|
24
26
|
private sock: ChannelSocket | undefined;
|
|
25
27
|
private claimed: ChannelSocket | undefined;
|
|
26
|
-
private
|
|
28
|
+
private nativeTurnSession: string | undefined;
|
|
27
29
|
private nativeActive = false;
|
|
28
30
|
/** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
|
|
29
31
|
pullOnly = false;
|
|
@@ -46,6 +48,10 @@ export class WsPeer extends BasePeer {
|
|
|
46
48
|
this.claimed = sock;
|
|
47
49
|
this.claimedPullOnly = pullOnly;
|
|
48
50
|
}
|
|
51
|
+
/** Host transport observation, distinct from the peer's activity state. */
|
|
52
|
+
get attached(): boolean { return !!this.sock && this.sock.readyState === WebSocket.OPEN && !this.claiming; }
|
|
53
|
+
get managedLaunchId(): string | undefined { return this.attached ? this.sock?.data.launchId : undefined; }
|
|
54
|
+
|
|
49
55
|
/** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
|
|
50
56
|
get channelHeld(): boolean {
|
|
51
57
|
return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
|
|
@@ -66,7 +72,7 @@ export class WsPeer extends BasePeer {
|
|
|
66
72
|
this.sock = sock;
|
|
67
73
|
this.generation = crypto.randomUUID();
|
|
68
74
|
this.delivered.clear();
|
|
69
|
-
this.
|
|
75
|
+
this.nativeTurnSession = undefined;
|
|
70
76
|
this.nativeActive = false;
|
|
71
77
|
this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
|
|
72
78
|
// Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
|
|
@@ -112,13 +118,13 @@ export class WsPeer extends BasePeer {
|
|
|
112
118
|
owns(sock: ChannelSocket): boolean { return this.sock === sock; }
|
|
113
119
|
nativeStarted(session: string): void {
|
|
114
120
|
if (!this.sock) return;
|
|
115
|
-
this.
|
|
121
|
+
this.nativeTurnSession = session;
|
|
116
122
|
this.nativeActive = true;
|
|
117
123
|
this.setState("busy");
|
|
118
124
|
}
|
|
119
125
|
nativeStopped(session: string): boolean {
|
|
120
|
-
if (!this.sock || (this.
|
|
121
|
-
this.
|
|
126
|
+
if (!this.sock || (this.nativeTurnSession !== undefined && this.nativeTurnSession !== session)) return false;
|
|
127
|
+
this.nativeTurnSession = session;
|
|
122
128
|
this.nativeActive = false;
|
|
123
129
|
this.setState("idle");
|
|
124
130
|
return true;
|
|
@@ -17,6 +17,7 @@ import { VERSION } from "../version.ts";
|
|
|
17
17
|
import { IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
|
|
18
18
|
import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
|
|
19
19
|
import { renderHeldHint } from "../hub/delivery-hints.ts";
|
|
20
|
+
import { acceptsNativePermissionRequest, buildNativePermissionProbe, NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH } from "../hub/native-permission-request.ts";
|
|
20
21
|
|
|
21
22
|
// A native session is pinned at launch. Unlike a new CLI invocation after `cd`,
|
|
22
23
|
// its MCP server must not silently move to another hub (including while offline).
|
|
@@ -26,6 +27,7 @@ const projectRoot = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
|
|
|
26
27
|
/** tools mode: the same server, run by Kimi (ACP mcpServers), Codex (mcp_servers override) or Jcode. Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
|
|
27
28
|
const toolsMode = process.env.AGENTHUB_MODE === "tools";
|
|
28
29
|
const permissionTool = toolsMode && process.env.AGENTHUB_PERMISSION_TOOL === "1";
|
|
30
|
+
let invalidPermissionAttempts = 0;
|
|
29
31
|
/**
|
|
30
32
|
* #368: a tools-mode server acts as exactly the peer whose owned launch started it, and the project's Jcode MCP entry names none, so the
|
|
31
33
|
* id comes from the owned process env alone. With none (a person's own `jcode` run in this project outside the hub) it connects as nobody,
|
|
@@ -212,7 +214,7 @@ async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T>
|
|
|
212
214
|
async function push(envs: Envelope[], deliveryId?: string, generation?: string): Promise<void> {
|
|
213
215
|
const parent = replyParent(envs); // reply_to on this id keeps the hop count honest
|
|
214
216
|
const single = envs.length === 1;
|
|
215
|
-
const content = single ? parent.body : envs.map((e) => `--- from ${e.from} (id ${e.id}, kind ${e.kind}) ---\n${sanitize(e.body)}`).join("\n\n");
|
|
217
|
+
const content = single ? sanitize(parent.body) : envs.map((e) => `--- from ${e.from} (id ${e.id}, kind ${e.kind}) ---\n${sanitize(e.body)}`).join("\n\n");
|
|
216
218
|
const meta = {
|
|
217
219
|
source: single ? parent.from : "hub-digest",
|
|
218
220
|
...(single ? {} : { sources: [...new Set(envs.map((e) => e.from))].join(",") }),
|
|
@@ -396,16 +398,21 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
396
398
|
if (name === "hub_permission_request") {
|
|
397
399
|
const deny = (message: string) => text(JSON.stringify({ behavior: "deny", message }));
|
|
398
400
|
if (!permissionTool || !hub) return deny("hub permission unavailable");
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
401
|
+
if (!acceptsNativePermissionRequest(args)) {
|
|
402
|
+
// Diagnostic validation only: its response can never authorize this invalid native request.
|
|
403
|
+
if (++invalidPermissionAttempts <= NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH) {
|
|
404
|
+
const probe = buildNativePermissionProbe(args);
|
|
405
|
+
if (probe) try { await hub.request(probe, 1000); } catch { /* Old or unavailable hubs still deny locally. */ }
|
|
406
|
+
}
|
|
407
|
+
return deny("invalid native permission request");
|
|
408
|
+
}
|
|
402
409
|
try {
|
|
403
410
|
// Only this fixed client capability permits host wrapper grants; older clients keep person approval.
|
|
404
411
|
const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input, wrapperProtocol: 1 }, 3_605_000);
|
|
405
412
|
if (res.ok !== true || res.behavior !== "allow") return deny(typeof res.message === "string" ? res.message : "permission denied");
|
|
406
413
|
if (res.wrapperProtocol !== undefined && res.wrapperProtocol !== 1) return deny("unsupported host execution response");
|
|
407
414
|
const updated = res.wrapperProtocol === 1 ? res.updatedInput : undefined;
|
|
408
|
-
if (res.wrapperProtocol === 1 && (args
|
|
415
|
+
if (res.wrapperProtocol === 1 && (args?.tool_name !== "Bash" || !updated || typeof updated !== "object" || Array.isArray(updated) || typeof updated.command !== "string")) return deny("invalid host execution response");
|
|
409
416
|
return text(JSON.stringify({ behavior: "allow", updatedInput: updated ?? args?.input }));
|
|
410
417
|
} catch { return deny("hub permission unavailable"); }
|
|
411
418
|
}
|
|
@@ -458,7 +465,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
458
465
|
}
|
|
459
466
|
|
|
460
467
|
// Inbox results still show fresh counts, without telling a held or refused reader to retry in a loop.
|
|
461
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => withPendingHint(await callTool(req), req.params.name !== "hub_inbox"));
|
|
468
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => withPendingHint(await callTool(req), req.params.name !== "hub_inbox" && req.params.name !== "hub_source_read"));
|
|
462
469
|
|
|
463
470
|
await server.connect(new StdioServerTransport());
|
|
464
471
|
// The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
|
|
@@ -11,9 +11,21 @@ import { sessionSettings } from "../cli/launch.ts";
|
|
|
11
11
|
import { assessVersion, COMPAT } from "./compat.ts";
|
|
12
12
|
import { OwnedPrintChild, PrintChildCleanupError, type OwnedPrintChildOptions } from "./claude-print-child.ts";
|
|
13
13
|
import { ClaudeTaskSessions } from "./claude-task-sessions.ts";
|
|
14
|
+
import { formatClaudeFailure, type ClaudeFailureTrace } from "../hub/claude-failure-stage.ts";
|
|
14
15
|
import type { PermissionMode } from "../hub/permission-mode.ts";
|
|
15
16
|
import type { ReviewerPrintUsage } from "./reviewer-print.ts";
|
|
16
17
|
|
|
18
|
+
const provenVersions = new WeakMap<() => boolean, string>();
|
|
19
|
+
function provenVersion(binding: () => boolean): string | undefined { return provenVersions.get(binding); }
|
|
20
|
+
function sealFailureTrace(trace: ClaudeFailureTrace | undefined): void { try { trace?.seal(); } catch { /* diagnostics never affect owned cleanup */ } }
|
|
21
|
+
function discardFailureTrace(trace: ClaudeFailureTrace | undefined): void { try { trace?.discard(); } catch { /* diagnostics never affect owned cleanup */ } }
|
|
22
|
+
function readFailureTrace(trace: ClaudeFailureTrace | undefined): unknown {
|
|
23
|
+
if (!trace) return undefined;
|
|
24
|
+
// Every returned trace is sealed by onLeaderExit before authority is revoked.
|
|
25
|
+
try { return trace.take(); }
|
|
26
|
+
catch { discardFailureTrace(trace); return undefined; }
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
export interface ClaudeImplementerOptions {
|
|
18
30
|
cwd: string;
|
|
19
31
|
stateDir: string;
|
|
@@ -38,6 +50,12 @@ export interface ClaudeImplementerOptions {
|
|
|
38
50
|
notify?(message: string): void;
|
|
39
51
|
/** Test/embedding-only proof port; project configuration cannot select it. */
|
|
40
52
|
prove?(): Promise<() => boolean>;
|
|
53
|
+
/** Host-observed failure evidence bound to one admitted native launch. */
|
|
54
|
+
failureTrace?(launch: Readonly<OwnedToolsLaunch>, nativeVersion?: string): ClaudeFailureTrace;
|
|
55
|
+
/** Resolve current privacy at final rendering; unknown or a throw is private. */
|
|
56
|
+
failurePrivate?(task?: number): boolean;
|
|
57
|
+
/** Optional host observer of the fixed, already privacy-screened failure message. */
|
|
58
|
+
failureLog?(message: string): void;
|
|
41
59
|
}
|
|
42
60
|
export interface ImplementerTurn { sessionId: string; resume: boolean; persistent: boolean }
|
|
43
61
|
export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string, model?: string): string[] {
|
|
@@ -65,13 +83,16 @@ export async function proveClaudeImplementer(peer: string, command: readonly str
|
|
|
65
83
|
const observed = COMPAT["claude-implementer"].parse(version), verdict = assessVersion("claude-implementer", observed);
|
|
66
84
|
if (!verdict.ok) throw new Error(verdict.reason);
|
|
67
85
|
if (fingerprint() !== binding) throw new Error("Claude implementer binary changed during proof");
|
|
68
|
-
|
|
86
|
+
const proof = () => { try { return fingerprint() === binding; } catch { return false; } };
|
|
87
|
+
provenVersions.set(proof, verdict.version);
|
|
88
|
+
return proof;
|
|
69
89
|
} finally { clearTimeout(timer); await child.stop(); rmSync(home, { recursive: true, force: true }); }
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
/** One owned print child at a time. Idle holds no native process; task continuity is ids-only on disk. */
|
|
73
93
|
export class ClaudeImplementerPeer extends BasePeer {
|
|
74
94
|
readonly hubNative = true;
|
|
95
|
+
readonly hubOwned: boolean = true;
|
|
75
96
|
private generation = 0;
|
|
76
97
|
private owned: OwnedPrintChild | undefined;
|
|
77
98
|
private running: Promise<void> | undefined;
|
|
@@ -83,6 +104,7 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
83
104
|
private command: readonly string[] | undefined;
|
|
84
105
|
private deliveryId: string | undefined;
|
|
85
106
|
private inputReleased = false;
|
|
107
|
+
private failureTrace: ClaudeFailureTrace | undefined;
|
|
86
108
|
private sessions: ClaudeTaskSessions;
|
|
87
109
|
private stopping: Promise<void> | undefined;
|
|
88
110
|
private stoppingTarget: "idle" | "offline" | undefined;
|
|
@@ -125,6 +147,7 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
125
147
|
}
|
|
126
148
|
this.stoppingTarget = next;
|
|
127
149
|
++this.generation; this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; this.abort?.abort(); this.clearWatchdog();
|
|
150
|
+
discardFailureTrace(this.failureTrace); this.failureTrace = undefined;
|
|
128
151
|
this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason);
|
|
129
152
|
if (this.owned || this.running) this.setState("busy");
|
|
130
153
|
const work: Promise<void> = Promise.resolve().then(async () => {
|
|
@@ -165,8 +188,10 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
165
188
|
}
|
|
166
189
|
private async run(groups: { task?: number; envs: Envelope[] }[], generation: number, running: Promise<void>): Promise<void> {
|
|
167
190
|
const current = () => generation === this.generation && !this.abort?.signal.aborted;
|
|
191
|
+
let failureReason: string | undefined;
|
|
168
192
|
try {
|
|
169
193
|
for (let group of groups) {
|
|
194
|
+
failureReason = undefined;
|
|
170
195
|
if (!current() || !this.binding?.()) throw new Error("Claude implementer admission changed");
|
|
171
196
|
group = { ...group, envs: group.envs.filter(env => !env.private && this.options.admit(env)) };
|
|
172
197
|
if (!group.envs.length) continue;
|
|
@@ -180,6 +205,7 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
180
205
|
}
|
|
181
206
|
}
|
|
182
207
|
const launchId = randomUUID(), directory = mkdtempSync(join(this.options.stateDir, "claude-child-")), settings = join(directory, "settings.json");
|
|
208
|
+
let launchFailureTrace: ClaudeFailureTrace | undefined;
|
|
183
209
|
this.cleanup = () => rmSync(directory, { recursive: true, force: true });
|
|
184
210
|
writeFileSync(settings, sessionSettings(undefined, { script: this.options.factsHook, stateDir: this.options.stateDir, purpose: "permission" }), { mode: 0o600, flag: "wx" });
|
|
185
211
|
const env = { AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: this.id, AGENTHUB_STATE_DIR: this.options.stateDir, AGENTHUB_PROJECT_DIR: this.options.projectRoot,
|
|
@@ -196,11 +222,15 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
196
222
|
void this.cancel("Claude implementer admission changed before child start", "idle").catch(() => {});
|
|
197
223
|
return;
|
|
198
224
|
}
|
|
199
|
-
this.inputReleased = true; // OwnedPrintChild releases stdin immediately after this synchronous admission fence.
|
|
200
225
|
this.childAbort = new AbortController();
|
|
201
|
-
|
|
226
|
+
const launch: OwnedToolsLaunch = Object.freeze({ permissionHook: true, launchId, generation: `${generation}:${launchId}`, sessionId: turn.sessionId, signal: this.childAbort.signal, ...(group.task !== undefined ? { task: group.task } : {}) });
|
|
227
|
+
this.active = launch;
|
|
228
|
+
try { launchFailureTrace = this.options.failureTrace?.(Object.freeze({ ...launch }), provenVersion(this.binding!)); }
|
|
229
|
+
catch { launchFailureTrace = undefined; }
|
|
230
|
+
this.failureTrace = launchFailureTrace;
|
|
231
|
+
this.inputReleased = true; // OwnedPrintChild releases stdin immediately after this synchronous admission fence.
|
|
202
232
|
},
|
|
203
|
-
onLeaderExit: () => { this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
|
|
233
|
+
onLeaderExit: () => { sealFailureTrace(launchFailureTrace); this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
|
|
204
234
|
onActivity: () => this.touch(),
|
|
205
235
|
onLimit: () => { void this.cancel("Claude implementer output limit exceeded", "idle").catch(() => {}); },
|
|
206
236
|
onFrame: event => {
|
|
@@ -219,12 +249,21 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
219
249
|
catch (error) {
|
|
220
250
|
// spawn ENOENT has no owned group. A started child with failed group cleanup must keep its handle.
|
|
221
251
|
if (child.process.pid === undefined) this.owned = undefined;
|
|
252
|
+
if (error instanceof PrintChildCleanupError) failureReason = this.failureReason(group.task, launchFailureTrace);
|
|
253
|
+
else discardFailureTrace(launchFailureTrace);
|
|
254
|
+
if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined;
|
|
222
255
|
throw error;
|
|
223
256
|
}
|
|
224
257
|
this.active = undefined; this.owned = undefined;
|
|
225
258
|
this.cleanup?.(); this.cleanup = undefined;
|
|
226
|
-
if (!current()) return;
|
|
227
|
-
if (code !== 0 || !result || failed)
|
|
259
|
+
if (!current()) { discardFailureTrace(launchFailureTrace); if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined; return; }
|
|
260
|
+
if (code !== 0 || !result || failed) {
|
|
261
|
+
failureReason = this.failureReason(group.task, launchFailureTrace) ?? "Claude implementer turn failed";
|
|
262
|
+
if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined;
|
|
263
|
+
throw new Error("Claude implementer turn failed");
|
|
264
|
+
}
|
|
265
|
+
discardFailureTrace(launchFailureTrace);
|
|
266
|
+
if (this.failureTrace === launchFailureTrace) this.failureTrace = undefined;
|
|
228
267
|
}
|
|
229
268
|
if (current()) {
|
|
230
269
|
if (this.running === running) this.running = undefined;
|
|
@@ -233,7 +272,11 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
233
272
|
} catch (error) {
|
|
234
273
|
this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined;
|
|
235
274
|
if (current()) {
|
|
236
|
-
|
|
275
|
+
const reason = error instanceof PrintChildCleanupError
|
|
276
|
+
? failureReason === "Claude implementer turn failed (details withheld)" ? "Claude implementer cleanup incomplete; details withheld"
|
|
277
|
+
: failureReason ? `Claude implementer cleanup incomplete; ${failureReason}` : "Claude implementer cleanup incomplete"
|
|
278
|
+
: failureReason ?? "Claude implementer turn failed";
|
|
279
|
+
this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason);
|
|
237
280
|
if (this.owned) this.setState("busy"); else {
|
|
238
281
|
this.cleanup?.(); this.cleanup = undefined;
|
|
239
282
|
if (this.running === running) this.running = undefined;
|
|
@@ -242,4 +285,15 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
242
285
|
}
|
|
243
286
|
}
|
|
244
287
|
}
|
|
288
|
+
|
|
289
|
+
private failureReason(task: number | undefined, trace: ClaudeFailureTrace | undefined): string | undefined {
|
|
290
|
+
if (!trace) return undefined;
|
|
291
|
+
const evidence = readFailureTrace(trace);
|
|
292
|
+
if (!evidence) return undefined;
|
|
293
|
+
let privateTurn = true;
|
|
294
|
+
try { privateTurn = this.options.failurePrivate?.(task) !== false; } catch { /* unknown privacy is private */ }
|
|
295
|
+
const message = formatClaudeFailure(evidence, privateTurn);
|
|
296
|
+
try { this.options.failureLog?.(message); } catch { /* host observation never changes settlement */ }
|
|
297
|
+
return message;
|
|
298
|
+
}
|
|
245
299
|
}
|