@staix/agent-hub 0.12.21 → 0.12.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +13 -6
  3. package/docs/agent-notes/adapters.md +6 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +4 -0
  7. package/docs/agent-notes/local-worker.md +2 -0
  8. package/docs/agent-notes/tasks.md +1 -1
  9. package/docs/agent-notes/tests.md +2 -0
  10. package/docs/events.md +13 -4
  11. package/docs/operations.md +253 -22
  12. package/docs/quickstart.md +6 -3
  13. package/docs/security.md +9 -6
  14. package/docs/smoke.md +61 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +38 -5
  16. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  17. package/docs/verified.json +21 -15
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +1 -1
  21. package/src/adapters/acp.ts +10 -4
  22. package/src/adapters/codex-appserver.ts +27 -22
  23. package/src/adapters/local-worker.ts +44 -10
  24. package/src/adapters/pi.ts +158 -19
  25. package/src/cli/help.ts +6 -5
  26. package/src/cli/identity.ts +2 -2
  27. package/src/cli/launch.ts +182 -7
  28. package/src/cli/main.ts +137 -18
  29. package/src/cli/recovery-package.ts +17 -0
  30. package/src/cli/upgrade-interactive.ts +297 -0
  31. package/src/cli/upgrade-runtime.ts +56 -2
  32. package/src/cli/upgrade.ts +27 -10
  33. package/src/hub/bus.ts +5 -3
  34. package/src/hub/daemon.ts +145 -60
  35. package/src/hub/events.ts +2 -1
  36. package/src/hub/peers.ts +2 -2
  37. package/src/hub/recovery-store.ts +38 -0
  38. package/src/hub/tasks.ts +32 -15
  39. package/src/local/deny.ts +48 -1
  40. package/src/local/sandbox.ts +6 -3
  41. package/src/local/tools.ts +25 -6
  42. package/src/pi/extension.ts +43 -20
  43. package/src/pi/tool-receipts.ts +17 -1
  44. package/src/ui/index.html +15 -11
@@ -8,7 +8,7 @@ import { BasePeer } from "../hub/peers.ts";
8
8
  import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
9
  import { processSignature } from "../pi/process-signature.ts";
10
10
  import { realPath } from "../hub/project.ts";
11
- import { toolResultFailed } from "../local/tools.ts";
11
+ import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
12
12
  import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
13
13
  import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
14
14
 
@@ -25,10 +25,13 @@ export interface PiExit {
25
25
  toolActive: boolean;
26
26
  lastToolName?: string;
27
27
  }
28
+ export interface PiToolContext { userBash: boolean; isCurrent: () => boolean; onApproval: (provenance: ApprovalProvenance) => void; }
29
+ export interface PiToolResult { text: string; exitCode: number | null; }
30
+ class PiToolSettlementFailure extends Error {}
28
31
  export interface PiOptions {
29
32
  cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
30
33
  model?: string;
31
- relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal) => Promise<string>; tools: PiToolSchema[];
34
+ relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
32
35
  preamble?: string;
33
36
  selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
34
37
  /** Atomic task/run admission immediately before provider requests or tool execution. */
