@staix/agent-hub 0.12.20 → 0.12.21

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 (60) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +6 -5
  3. package/docs/agent-notes/adapters.md +9 -2
  4. package/docs/agent-notes/bus.md +5 -2
  5. package/docs/agent-notes/daemon.md +8 -2
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/tasks.md +3 -1
  8. package/docs/agent-notes/tests.md +3 -2
  9. package/docs/bench.md +143 -0
  10. package/docs/events.md +2 -1
  11. package/docs/operations.md +271 -46
  12. package/docs/quickstart.md +12 -10
  13. package/docs/research.md +92 -0
  14. package/docs/security.md +45 -10
  15. package/docs/smoke.md +79 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +194 -20
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +68 -10
  18. package/docs/verified.json +28 -17
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +3 -2
  22. package/src/adapters/acp.ts +67 -6
  23. package/src/adapters/codex-appserver.ts +67 -2
  24. package/src/adapters/local-worker.ts +4 -3
  25. package/src/adapters/pi.ts +58 -4
  26. package/src/cli/bench.ts +287 -0
  27. package/src/cli/console-state.ts +226 -28
  28. package/src/cli/console.ts +16 -4
  29. package/src/cli/facts-hook.ts +50 -8
  30. package/src/cli/help.ts +10 -0
  31. package/src/cli/identity.ts +10 -4
  32. package/src/cli/launch.ts +96 -17
  33. package/src/cli/main.ts +159 -18
  34. package/src/cli/preview.ts +13 -3
  35. package/src/cli/status-lines.ts +2 -1
  36. package/src/cli/terminal-recovery.ts +83 -31
  37. package/src/cli/upgrade-runtime.ts +67 -18
  38. package/src/cli/upgrade.ts +81 -9
  39. package/src/hub/bench.ts +211 -0
  40. package/src/hub/bus.ts +19 -4
  41. package/src/hub/conductor.ts +22 -21
  42. package/src/hub/config-trust.ts +1 -0
  43. package/src/hub/control-client.ts +4 -1
  44. package/src/hub/daemon.ts +346 -40
  45. package/src/hub/delivery-journal.ts +6 -6
  46. package/src/hub/events.ts +2 -1
  47. package/src/hub/lifecycle.ts +8 -13
  48. package/src/hub/manager.ts +16 -14
  49. package/src/hub/permission-mode.ts +44 -0
  50. package/src/hub/recovery-store.ts +21 -9
  51. package/src/hub/registry.ts +36 -22
  52. package/src/hub/research.ts +255 -0
  53. package/src/hub/tasks.ts +70 -43
  54. package/src/hub/tool-refusal.ts +7 -0
  55. package/src/hub/ui.ts +4 -1
  56. package/src/local/tools.ts +14 -10
  57. package/src/pi/process-signature.ts +23 -0
  58. package/src/pi/tool-receipts.ts +7 -1
  59. package/src/ui/index.html +83 -6
  60. package/src/ui/task-progress.ts +33 -0
@@ -1,6 +1,7 @@
1
1
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
+ import { KIMI_MODE_IDS, type PermissionMode } from "../hub/permission-mode.ts";
4
5
  import { BasePeer } from "../hub/peers.ts";
5
6
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
6
7
 