@@ -40,6 +43,7 @@ export interface PiOptions {
40
43
  watchdogMs?: number; log?: (line: string) => void;
41
44
  /** Once per owner, after it is offline; a TUI owner may have unknown OS exit status. */
42
45
  onExit?: (exit: PiExit) => void;
46
+ onApprovalStop?: (reason: string) => void;
43
47
  /** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
44
48
  stopGraceMs?: number;
45
49
  }
@@ -77,8 +81,17 @@ export class PiPeer extends BasePeer {
77
81
  private persistedOnce = false;
78
82
  private activeEnvs: Envelope[] = [];
79
83
  private activeTools = 0;
84
+ private readonly pendingToolGenerations = new Map<string, { generation: number; abort: AbortController; settled: Promise<void> }>();
85
+ private readonly abortedToolIds = new Set<string>();
86
+ private abortIdsOverflow = false;
80
87
  private readonly activeDeliveryIds = new Set<string>();
81
88
  private agentRunning = false;
89
+ private approvalExpiries = 0;
90
+ private approvalAnswerEpoch: number | undefined;
91
+ private approvalTurnAbort = false;
92
+ private approvalAbortSent = false;
93
+ private approvalStopReason = "";
94
+ private requestedStopReason: string | undefined;
82
95
  private owner = randomUUID();
83
96
  private _tuiLaunch?: PiTuiLaunch;
84
97
  private requestedModel = "";
@@ -180,11 +193,18 @@ export class PiPeer extends BasePeer {
180
193
  }
181
194
  private async handleToolRequest(body: any): Promise<Response> {
182
195
  if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
196
+ if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
197
+ const text = "error: Pi tool lineage is missing, stale or unverified; restart Pi with the current hub extension before retrying";
198
+ this.opts.log?.(`[${this.id}] ${text}`);
199
+ return Response.json({ text, failed: true }, { status: 409 });
200
+ }
201
+ if (this.abortIdsOverflow || this.abortedToolIds.has(String(body.toolCallId))) return Response.json({ text: "error: tool call cancelled before execution", failed: true });
202
+ if (this.pendingToolGenerations.has(String(body.toolCallId ?? ""))) return Response.json({ text: "error: this Pi tool call is already in progress", failed: true }, { status: 409 });
183
203
  let executionSignal = this.executionAbort?.signal;
184
204
  let idleBashTimer: ReturnType<typeof setTimeout> | undefined;
185
205
  if (body.purpose === "idle_user_bash") {
186
206
  const reservation = typeof body.reservation === "string" ? this.idleBashReservations.get(body.reservation) : undefined;
187
- if (!reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
207
+ if (this.opts.mode !== "tui" || !this.verifiedOwner() || !reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
188
208
  if (reservation.deadlineAt !== undefined && reservation.deadlineAt <= Date.now()) {
189
209
  this.idleBashReservations.delete(String(body.reservation));
190
210
  return Response.json({ text: "error: Pi user shell execution budget expired" }, { status: 409 });
@@ -195,6 +215,18 @@ export class PiPeer extends BasePeer {
195
215
  if (reservation.deadlineAt !== undefined) idleBashTimer = setTimeout(() => idleBashAbort.abort(), Math.max(0, reservation.deadlineAt - Date.now()));
196
216
  }
197
217
  this.noteActivity();
218
+ const generation = this.budgetGeneration;
219
+ const turnSignal = this.executionAbort?.signal;
220
+ const parentSignal = executionSignal;
221
+ const toolAbort = new AbortController();
222
+ const abortTool = () => toolAbort.abort();
223
+ parentSignal?.addEventListener("abort", abortTool, { once: true });
224
+ if (parentSignal?.aborted) abortTool();
225
+ executionSignal = toolAbort.signal;
226
+ const callId = String(body.toolCallId ?? "");
227
+ let settled!: () => void;
228
+ const settlement = new Promise<void>((resolve) => { settled = resolve; });
229
+ this.pendingToolGenerations.set(callId, { generation, abort: toolAbort, settled: settlement });
198
230
  const name = String(body.name);
199
231
  this.lastToolName = this.opts.tools.some((tool) => tool.name === name) && /^[a-zA-Z0-9_.-]{1,128}$/.test(name) ? name : "unknown";
200
232
  this.activeTools++;
@@ -203,11 +235,40 @@ export class PiPeer extends BasePeer {
203
235
  // `failed` is the managed-tool failure verdict (toolResultFailed, the one contract): Pi 1.0.1
204
236
  // classifies a native tool result by isError === true alone, so the extension needs the flag,
205
237
  // not just the text (#181). An older extension simply ignores the extra field.
206
- try { const text = await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId, executionSignal); return Response.json({ text, failed: toolResultFailed(String(body.name), text) }); }
238
+ try {
239
+ const result = await this.opts.executeTool(name, body.args, callId, this.sessionId, executionSignal, {
240
+ userBash: body.purpose === "idle_user_bash",
241
+ isCurrent: () => generation === this.budgetGeneration && !this.stopping && this.state !== "offline" && !executionSignal?.aborted && !this.approvalStopReason && this.verifiedOwner() && (body.purpose === "idle_user_bash" ? this.opts.mode === "tui" : turnSignal === this.executionAbort?.signal),
242
+ onApproval: (provenance) => {
243
+ if (!this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
244
+ if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
245
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
246
+ }
247
+ if (provenance.source === "person") this.approvalExpiries = 0;
248
+ if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
249
+ this.approvalStopReason = "Pi turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
250
+ try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
251
+ }
252
+ },
253
+ });
254
+ const text = typeof result === "string" ? result : result.text;
255
+ return Response.json({ text, failed: typeof result !== "string" && result.exitCode !== null ? result.exitCode !== 0 : toolResultFailed(name, text), ...(typeof result === "string" ? {} : { exitCode: result.exitCode }) });
256
+ }
207
257
  catch (error) { return Response.json({ text: `error: ${(error as Error).message}`, failed: true }, { status: 200 }); }
208
258
  finally {
209
259
  clearTimeout(idleBashTimer);
210
260
  this.activeTools--;
261
+ parentSignal?.removeEventListener("abort", abortTool);
262
+ if (this.pendingToolGenerations.get(callId)?.abort === toolAbort) this.pendingToolGenerations.delete(callId);
263
+ settled();
264
+ if (this.approvalStopReason && !this.approvalAbortSent) {
265
+ this.approvalAbortSent = true;
266
+ const reason = this.approvalStopReason;
267
+ // Finish this request's expiry reply before withdrawing siblings and aborting the native turn.
268
+ setTimeout(() => {
269
+ if (this.agentRunning && generation === this.budgetGeneration && this.approvalStopReason === reason && !this.stopping) void this.endApprovalTurn(generation, reason);
270
+ }, 10);
271
+ }
211
272
  if (this.state === "busy") {
212
273
  if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
213
274
  else this.touch();
@@ -218,12 +279,14 @@ export class PiPeer extends BasePeer {
218
279
  private async handleBudgetRequest(body: any): Promise<Response> {
219
280
  if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
220
281
  const idleUserBash = body.idleUserBash === true;
221
- const current = Number.isSafeInteger(body.generation) && body.generation === this.budgetGeneration;
222
- const idleOwner = this.ownerClaimed && this.ownerPid !== undefined && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
223
- if (!current || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
282
+ const generation = body.generation, controller = this.executionAbort, session = this.sessionId, ownerPid = this.ownerPid, ownerSignature = this.ownerSignature;
283
+ const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature && !this.stopping && this.verifiedOwner();
284
+ const idleOwner = this.opts.mode === "tui" && !this.stopping && this.verifiedOwner() && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
285
+ if (!current() || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
224
286
  // An interactive user shell has no task delivery. It may spend an eligible run budget only.
225
287
  const envs = idleUserBash ? [] : this.budgetEnvelopes;
226
288
  const decisions = this.opts.admitBudget ? await this.opts.admitBudget(envs, body.unit as ExecutionUnit) : [];
289
+ if (!current() || (idleUserBash ? this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "Pi owner or turn changed during admission" }, { status: 409 });
227
290
  const denied = decisions.find((decision) => !decision.allowed);
228
291
  const remaining = decisions.filter((decision) => decision.unit === "elapsed_ms" && decision.remaining !== null).reduce<number | undefined>((min, decision) => min === undefined ? decision.remaining! : Math.min(min, decision.remaining!), undefined);
229
292
  let reservation: string | undefined;
@@ -248,7 +311,10 @@ export class PiPeer extends BasePeer {
248
311
 
249
312
  private async startImpl(): Promise<void> {
250
313
  this.stopping = false;
314
+ this.requestedStopReason = undefined;
315
+ this.approvalTurnAbort = false;
251
316
  this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
317
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
252
318
  mkdirSync(this.opts.stateDir, { recursive: true });
253
319
  const sessions = join(this.opts.stateDir, "pi-sessions");
254
320
  mkdirSync(sessions, { recursive: true, mode: 0o700 });
@@ -311,17 +377,48 @@ export class PiPeer extends BasePeer {
311
377
  throw new Error("Pi get_state session identity does not match the requested recovery session");
312
378
  }
313
379
  this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
380
+ // The hub spawned this headless owner and get_state proved its session. Pin the same
381
+ // identity a native extension claim uses, including fakes that do not load the extension.
382
+ if (!this.ownerClaimed) {
383
+ const pid = this.proc?.pid, signature = pid ? processSignature(pid) : undefined;
384
+ if (!pid || !signature) throw new Error("Pi headless owner process identity is not verified");
385
+ this.ownerPid = pid; this.ownerSignature = signature;
386
+ this.ownerToken = String(this._tuiLaunch?.env.AGENTHUB_PI_OWNER_TOKEN ?? ""); this.ownerClaimed = true;
387
+ }
314
388
  this.persistedOnce = existsSync(this.sessionFile);
315
389
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) throw new Error("Pi startup session identity mismatch");
316
390
  this.started = true;
317
391
  this.setState("idle");
318
392
  }
319
393
 
320
- async stop(): Promise<void> {
394
+ private verifiedOwner(): boolean {
395
+ return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && processSignature(this.ownerPid) === this.ownerSignature;
396
+ }
397
+ private abortTools(): Promise<void>[] {
398
+ this.executionAbort?.abort();
399
+ this.idleBashReservations.clear();
400
+ const pending = [...this.pendingToolGenerations.values()];
401
+ for (const call of pending) call.abort.abort();
402
+ return pending.map((call) => call.settled);
403
+ }
404
+ get mode(): "headless" | "tui" { return this.opts.mode; }
405
+
406
+ async stop(reason?: string): Promise<void> {
407
+ if (reason !== undefined) this.requestedStopReason = reason;
408
+ this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
409
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
410
+ this.activeDeliveryIds.clear();
411
+ const settling = this.abortTools();
412
+ let unsettled: unknown;
413
+ if (settling.length) {
414
+ let timer: ReturnType<typeof setTimeout> | undefined;
415
+ try {
416
+ await Promise.race([Promise.all(settling), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new PiToolSettlementFailure("Pi managed tools did not settle after cancellation")), this.opts.stopGraceMs ?? 5_000); })]);
417
+ } catch (error) { unsettled = error; } // a tool past its grace must not block the teardown (#253): stop reports it at the end
418
+ finally { clearTimeout(timer); }
419
+ }
321
420
  const tuiExit = this.opts.mode === "tui" && this.ownerClaimed && this.ownerPid && this.ownerSignature
322
421
  ? { ...this.exitMetadata("owner_stopped"), expected: true } : undefined;
323
- for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi session stopped before settlement" });
324
- this.activeDeliveryIds.clear();
325
422
  this.stopping = true;
326
423
  this.clearOwnerMonitor();
327
424
  if (this.opts.mode === "tui") {
@@ -361,6 +458,7 @@ export class PiPeer extends BasePeer {
361
458
  this.server?.stop(true); this.server = undefined; this.setState("offline");
362
459
  if (failed) throw failed;
363
460
  if (tuiExit) this.reportExit(tuiExit); // the verified owner is gone; never invent an exit for an unclaimed launch
461
+ if (unsettled) throw unsettled;
364
462
  }
365
463
 
366
464
  /**
@@ -443,8 +541,10 @@ export class PiPeer extends BasePeer {
443
541
  }
444
542
 
445
543
  private handleBridgeEvent(event: any): void {
544
+ if (this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) return;
446
545
  if (event.type === "session_start") {
447
546
  if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
547
+ this.approvalTurnAbort = event.approvalTurnAbort === true;
448
548
  this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
449
549
  this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
450
550
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
@@ -457,11 +557,24 @@ export class PiPeer extends BasePeer {
457
557
  this.fail(new Error("Pi session shut down before settlement"));
458
558
  if (this.opts.mode === "tui") this.reportExit(exit);
459
559
  }
560
+ if (event.type === "tool_abort" && this.agentRunning && this.ownerClaimed && this.ownerPid && this.ownerSignature
561
+ && processSignature(this.ownerPid) === this.ownerSignature && event.sessionId === this.sessionId && event.generation === this.budgetGeneration
562
+ && typeof event.toolCallId === "string" && event.toolCallId.length > 0 && event.toolCallId.length <= 512) {
563
+ const pending = this.pendingToolGenerations.get(event.toolCallId);
564
+ if (pending && pending.generation === event.generation) pending.abort.abort();
565
+ else { // a native abort may arrive before its HTTP tool request; fence that call in this generation
566
+ // ponytail: 64 early aborts per turn; fence further calls rather than forget a cancellation.
567
+ // A native per-call acknowledgement would replace this bounded early-arrival cache.
568
+ if (this.abortedToolIds.size >= 64 && !this.abortedToolIds.has(event.toolCallId)) this.abortIdsOverflow = true;
569
+ else this.abortedToolIds.add(event.toolCallId);
570
+ }
571
+ }
460
572
  if (event.type === "agent_start") {
461
573
  const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
462
574
  if (generation <= this.budgetGeneration) return;
463
575
  this.modelStep = 0;
464
- this.usageSeen.clear(); this.idleBashReservations.clear(); this.ceilingStops.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
576
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false; this.abortedToolIds.clear(); this.abortIdsOverflow = false;
577
+ this.usageSeen.clear(); this.ceilingStops.clear(); this.abortTools(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
465
578
  }
466
579
  // #179: the extension's tool-step ceiling signal. Only a schema-valid event bound to THIS session,
467
580
  // THIS turn generation and a running turn is stored; the first signal of a turn stands. Anything
@@ -495,7 +608,8 @@ export class PiPeer extends BasePeer {
495
608
  this.agentRunning = false;
496
609
  if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
497
610
  const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
498
- const text = this.settledText.trim(); const error = this.budgetStops.get(generation) ?? this.settledError; const cancelled = !error && this.settledCancelled;
611
+ const approvalStopReason = this.approvalStopReason;
612
+ const text = this.settledText.trim(); const error = approvalStopReason || this.budgetStops.get(generation) || this.settledError; const cancelled = !error && this.settledCancelled;
499
613
  const ceiling = this.ceilingStops.get(generation);
500
614
  this.budgetStops.delete(generation);
501
615
  this.ceilingStops.delete(generation);
@@ -505,12 +619,16 @@ export class PiPeer extends BasePeer {
505
619
  const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
506
620
  if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
507
621
  else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
622
+ else if (approvalStopReason) this.onMessage?.(approvalStopReason, reply);
508
623
  else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
509
624
  else if (text) this.onMessage?.(text, reply);
510
- for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
625
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
511
626
  this.activeDeliveryIds.clear();
512
- this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
513
- this.executionAbort = undefined;
627
+ this.currentReply = undefined; this.activeEnvs = [];
628
+ this.executionAbort?.abort(); this.executionAbort = undefined;
629
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
630
+ this.abortedToolIds.clear(); this.abortIdsOverflow = false;
631
+ if (this.state === "busy" && !this.activeTools) this.setState("idle");
514
632
  }
515
633
  }
516
634
 
@@ -560,14 +678,19 @@ export class PiPeer extends BasePeer {
560
678
  }
561
679
 
562
680
  private fail(error: Error): void {
681
+ const approvalStopReason = this.approvalStopReason;
682
+ this.abortTools();
683
+ this.agentRunning = false;
684
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
685
+ this.abortedToolIds.clear(); this.abortIdsOverflow = false;
563
686
  for (const pending of this.pending.values()) pending.reject(error);
564
687
  this.pending.clear();
565
688
  const envs = this.activeEnvs; this.activeEnvs = [];
566
689
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: error.message });
567
690
  this.activeDeliveryIds.clear();
568
- if (envs.length) {
569
- this.onMessage?.("Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
570
- void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
691
+ if (envs.length && !this.requestedStopReason) {
692
+ this.onMessage?.(approvalStopReason ? `${approvalStopReason} Pi is offline because its extension could not abort the turn; restart with ahub pi and settle the held delivery after inspecting prior work.` : "Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
693
+ if (!approvalStopReason && !(error instanceof ApprovalWaitStop)) void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
571
694
  }
572
695
  this.currentReply = undefined;
573
696
  this.setState("offline");
@@ -588,6 +711,17 @@ export class PiPeer extends BasePeer {
588
711
  this.ownerMonitor.unref?.();
589
712
  }
590
713
  private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
714
+ private async endApprovalTurn(generation: number, reason: string): Promise<void> {
715
+ if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || this.stopping || this.state === "offline") return;
716
+ this.executionAbort?.abort();
717
+ if (this.approvalTurnAbort) {
718
+ try { await this.sendTui({ type: "abort_budget", generation, cause: "approval", reason }); return; }
719
+ catch { if (generation !== this.budgetGeneration || !this.agentRunning) return; }
720
+ }
721
+ this.opts.log?.(`[${this.id}] approval turn abort unsupported; Pi goes offline. Restart with ahub pi and settle the held delivery after inspecting prior work.`);
722
+ await this.terminateFailedTurn(new ApprovalWaitStop(reason));
723
+ }
724
+
591
725
  private async terminateFailedTurn(error: Error): Promise<void> {
592
726
  const envs = this.activeEnvs.slice(), reply = this.currentReply;
593
727
  this.stopping = true;
@@ -595,7 +729,12 @@ export class PiPeer extends BasePeer {
595
729
  await this.stop();
596
730
  // The process exit handler may already have reported this failure.
597
731
  if (this.activeEnvs.length) this.fail(error);
598
- } catch {
732
+ } catch (stopError) {
733
+ if (stopError instanceof PiToolSettlementFailure && this.state === "offline") {
734
+ this.opts.log?.(`Pi teardown completed with a managed-tool settlement failure: ${(stopError as Error).message}`);
735
+ if (this.activeEnvs.length) this.fail(error);
736
+ return;
737
+ }
599
738
  this.activeEnvs = envs; this.currentReply = reply;
600
739
  this.stopping = true;
601
740
  this.setState("busy");
package/src/cli/help.ts CHANGED
@@ -24,11 +24,11 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
24
24
  ["ahub doctor --orphans [--kill]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
25
25
  ["ahub kill", "stop this project's hub"],
26
26
  ["ahub reset [--all] [--yes]", "list, then with --yes stop the hub and discard every queued and needs_review delivery, clear holds and pauses and drop session pointers; --all moves the state directory to .agenthub/archive instead"],
27
- ["ahub restart [--dry-run] [--yes]", "recover this project's runtime"],
28
- ["ahub upgrade --to <version> [--dry-run] [--yes]", "review and upgrade running projects"],
29
- ["ahub recovery status|resume|abort <operation-id>", "inspect, resume or cancel a preflight; status names the next actions"],
30
- ["ahub recovery dispose <operation-id> --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
31
- ["ahub recovery dispose <operation-id> --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
27
+ ["ahub restart [--dry-run] [--yes]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
28
+ ["ahub upgrade [--to <version>] [--dry-run] [--yes]", "review and upgrade running projects; --to defaults to the latest release (--yes needs it), and a newer release's own coordinator takes over; on a terminal without --dry-run or --yes: review, end agents first if you choose, apply, follow, and resume, cancel, end or reset on the spot"],
29
+ ["ahub recovery [status|resume|abort] [<operation-id>]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
30
+ ["ahub recovery dispose [<operation-id>] --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
31
+ ["ahub recovery dispose [<operation-id>] --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
32
32
  ["ahub daemon [--unattended]", "internal: the detached hub process that ahub up or the dashboard manager starts"],
33
33
  ["ahub recovery-run <operation-id>", "internal: the detached runner of an upgrade, restart or recovery resume"],
34
34
  ]],
@@ -38,6 +38,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
38
38
  ["ahub kimi [--print-command] [--model <alias>]", "start Kimi headless under ACP"],
39
39
  ["ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>]", "start Pi"],
40
40
  ["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
41
+ ["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
41
42
  ["ahub models setup|status|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
42
43
  ]],
43
44
  ["Messages and approvals", [
@@ -26,7 +26,7 @@ export type PeerCommandAccess = "allowed" | "conductor" | "console";
26
26
 
27
27
  /** A closed list: unknown commands cannot acquire console authority through an agent shell. */
28
28
  export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
29
- if (command === "permission") return "console";
29
+ if (command === "permission" || command === "stop") return "console";
30
30
  if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
31
31
  if (command === "task") {
32
32
  if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
@@ -47,7 +47,7 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
47
47
 
48
48
  /** No argument text is included: notices and refusal logs must not copy task text or credentials. */
49
49
  export function cliCommandLabel(command: string, args: readonly string[]): string {
50
- if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "status", "logs", "export", "report", "research", "bench", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
50
+ if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "stop", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "status", "logs", "export", "report", "research", "bench", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
51
51
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "bench"].includes(command)) {
52
52
  const sub = command === "bench" ? args.find((arg) => arg !== "--json") : args[0];
53
53
  if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "run", "report", "compare"].includes(sub)) return `${command} ${sub}`;
package/src/cli/launch.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
- import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync } from "node:fs";
3
- import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import { randomUUID, createHash } from "node:crypto";
4
5
  import { resolve, join, basename, dirname, isAbsolute } from "node:path";
5
6
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
6
7
  import { realPath } from "../hub/project.ts";
7
- import { peerChildEnv } from "../hub/child-process.ts";
8
+ import { peerChildEnv, processTable } from "../hub/child-process.ts";
8
9
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
9
10
 
10
11
  /** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
@@ -65,14 +66,188 @@ export function cleanupClaudeSettings(stateDir: string, previous: unknown): void
65
66
  /** Crash fallback only: an absent/unreadable owner identity never certifies that its native launch ended. */
66
67
  export function cleanupStaleClaudeSettings(stateDir: string, previous: unknown, identity = processSignature): void {
67
68
  if (!previous || typeof previous !== "object" || Array.isArray(previous)) return;
68
- const record = previous as Record<string, unknown>;
69
+ const original = previous as Record<string, unknown>;
70
+ const record = readClaudeNative(stateDir, original as ClaudeLaunchRecord) ?? original;
69
71
  if (typeof record.launcherSignature !== "string" || !record.launcherSignature) return;
70
72
  if (processLiveness(record.launcherPid, record.launcherSignature, identity) !== "gone") return;
71
- // ponytail: spawnSync launch records have no independent native identity, so crash-left settings are retained.
72
- // Upgrade path: record the native child's PID and processSignature before admitting crash fallback cleanup.
73
73
  if (typeof record.nativePid !== "number" || !Number.isSafeInteger(record.nativePid) || record.nativePid <= 0 || record.nativePid === record.launcherPid || typeof record.nativeSignature !== "string" || !record.nativeSignature) return;
74
- if (processLiveness(record.nativePid, record.nativeSignature, identity) !== "gone") return;
74
+ if (record.nativeIdentity === "direct-child") {
75
+ // A direct child may exec in place. A changed signature while its PID still runs cannot certify native exit.
76
+ // The shared table excludes exited zombies; an unavailable table certifies nothing.
77
+ const table = processTable();
78
+ if (!table || table.some(row => row.pid === record.nativePid)) return;
79
+ } else if (processLiveness(record.nativePid, record.nativeSignature, identity) !== "gone") return;
75
80
  cleanupClaudeSettings(stateDir, record.settingsFile);
81
+ removeClaudeNative(stateDir, original as ClaudeLaunchRecord);
82
+ }
83
+
84
+ export interface ClaudeLaunchRecord extends Record<string, unknown> {
85
+ instanceId: string; launchId: string; settingsFile?: string; launcherPid: number; launcherSignature?: string;
86
+ }
87
+
88
+ export interface ClaudeLaunchPublication { recorded: boolean; warning?: string; }
89
+
90
+ /** Native attestation alone is serialized; managed hook metadata never waits on this lock. */
91
+ async function withClaudeLaunchRecord(stateDir: string, action: () => void): Promise<ClaudeLaunchPublication> {
92
+ const lock = join(stateDir, "claude-launch.lock"), temp = `${lock}.${randomUUID()}.tmp`;
93
+ const skipped = (reason = `${lock} is busy or unverified`): ClaudeLaunchPublication => ({ recorded: false, warning: `Native identity and crash cleanup disabled for this launch; managed hook record stays current. ${reason}; inspect ${lock} and verify its owner has ended before removing it.` });
94
+ const signature = processSignature(process.pid);
95
+ if (!signature) return skipped("launcher identity could not be verified");
96
+ let locked = false, tempCreated = false, own: { dev: number; ino: number } | undefined;
97
+ try {
98
+ const fd = openSync(temp, "wx", 0o600); tempCreated = true;
99
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid, signature })); } finally { closeSync(fd); }
100
+ const stat = lstatSync(temp); own = { dev: stat.dev, ino: stat.ino };
101
+ const deadline = performance.now() + 250;
102
+ for (;;) {
103
+ try { linkSync(temp, lock); break; }
104
+ catch (error) {
105
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") return skipped();
106
+ let before;
107
+ try { before = lstatSync(lock); } catch { if (performance.now() < deadline) continue; return skipped(); }
108
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600 || !process.getuid || before.uid !== process.getuid()) return skipped();
109
+ let owner: { pid?: unknown; signature?: unknown };
110
+ try { owner = JSON.parse(readFileSync(lock, "utf8")); } catch { return skipped(); }
111
+ if (!owner || typeof owner.signature !== "string" || !owner.signature) return skipped();
112
+ const alive = processLiveness(owner.pid, owner.signature);
113
+ if (alive === "unknown") return skipped();
114
+ if (alive === "live") {
115
+ if (performance.now() >= deadline) return skipped();
116
+ await new Promise(resolve => setTimeout(resolve, 25)); continue;
117
+ }
118
+ const current = lstatSync(lock);
119
+ if (current.dev !== before.dev || current.ino !== before.ino) return skipped();
120
+ unlinkSync(lock);
121
+ }
122
+ }
123
+ locked = true;
124
+ unlinkSync(temp);
125
+ try { action(); } catch { return skipped("native attestation publication failed"); }
126
+ return { recorded: true };
127
+ } catch { return skipped("native attestation lock could not be read or published"); }
128
+ finally {
129
+ if (locked && own) {
130
+ try { const current = lstatSync(lock); if (current.dev === own.dev && current.ino === own.ino) unlinkSync(lock); }
131
+ catch { /* only our verified lock may be removed */ }
132
+ }
133
+ if (tempCreated) { try { unlinkSync(temp); } catch { /* our populated temp may already be linked and consumed */ } }
134
+ }
135
+ }
136
+
137
+ function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
138
+ const file = join(stateDir, "claude-launch.json"), temp = `${file}.${randomUUID()}.tmp`;
139
+ try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
140
+ finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
141
+ }
142
+
143
+ export function recordClaudeLaunch(stateDir: string, record: ClaudeLaunchRecord): ClaudeLaunchPublication {
144
+ writeClaudeLaunchRecord(stateDir, record);
145
+ return { recorded: true };
146
+ }
147
+
148
+ function sameClaudeLaunch(left: ClaudeLaunchRecord, right: ClaudeLaunchRecord): boolean {
149
+ return left.instanceId === right.instanceId && left.launchId === right.launchId && left.settingsFile === right.settingsFile
150
+ && left.launcherPid === right.launcherPid && left.launcherSignature === right.launcherSignature;
151
+ }
152
+ function nativeAttestationPath(stateDir: string, launch: ClaudeLaunchRecord): string {
153
+ const hash = createHash("sha256").update(JSON.stringify([launch.instanceId, launch.launchId, launch.settingsFile, launch.launcherPid, launch.launcherSignature])).digest("hex");
154
+ return join(stateDir, `claude-native-${hash}.json`);
155
+ }
156
+ function privateClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): { file: string; stat: ReturnType<typeof lstatSync>; record: ClaudeLaunchRecord } | undefined {
157
+ const file = nativeAttestationPath(stateDir, launch);
158
+ try {
159
+ const stat = lstatSync(file);
160
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
161
+ const record = JSON.parse(readFileSync(file, "utf8")) as ClaudeLaunchRecord;
162
+ if (!record || !sameClaudeLaunch(record, launch) || typeof record.nativePid !== "number" || !Number.isSafeInteger(record.nativePid) || record.nativePid <= 0 || typeof record.nativeSignature !== "string" || !record.nativeSignature || record.nativeIdentity !== "direct-child") return;
163
+ return { file, stat, record };
164
+ } catch { return; }
165
+ }
166
+ export function readClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): ClaudeLaunchRecord | undefined {
167
+ return privateClaudeNative(stateDir, launch)?.record;
168
+ }
169
+ function removeClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): void {
170
+ const saved = privateClaudeNative(stateDir, launch); if (!saved?.stat) return;
171
+ try { const now = lstatSync(saved.file); if (now.dev === saved.stat.dev && now.ino === saved.stat.ino) unlinkSync(saved.file); }
172
+ catch { /* absent or replaced native proof remains untouched */ }
173
+ }
174
+
175
+ /** Never attach an old child's identity to a replacement launch, even when its spawn callback arrives later. */
176
+ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchRecord, pid: number, signature: string, report?: (warning: string) => void): Promise<boolean> {
177
+ let recorded = false;
178
+ const publication = await withClaudeLaunchRecord(stateDir, () => {
179
+ let current: ClaudeLaunchRecord;
180
+ try {
181
+ const file = join(stateDir, "claude-launch.json"), stat = lstatSync(file);
182
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
183
+ current = JSON.parse(readFileSync(file, "utf8"));
184
+ } catch { return; }
185
+ if (!sameClaudeLaunch(current, launch)) return;
186
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === launch.launcherPid || !signature) return;
187
+ const file = nativeAttestationPath(stateDir, launch), temp = `${file}.${randomUUID()}.tmp`;
188
+ try {
189
+ lstatSync(file);
190
+ const saved = privateClaudeNative(stateDir, launch);
191
+ recorded = !!saved && saved.record.nativePid === pid && saved.record.nativeSignature === signature;
192
+ return; // a launch's first native proof is immutable; an unknown file is never overwritten
193
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return; }
194
+ try {
195
+ writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
196
+ renameSync(temp, file);
197
+ } finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
198
+ try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
199
+ });
200
+ if (publication.warning) report?.(publication.warning);
201
+ return publication.recorded && recorded;
202
+ }
203
+
204
+ /** Scripts and interpreters can leave a different native owner behind: their crash cleanup remains disabled. */
205
+ function directNativeExecutable(command: string, cwd: string, env: NodeJS.ProcessEnv): boolean {
206
+ const interpreter = /^(?:node|bun|deno|env|busybox|python(?:[0-9.]+)?|ruby|perl|bash|sh|zsh|fish|dash|ksh|csh|tcsh|ash)(?:\.exe)?$/;
207
+ if (interpreter.test(basename(command))) return false;
208
+ const paths = command.includes("/") ? [resolve(cwd, command)] : (env.PATH ?? "").split(":").filter(Boolean).map(path => resolve(cwd, path, command));
209
+ for (const path of paths) {
210
+ let fd: number | undefined;
211
+ try {
212
+ const target = realPath(path);
213
+ accessSync(target, constants.X_OK);
214
+ if (interpreter.test(basename(target))) return false;
215
+ fd = openSync(target, "r");
216
+ const bytes = Buffer.alloc(4); if (readSync(fd, bytes, 0, 4, 0) !== 4) return false;
217
+ return ["7f454c46", "feedface", "cefaedfe", "feedfacf", "cffaedfe", "cafebabe", "bebafeca", "cafebabf", "bfbafeca"].includes(bytes.toString("hex"));
218
+ } catch { /* resolve the next PATH entry */ }
219
+ finally { if (fd !== undefined) closeSync(fd); }
220
+ }
221
+ return false;
222
+ }
223
+
224
+ /** Same native stdio/environment and exit result as spawnSync, with a live child identity before it exits. */
225
+ export async function runClaudeLaunch(launch: Launch, options: { cwd: string; env: NodeJS.ProcessEnv; stateDir: string; record?: ClaudeLaunchRecord }): Promise<{ status: number | null; signal: NodeJS.Signals | null; error?: Error }> {
226
+ const direct = directNativeExecutable(launch.cmd, options.cwd, options.env);
227
+ return new Promise(resolveResult => {
228
+ let attestation: Promise<unknown> | undefined;
229
+ let childSignature: string | undefined;
230
+ const child = spawn(launch.cmd, launch.args, { cwd: options.cwd, env: options.env, stdio: "inherit" });
231
+ child.once("spawn", () => {
232
+ if (!options.record || !child.pid) return;
233
+ const unavailable = (reason: string) => console.error(`Native identity and crash cleanup disabled for this launch; managed hook record stays current. ${reason}.`);
234
+ if (!direct) { unavailable("native executable is a script or an unverified interpreter"); return; }
235
+ if (!options.record.launcherSignature) { unavailable("launcher identity could not be verified"); return; }
236
+ const signature = processSignature(child.pid);
237
+ if (!signature) { unavailable("native process identity could not be verified"); return; }
238
+ childSignature = signature;
239
+ attestation = recordClaudeNative(options.stateDir, options.record, child.pid, signature, warning => console.error(warning)).catch(() => undefined);
240
+ });
241
+ child.once("error", error => resolveResult({ status: null, signal: null, error }));
242
+ child.once("exit", async (status, signal) => {
243
+ await attestation;
244
+ if (options.record) {
245
+ const saved = readClaudeNative(options.stateDir, options.record);
246
+ if (saved && typeof child.pid === "number" && saved.nativePid === child.pid && saved.nativeSignature === childSignature) removeClaudeNative(options.stateDir, options.record);
247
+ } // this child's observed exit, after its last bounded write
248
+ resolveResult({ status, signal });
249
+ });
250
+ });
76
251
  }
77
252
 
78
253
  export interface StatusLineTee {