@@ -56,6 +57,9 @@ export interface AcpOptions {
56
57
  cmd: string[];
57
58
  /** Coordinator-visible selected model only; contains no prompts or command arguments. */
58
59
  launchModel?: string;
60
+ permissionMode?: PermissionMode;
61
+ /** Bound for native mode acknowledgement; defaults to the handshake deadline. */
62
+ permissionModeTimeoutMs?: number;
59
63
  /** Load this earlier session (ACP `session/load`) instead of starting a new one: crash recovery, issue #37. */
60
64
  resumeSessionId?: string;
61
65
  cwd: string;
@@ -94,6 +98,8 @@ export function canonicalMcpToolName(announcedTitle: string | undefined, serverN
94
98
  return m && serverNames?.some((name) => name === m[2]) ? `mcp__${m[2]}__${m[1]}` : undefined;
95
99
  }
96
100
 
101
+ class RpcTimeoutError extends Error {}
102
+
97
103
  type Pending = { resolve: (v: any) => void; reject: (e: Error) => void };
98
104
 
99
105
  /** Streamed argument text as a payload: a complete JSON object, or nothing (a partial stream is not what will run). */
@@ -108,6 +114,10 @@ function jsonObject(text: string | undefined): object | undefined {
108
114
 
109
115
  /** ACP client (JSON-RPC 2.0, newline-delimited, over the child's stdio). One session, one prompt in flight. */
110
116
  export class AcpPeer extends BasePeer {
117
+ private permissionMode: PermissionMode;
118
+ private modeUnknown = false;
119
+ private kimiAgent = false;
120
+ private availableModes = new Set<string>();
111
121
  private proc: ChildProcessWithoutNullStreams | undefined;
112
122
  private sessionId = "";
113
123
  private nextId = 1;
@@ -126,6 +136,7 @@ export class AcpPeer extends BasePeer {
126
136
  private readonly opts: AcpOptions,
127
137
  ) {
128
138
  super(id, opts.watchdogMs);
139
+ this.permissionMode = opts.permissionMode ?? "ask";
129
140
  }
130
141
 
131
142
  recoveryMetadata(): Record<string, unknown> {
@@ -149,26 +160,68 @@ export class AcpPeer extends BasePeer {
149
160
  protocolVersion: 1,
150
161
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
151
162
  });
163
+ // The configured peer id and command are labels, not an agent-specific mode contract.
164
+ // Installed Kimi Code CLI's ACP initialize advertises this exact name.
165
+ this.kimiAgent = init?.agentInfo?.name === "Kimi Code CLI";
152
166
  const resume = this.opts.resumeSessionId;
153
167
  if (!resume) return this.request("session/new", { cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
154
168
  // The agent replays the session as updates while it loads; they arrive before the peer is idle, so none of
155
169
  // them is taken for an answer.
156
170
  if (!init?.agentCapabilities?.loadSession) throw new Error(`${this.id} cannot load an earlier session (the agent offers no loadSession)`);
157
- await this.request("session/load", { sessionId: resume, cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
158
- return { sessionId: resume };
171
+ const loaded = await this.request("session/load", { sessionId: resume, cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
172
+ return { ...loaded, sessionId: resume };
159
173
  };
160
174
  const timeout = new Promise<never>((_, reject) => {
161
175
  setTimeout(() => reject(new Error(`${this.id} did not complete the ACP handshake within ${HANDSHAKE_MS / 1000} s`)), HANDSHAKE_MS).unref();
162
176
  });
163
177
  try {
164
- this.sessionId = (await Promise.race([handshake(), timeout])).sessionId;
178
+ const session = await Promise.race([handshake(), timeout]);
179
+ this.sessionId = session.sessionId;
180
+ const modes: unknown = session.modes?.availableModes;
181
+ this.availableModes = new Set(Array.isArray(modes) ? modes.flatMap((mode) => typeof mode?.id === "string" ? [mode.id] : []) : []);
182
+ // A recovered session can retain a previous runtime opt-in. An advertised non-default mode
183
+ // must be reset for the configured ask default before any prompt; a fresh ask session stays untouched.
184
+ const resetResumed = this.opts.resumeSessionId && typeof session.modes?.currentModeId === "string" && session.modes.currentModeId !== KIMI_MODE_IDS.ask;
185
+ // Other ACP vendors retain their native policy, including reported non-default modes on resume.
186
+ if (this.kimiAgent && (this.permissionMode !== "ask" || resetResumed)) await this.setPermissionMode(this.permissionMode);
165
187
  } catch (e) {
166
- await stopOwnedProcess(proc, { group: true }).catch((stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
188
+ this.setState("offline");
189
+ if (this.proc === proc) {
190
+ await stopOwnedProcess(proc, { group: true }).then(() => { if (this.proc === proc) this.proc = undefined; }, (stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
191
+ }
167
192
  throw e;
168
193
  }
169
194
  this.setState("idle");
170
195
  }
171
196
 
197
+ getPermissionMode(): PermissionMode { return this.permissionMode; }
198
+ get permissionModeState(): PermissionMode | "unknown" | "unmanaged" {
199
+ return !this.kimiAgent ? "unmanaged" : this.modeUnknown ? "unknown" : this.permissionMode;
200
+ }
201
+
202
+ async setPermissionMode(mode: PermissionMode): Promise<void> {
203
+ if (!this.kimiAgent) throw new Error(`${this.id} permission mode ${mode} unmanaged: this ACP agent has no verified mode mapping; use Kimi Code CLI or add an agent-specific mapping`);
204
+ if (this.modeUnknown) throw new Error(`${this.id} permission mode unknown after an unanswered change; restart the peer before changing modes`);
205
+ const id = KIMI_MODE_IDS[mode];
206
+ if (!this.sessionId || !this.proc || !this.availableModes.has(id)) throw new Error(`${this.id} permission mode ${mode} unavailable: session does not offer ${id}`);
207
+ try {
208
+ await this.request("session/set_mode", { sessionId: this.sessionId, modeId: id }, this.opts.permissionModeTimeoutMs ?? HANDSHAKE_MS);
209
+ } catch (error) {
210
+ if (error instanceof RpcTimeoutError) {
211
+ // No reply means the native mode may have changed. Invalidate the turn before stopping
212
+ // our verified process group; neither a late prompt result nor a late mode ack restores idle.
213
+ this.modeUnknown = true;
214
+ this.turn++;
215
+ this.setState("offline");
216
+ await this.stop().catch((stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
217
+ throw new Error(`${this.id} permission mode ${mode} unknown: ${error.message}; peer is offline, restart it before sending work`);
218
+ }
219
+ throw new Error(`${this.id} permission mode ${mode} refused: ${(error as Error).message}`);
220
+ }
221
+ if (this.modeUnknown) throw new Error(`${this.id} permission mode unknown; restart the peer`);
222
+ this.permissionMode = mode;
223
+ }
224
+
172
225
  async stop(): Promise<void> {
173
226
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP session stopped before settlement" });
174
227
  this.activeDeliveryId = undefined;
@@ -350,10 +403,18 @@ export class AcpPeer extends BasePeer {
350
403
  });
351
404
  }
352
405
 
353
- private request(method: string, params: unknown): Promise<any> {
406
+ private request(method: string, params: unknown, timeoutMs?: number): Promise<any> {
354
407
  const id = this.nextId++;
355
408
  return new Promise((resolve, reject) => {
356
- this.pending.set(id, { resolve, reject });
409
+ const timer = timeoutMs === undefined ? undefined : setTimeout(() => {
410
+ this.pending.delete(id);
411
+ reject(new RpcTimeoutError(`${method} did not answer within ${timeoutMs / 1000} s`));
412
+ }, timeoutMs);
413
+ timer?.unref();
414
+ this.pending.set(id, {
415
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
416
+ reject: (error) => { clearTimeout(timer); reject(error); },
417
+ });
357
418
  this.send({ jsonrpc: "2.0", id, method, params });
358
419
  });
359
420
  }
@@ -1,6 +1,7 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import type { Server, ServerWebSocket } from "bun";
3
3
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
+ import type { PermissionMode } from "../hub/permission-mode.ts";
4
5
  import { BasePeer } from "../hub/peers.ts";
5
6
  import { codexContext, type ContextReading } from "../hub/context-window.ts";
6
7
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
@@ -8,6 +9,7 @@ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process
8
9
  export interface CodexOptions {
9
10
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
10
11
  proxyPort: number;
12
+ permissionMode?: PermissionMode;
11
13
  /** Port for the spawned `codex app-server`. Ignored when `upstreamUrl` is set. */
12
14
  appPort: number;
13
15
  /** Attach to an already running app-server instead of spawning one (tests). */
@@ -60,6 +62,10 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
60
62
  * TUI's, and their responses are swallowed.
61
63
  */
62
64
  export class CodexPeer extends BasePeer {
65
+ private permissionMode: PermissionMode;
66
+ private nativeApprovalPolicy: unknown;
67
+ private approvalOverridden = false;
68
+ private readonly restoringApprovals = new Set<string | number>();
63
69
  private proc: ChildProcess | undefined;
64
70
  private server: Server<Link> | undefined;
65
71
  private link: Link | undefined; // the connection that owns the current thread
@@ -96,6 +102,7 @@ export class CodexPeer extends BasePeer {
96
102
  private readonly opts: CodexOptions,
97
103
  ) {
98
104
  super(id, opts.watchdogMs);
105
+ this.permissionMode = opts.permissionMode ?? "ask";
99
106
  }
100
107
 
101
108
  recoveryMetadata(): Record<string, unknown> {
@@ -184,6 +191,7 @@ export class CodexPeer extends BasePeer {
184
191
  this.pending.set(id, {
185
192
  deliveryId, kind: "deliver",
186
193
  resolve: (result) => {
194
+ if (this.restoringApprovals.delete(id) && this.permissionMode === "ask") this.approvalOverridden = false;
187
195
  this.primed = true;
188
196
  this.injected = replyParent(envs);
189
197
  this.answering = [...envs];
@@ -196,13 +204,14 @@ export class CodexPeer extends BasePeer {
196
204
  resolve();
197
205
  },
198
206
  reject: (e) => {
207
+ this.restoringApprovals.delete(id);
199
208
  if (!this.activeTurns.size) this.setState("idle");
200
209
  if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
201
210
  reject(e);
202
211
  },
203
212
  });
204
213
  link.up.send(
205
- JSON.stringify({ method: "turn/start", id, params: { threadId: this.threadId, input: [{ type: "text", text }] } }),
214
+ JSON.stringify({ method: "turn/start", id, params: this.applyPermissionPolicy({ threadId: this.threadId, input: [{ type: "text", text }] }, id) }),
206
215
  );
207
216
  });
208
217
  }
@@ -350,6 +359,7 @@ export class CodexPeer extends BasePeer {
350
359
  if (this.link !== link) return;
351
360
  clearInterval(this.usageTimer);
352
361
  this.link = undefined;
362
+ this.restoringApprovals.clear();
353
363
  this.threadId = "";
354
364
  this.contextEpoch++;
355
365
  this.activeTurns.clear();
@@ -372,8 +382,55 @@ export class CodexPeer extends BasePeer {
372
382
  this.lastAnswer = "";
373
383
  }
374
384
 
385
+ get proxyAttached(): boolean { return !!this.link && this.link.up.readyState === WebSocket.OPEN; }
386
+
387
+ getPermissionMode(): PermissionMode { return this.permissionMode; }
388
+
389
+ /** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
390
+ setStartupPermissionMode(mode: PermissionMode): void {
391
+ if (this.link || this.state !== "offline" || this.approvalOverridden) throw new Error("codex startup mode reconciliation requires a fresh unattached proxy");
392
+ this.permissionMode = mode;
393
+ }
394
+
395
+ /**
396
+ * The hub's choice was cleared while no TUI is attached: this proxy sends no override from now on. Returns false
397
+ * when an override it already sent cannot be undone on that thread (its native policy was never reported): a thread
398
+ * started or resumed through the proxy afterwards reports its own policy, and that one is left alone.
399
+ */
400
+ clearPermissionMode(): boolean {
401
+ const restorable = !this.approvalOverridden || this.nativeApprovalPolicy !== undefined;
402
+ this.permissionMode = "ask";
403
+ return restorable;
404
+ }
405
+
406
+ async setPermissionMode(mode: PermissionMode): Promise<void> {
407
+ if (!this.link || this.link.up.readyState !== WebSocket.OPEN) throw new Error("codex permission mode unavailable: attach the TUI through ahub codex first");
408
+ if (mode === "ask" && this.approvalOverridden && this.nativeApprovalPolicy === undefined) throw new Error("codex cannot restore ask: native approval policy unavailable; restart the Codex session");
409
+ this.permissionMode = mode;
410
+ }
411
+
412
+ private applyPermissionPolicy(params: Record<string, unknown>, id: string | number): Record<string, unknown> {
413
+ if (this.permissionMode !== "ask") {
414
+ this.approvalOverridden = true;
415
+ return { ...params, approvalPolicy: this.permissionMode === "never-ask" ? "never" : "on-request" };
416
+ }
417
+ if (this.permissionMode === "ask" && this.approvalOverridden) {
418
+ this.restoringApprovals.add(id);
419
+ return { ...params, approvalPolicy: this.nativeApprovalPolicy };
420
+ }
421
+ return params;
422
+ }
423
+
375
424
  private fromTui(link: Link, raw: string): void {
376
425
  const msg = parse(raw);
426
+ if (msg?.method === "turn/start" && msg.params && link === this.link) {
427
+ if (msg.params.approvalPolicy != null) this.nativeApprovalPolicy = msg.params.approvalPolicy;
428
+ if (this.permissionMode !== "ask" || this.approvalOverridden) {
429
+ // Native turn overrides persist: ask restores the captured native policy once on successful admission.
430
+ msg.params = this.applyPermissionPolicy(msg.params, msg.id);
431
+ raw = JSON.stringify(msg);
432
+ }
433
+ }
377
434
  if (msg?.id !== undefined && TRACKED.has(msg.method)) link.tracked.set(msg.id, msg.method);
378
435
  if (link.up.readyState === WebSocket.OPEN) link.up.send(raw);
379
436
  else link.backlog.push(raw);
@@ -391,9 +448,17 @@ export class CodexPeer extends BasePeer {
391
448
  else p?.resolve(msg.result);
392
449
  return; // ours: the TUI never asked for it
393
450
  }
451
+ if (msg.id !== undefined && !msg.method && this.restoringApprovals.delete(msg.id) && !msg.error && this.permissionMode === "ask") this.approvalOverridden = false;
394
452
  if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
395
453
  const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
396
- if (tracked && link.tracked.delete(msg.id)) this.adopt(link, msg.result?.thread?.id, tracked === "thread/start");
454
+ if (tracked && link.tracked.delete(msg.id)) {
455
+ if (typeof msg.result?.thread?.id === "string") {
456
+ this.nativeApprovalPolicy = msg.result?.approvalPolicy;
457
+ this.approvalOverridden = false;
458
+ this.restoringApprovals.clear();
459
+ this.adopt(link, msg.result.thread.id, tracked === "thread/start");
460
+ }
461
+ }
397
462
  else if (msg.method) this.onNotification(link, msg.method, msg.params ?? {});
398
463
  link.tui.send(raw);
399
464
  }
@@ -264,7 +264,8 @@ export class LocalPeer extends BasePeer {
264
264
  const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
265
265
  msgs.push(result); results.push(result);
266
266
  this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
267
- capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
267
+ // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
268
+ if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
268
269
  }
269
270
  } finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
270
271
  }
@@ -276,13 +277,13 @@ export class LocalPeer extends BasePeer {
276
277
  return {
277
278
  cwd: this.opts.cwd,
278
279
  deny: this.opts.tools.deny,
279
- permit: async (title) => {
280
+ permit: async (title, tool, _signal, path) => {
280
281
  if (turnSignal.aborted) return false;
281
282
  return new Promise<boolean>((resolve, reject) => {
282
283
  const finish = (allowed: boolean) => { turnSignal.removeEventListener("abort", onAbort); resolve(allowed); };
283
284
  const onAbort = () => finish(false);
284
285
  turnSignal.addEventListener("abort", onAbort, { once: true });
285
- this.opts.tools.permit(title).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
286
+ this.opts.tools.permit(title, tool, turnSignal, path).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
286
287
  });
287
288
  },
288
289
  sandboxProfile: this.sandboxProfile,
@@ -15,6 +15,16 @@ import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-bu
15
15
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
16
16
  export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
17
17
  export interface PiRelay { url: string; token: string; models: PiModelDescriptor[]; }
18
+ export interface PiExit {
19
+ cause: "process_exit" | "spawn_error" | "session_shutdown" | "owner_lost" | "owner_stopped";
20
+ code: number | null;
21
+ signal: NodeJS.Signals | null;
22
+ expected: boolean;
23
+ started: boolean;
24
+ turnActive: boolean;
25
+ toolActive: boolean;
26
+ lastToolName?: string;
27
+ }
18
28
  export interface PiOptions {
19
29
  cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
20
30
  model?: string;
@@ -28,6 +38,8 @@ export interface PiOptions {
28
38
  /** `ceiling` is the validated, session/turn-bound tool-step ceiling signal (#179), present only when the trusted extension emitted one for this turn. */
29
39
  onTurnFailure?: (envs: Envelope[], reason: string, ceiling?: PiToolStepCeiling) => Promise<void>;
30
40
  watchdogMs?: number; log?: (line: string) => void;
41
+ /** Once per owner, after it is offline; a TUI owner may have unknown OS exit status. */
42
+ onExit?: (exit: PiExit) => void;
31
43
  /** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
32
44
  stopGraceMs?: number;
33
45
  }
@@ -74,6 +86,10 @@ export class PiPeer extends BasePeer {
74
86
  private ownerClaimed = false;
75
87
  private ownerToken = "";
76
88
  private stopping = true;
89
+ private exitReported = false;
90
+ private started = false;
91
+ private lastToolName?: string;
92
+ private shutdownExit?: PiExit;
77
93
  private ownerPid?: number;
78
94
  private ownerSignature?: string;
79
95
  private ownerMonitor?: ReturnType<typeof setInterval>;
@@ -179,6 +195,8 @@ export class PiPeer extends BasePeer {
179
195
  if (reservation.deadlineAt !== undefined) idleBashTimer = setTimeout(() => idleBashAbort.abort(), Math.max(0, reservation.deadlineAt - Date.now()));
180
196
  }
181
197
  this.noteActivity();
198
+ const name = String(body.name);
199
+ this.lastToolName = this.opts.tools.some((tool) => tool.name === name) && /^[a-zA-Z0-9_.-]{1,128}$/.test(name) ? name : "unknown";
182
200
  this.activeTools++;
183
201
  if (this.state === "idle") this.setState("busy");
184
202
  if (this.state === "busy") this.touch();
@@ -230,6 +248,7 @@ export class PiPeer extends BasePeer {
230
248
 
231
249
  private async startImpl(): Promise<void> {
232
250
  this.stopping = false;
251
+ this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
233
252
  mkdirSync(this.opts.stateDir, { recursive: true });
234
253
  const sessions = join(this.opts.stateDir, "pi-sessions");
235
254
  mkdirSync(sessions, { recursive: true, mode: 0o700 });
@@ -275,8 +294,17 @@ export class PiPeer extends BasePeer {
275
294
  trackGroup(this.proc);
276
295
  this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
277
296
  this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
278
- this.proc.on("error", (error) => this.fail(error));
279
- this.proc.on("exit", (code) => { if (code !== 0) this.fail(new Error(`pi exited with code ${code}`)); else this.setState("offline"); });
297
+ const proc = this.proc;
298
+ proc.on("error", (error) => {
299
+ const exit = this.exitMetadata("spawn_error");
300
+ this.fail(error);
301
+ this.reportExit(exit);
302
+ });
303
+ proc.on("exit", (code, signal) => {
304
+ const exit = { ...(this.shutdownExit ?? this.exitMetadata("process_exit")), code, signal };
305
+ this.fail(new Error(`pi exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
306
+ this.reportExit(exit);
307
+ });
280
308
  const state = await this.waitRpc("get_state", 15_000);
281
309
  if (state.success !== true || typeof state.data?.sessionId !== "string" || typeof state.data?.sessionFile !== "string" || !state.data.sessionId || !state.data.sessionFile) throw new Error("Pi get_state did not prove session identity");
282
310
  if ((this.opts.sessionId && state.data.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && state.data.sessionFile !== this.opts.sessionFile)) {
@@ -285,10 +313,13 @@ export class PiPeer extends BasePeer {
285
313
  this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
286
314
  this.persistedOnce = existsSync(this.sessionFile);
287
315
  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
+ this.started = true;
288
317
  this.setState("idle");
289
318
  }
290
319
 
291
320
  async stop(): Promise<void> {
321
+ const tuiExit = this.opts.mode === "tui" && this.ownerClaimed && this.ownerPid && this.ownerSignature
322
+ ? { ...this.exitMetadata("owner_stopped"), expected: true } : undefined;
292
323
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi session stopped before settlement" });
293
324
  this.activeDeliveryIds.clear();
294
325
  this.stopping = true;
@@ -329,6 +360,7 @@ export class PiPeer extends BasePeer {
329
360
  this.pending.clear();
330
361
  this.server?.stop(true); this.server = undefined; this.setState("offline");
331
362
  if (failed) throw failed;
363
+ if (tuiExit) this.reportExit(tuiExit); // the verified owner is gone; never invent an exit for an unclaimed launch
332
364
  }
333
365
 
334
366
  /**
@@ -416,9 +448,15 @@ export class PiPeer extends BasePeer {
416
448
  this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
417
449
  this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
418
450
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
419
- this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
451
+ this.startOwnerMonitor(); if (this.opts.mode === "tui") { this.started = true; this.setState("idle"); }
452
+ }
453
+ if (event.type === "session_shutdown") {
454
+ const exit = this.exitMetadata("session_shutdown");
455
+ this.shutdownExit ??= exit; // headless waits for the OS exit, retaining the pre-cleanup turn and stop facts
456
+ this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
457
+ this.fail(new Error("Pi session shut down before settlement"));
458
+ if (this.opts.mode === "tui") this.reportExit(exit);
420
459
  }
421
- if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.fail(new Error("Pi session shut down before settlement")); }
422
460
  if (event.type === "agent_start") {
423
461
  const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
424
462
  if (generation <= this.budgetGeneration) return;
@@ -507,6 +545,20 @@ export class PiPeer extends BasePeer {
507
545
  try { return await Promise.race([this.sendRpc({ type: command }), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`Pi ${command} timed out`)), timeout); })]); }
508
546
  finally { clearTimeout(timer); }
509
547
  }
548
+ private exitMetadata(cause: PiExit["cause"]): PiExit {
549
+ return { cause, code: null, signal: null, expected: this.stopping, started: this.started,
550
+ turnActive: this.agentRunning || this.activeEnvs.length > 0, toolActive: this.activeTools > 0,
551
+ ...(this.lastToolName ? { lastToolName: this.lastToolName } : {}) };
552
+ }
553
+
554
+ private reportExit(exit: PiExit): void {
555
+ if (this.exitReported) return;
556
+ this.exitReported = true;
557
+ const line = `[${this.id}] Pi exit: cause=${exit.cause} code=${exit.code ?? "unknown"} signal=${exit.signal ?? "unknown"} expected=${exit.expected} started=${exit.started} turnActive=${exit.turnActive} toolActive=${exit.toolActive} lastTool=${exit.lastToolName ?? "none"}`;
558
+ try { this.opts.log?.(line); } catch { /* reporting must not prevent owner settlement */ }
559
+ try { this.opts.onExit?.(exit); } catch { /* the owner is already offline */ }
560
+ }
561
+
510
562
  private fail(error: Error): void {
511
563
  for (const pending of this.pending.values()) pending.reject(error);
512
564
  this.pending.clear();
@@ -525,11 +577,13 @@ export class PiPeer extends BasePeer {
525
577
  if (this.opts.mode !== "tui" || !this.ownerPid || !this.ownerSignature) return;
526
578
  this.ownerMonitor = setInterval(() => {
527
579
  if (!this.ownerPid || ownerStillAlive(this.ownerPid, this.ownerSignature)) return;
580
+ const exit = this.exitMetadata("owner_lost");
528
581
  this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
529
582
  this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
530
583
  for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
531
584
  this.tuiCommands.clear(); this.tuiQueue = [];
532
585
  this.fail(new Error("Pi owner process exited without session_shutdown"));
586
+ this.reportExit(exit);
533
587
  }, 500);
534
588
  this.ownerMonitor.unref?.();
535
589
  }