@staix/agent-hub 0.12.20 → 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 (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -9
  3. package/docs/agent-notes/adapters.md +13 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +7 -3
  6. package/docs/agent-notes/daemon.md +12 -2
  7. package/docs/agent-notes/local-worker.md +5 -1
  8. package/docs/agent-notes/tasks.md +4 -2
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +15 -5
  12. package/docs/operations.md +510 -54
  13. package/docs/quickstart.md +16 -11
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +49 -11
  16. package/docs/smoke.md +138 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
  19. package/docs/verified.json +34 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +3 -2
  23. package/src/adapters/acp.ts +77 -10
  24. package/src/adapters/codex-appserver.ts +72 -2
  25. package/src/adapters/local-worker.ts +46 -11
  26. package/src/adapters/pi.ts +215 -22
  27. package/src/cli/bench.ts +287 -0
  28. package/src/cli/console-state.ts +226 -28
  29. package/src/cli/console.ts +16 -4
  30. package/src/cli/facts-hook.ts +50 -8
  31. package/src/cli/help.ts +16 -5
  32. package/src/cli/identity.ts +10 -4
  33. package/src/cli/launch.ts +272 -18
  34. package/src/cli/main.ts +290 -30
  35. package/src/cli/preview.ts +13 -3
  36. package/src/cli/recovery-package.ts +17 -0
  37. package/src/cli/status-lines.ts +2 -1
  38. package/src/cli/terminal-recovery.ts +83 -31
  39. package/src/cli/upgrade-interactive.ts +297 -0
  40. package/src/cli/upgrade-runtime.ts +123 -20
  41. package/src/cli/upgrade.ts +108 -19
  42. package/src/hub/bench.ts +211 -0
  43. package/src/hub/bus.ts +24 -7
  44. package/src/hub/conductor.ts +22 -21
  45. package/src/hub/config-trust.ts +1 -0
  46. package/src/hub/control-client.ts +4 -1
  47. package/src/hub/daemon.ts +477 -86
  48. package/src/hub/delivery-journal.ts +6 -6
  49. package/src/hub/events.ts +4 -2
  50. package/src/hub/lifecycle.ts +8 -13
  51. package/src/hub/manager.ts +16 -14
  52. package/src/hub/peers.ts +2 -2
  53. package/src/hub/permission-mode.ts +44 -0
  54. package/src/hub/recovery-store.ts +59 -9
  55. package/src/hub/registry.ts +36 -22
  56. package/src/hub/research.ts +255 -0
  57. package/src/hub/tasks.ts +99 -55
  58. package/src/hub/tool-refusal.ts +7 -0
  59. package/src/hub/ui.ts +4 -1
  60. package/src/local/deny.ts +48 -1
  61. package/src/local/sandbox.ts +6 -3
  62. package/src/local/tools.ts +34 -11
  63. package/src/pi/extension.ts +43 -20
  64. package/src/pi/process-signature.ts +23 -0
  65. package/src/pi/tool-receipts.ts +24 -2
  66. package/src/ui/index.html +98 -17
  67. package/src/ui/task-progress.ts +33 -0
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
5
+ "verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
6
6
  "paths": [
7
7
  "package.json",
8
8
  "src/",
@@ -11,14 +11,14 @@
11
11
  ]
12
12
  },
13
13
  "docs/security.md": {
14
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
14
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
21
+ "verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
22
22
  "paths": [
23
23
  "package.json",
24
24
  "src/",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "docs/quickstart.md": {
38
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
38
+ "verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
39
39
  "paths": [
40
40
  "package.json",
41
41
  "src/",
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "docs/agent-notes/adapters.md": {
46
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
46
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -52,11 +52,15 @@
52
52
  "src/hub/lifecycle.ts",
53
53
  "src/memory/capture.ts",
54
54
  "scripts/benchmarks/teardown.ts",
55
- "src/hub/daemon.ts"
55
+ "src/hub/daemon.ts",
56
+ "src/hub/permission-mode.ts",
57
+ "src/hub/tool-refusal.ts",
58
+ "src/cli/identity.ts",
59
+ "src/local/tools.ts"
56
60
  ]
57
61
  },
58
62
  "docs/agent-notes/benchmarks.md": {
59
- "verifiedAgainst": "84a3c404d3fe3e293495009cffa11b204d0d3523",
63
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
60
64
  "paths": [
61
65
  "scripts/benchmarks/",
62
66
  "test/benchmarks/",
@@ -68,7 +72,7 @@
68
72
  ]
69
73
  },
70
74
  "docs/agent-notes/budget.md": {
71
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
75
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
72
76
  "paths": [
73
77
  "src/hub/budget.ts",
74
78
  "src/cli/statusline-tee.ts",
@@ -80,7 +84,7 @@
80
84
  ]
81
85
  },
82
86
  "docs/agent-notes/bus.md": {
83
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
87
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
84
88
  "paths": [
85
89
  "src/hub/bus.ts",
86
90
  "src/hub/envelope.ts",
@@ -92,7 +96,7 @@
92
96
  ]
93
97
  },
94
98
  "docs/agent-notes/daemon.md": {
95
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
99
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
96
100
  "paths": [
97
101
  "src/hub/daemon.ts",
98
102
  "src/hub/control-client.ts",
@@ -114,16 +118,22 @@
114
118
  "src/cli/console.ts",
115
119
  "src/cli/console-state.ts",
116
120
  "src/cli/identity.ts",
117
- "src/cli/identity-audit.ts"
121
+ "src/cli/identity-audit.ts",
122
+ "src/hub/registry.ts",
123
+ "src/cli/main.ts",
124
+ "src/cli/launch.ts",
125
+ "src/hub/child-process.ts",
126
+ "src/hub/bus.ts"
118
127
  ]
119
128
  },
120
129
  "docs/agent-notes/local-worker.md": {
121
- "verifiedAgainst": "84a3c404d3fe3e293495009cffa11b204d0d3523",
130
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
122
131
  "paths": [
123
132
  "src/adapters/local-worker.ts",
124
133
  "src/local/",
125
134
  "src/memory/capture.ts",
126
- "src/hub/facts.ts"
135
+ "src/hub/facts.ts",
136
+ "src/hub/permission-mode.ts"
127
137
  ]
128
138
  },
129
139
  "docs/agent-notes/models.md": {
@@ -136,7 +146,7 @@
136
146
  ]
137
147
  },
138
148
  "docs/agent-notes/tasks.md": {
139
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
149
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
140
150
  "paths": [
141
151
  "src/hub/tasks.ts",
142
152
  "src/hub/board.ts",
@@ -147,11 +157,13 @@
147
157
  "src/cli/main.ts",
148
158
  "src/cli/preview.ts",
149
159
  "src/hub/conductor.ts",
150
- "src/hub/supervision.ts"
160
+ "src/hub/supervision.ts",
161
+ "src/hub/tool-refusal.ts",
162
+ "src/hub/daemon.ts"
151
163
  ]
152
164
  },
153
165
  "docs/agent-notes/tests.md": {
154
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
166
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
155
167
  "paths": [
156
168
  "test/",
157
169
  "scripts/check.sh",
@@ -161,7 +173,12 @@
161
173
  "scripts/ci-gates.mjs",
162
174
  "scripts/ci-reuse.mjs",
163
175
  ".github/workflows/check.yml",
164
- ".github/workflows/release.yml"
176
+ ".github/workflows/release.yml",
177
+ "src/hub/child-process.ts",
178
+ "src/switchyard/sidecar.ts",
179
+ "src/local/sandbox.ts",
180
+ "src/models/mlx.ts",
181
+ "src/cli/upgrade-interactive.ts"
165
182
  ]
166
183
  }
167
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.20",
3
+ "version": "0.12.22",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.12.20",
3
+ "version": "0.12.22",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -15306,7 +15306,8 @@ function readControl(stateDir) {
15306
15306
  instanceId: status.instanceId,
15307
15307
  cwd: status.cwd,
15308
15308
  protocol: status.protocol,
15309
- pid: status.pid
15309
+ pid: status.pid,
15310
+ ...typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}
15310
15311
  };
15311
15312
  } catch {
15312
15313
  return;
@@ -15403,7 +15404,7 @@ class ControlClient {
15403
15404
  // package.json
15404
15405
  var package_default = {
15405
15406
  name: "@staix/agent-hub",
15406
- version: "0.12.20",
15407
+ version: "0.12.22",
15407
15408
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15408
15409
  license: "MIT",
15409
15410
  type: "module",
@@ -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;
@@ -118,6 +128,7 @@ export class AcpPeer extends BasePeer {
118
128
  private readonly toolTitles = new Map<string, string>(); // the title a call was announced with, per call id (#138)
119
129
  private primed = false;
120
130
  private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
131
+ private stopping = false;
121
132
  private activeDeliveryId: string | undefined;
122
133
  private deliveryAccepted = false;
123
134
 
@@ -126,6 +137,7 @@ export class AcpPeer extends BasePeer {
126
137
  private readonly opts: AcpOptions,
127
138
  ) {
128
139
  super(id, opts.watchdogMs);
140
+ this.permissionMode = opts.permissionMode ?? "ask";
129
141
  }
130
142
 
131
143
  recoveryMetadata(): Record<string, unknown> {
@@ -133,6 +145,7 @@ export class AcpPeer extends BasePeer {
133
145
  }
134
146
 
135
147
  async start(): Promise<void> {
148
+ this.stopping = false;
136
149
  const [bin, ...args] = this.opts.cmd;
137
150
  // 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.
138
151
  const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
@@ -149,38 +162,83 @@ export class AcpPeer extends BasePeer {
149
162
  protocolVersion: 1,
150
163
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
151
164
  });
165
+ // The configured peer id and command are labels, not an agent-specific mode contract.
166
+ // Installed Kimi Code CLI's ACP initialize advertises this exact name.
167
+ this.kimiAgent = init?.agentInfo?.name === "Kimi Code CLI";
152
168
  const resume = this.opts.resumeSessionId;
153
169
  if (!resume) return this.request("session/new", { cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
154
170
  // The agent replays the session as updates while it loads; they arrive before the peer is idle, so none of
155
171
  // them is taken for an answer.
156
172
  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 };
173
+ const loaded = await this.request("session/load", { sessionId: resume, cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
174
+ return { ...loaded, sessionId: resume };
159
175
  };
160
176
  const timeout = new Promise<never>((_, reject) => {
161
177
  setTimeout(() => reject(new Error(`${this.id} did not complete the ACP handshake within ${HANDSHAKE_MS / 1000} s`)), HANDSHAKE_MS).unref();
162
178
  });
163
179
  try {
164
- this.sessionId = (await Promise.race([handshake(), timeout])).sessionId;
180
+ const session = await Promise.race([handshake(), timeout]);
181
+ this.sessionId = session.sessionId;
182
+ const modes: unknown = session.modes?.availableModes;
183
+ this.availableModes = new Set(Array.isArray(modes) ? modes.flatMap((mode) => typeof mode?.id === "string" ? [mode.id] : []) : []);
184
+ // A recovered session can retain a previous runtime opt-in. An advertised non-default mode
185
+ // must be reset for the configured ask default before any prompt; a fresh ask session stays untouched.
186
+ const resetResumed = this.opts.resumeSessionId && typeof session.modes?.currentModeId === "string" && session.modes.currentModeId !== KIMI_MODE_IDS.ask;
187
+ // Other ACP vendors retain their native policy, including reported non-default modes on resume.
188
+ if (this.kimiAgent && (this.permissionMode !== "ask" || resetResumed)) await this.setPermissionMode(this.permissionMode);
165
189
  } catch (e) {
166
- await stopOwnedProcess(proc, { group: true }).catch((stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
190
+ this.setState("offline");
191
+ if (this.proc === proc) {
192
+ await stopOwnedProcess(proc, { group: true }).then(() => { if (this.proc === proc) this.proc = undefined; }, (stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
193
+ }
167
194
  throw e;
168
195
  }
169
196
  this.setState("idle");
170
197
  }
171
198
 
172
- async stop(): Promise<void> {
173
- if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP session stopped before settlement" });
199
+ getPermissionMode(): PermissionMode { return this.permissionMode; }
200
+ get permissionModeState(): PermissionMode | "unknown" | "unmanaged" {
201
+ return !this.kimiAgent ? "unmanaged" : this.modeUnknown ? "unknown" : this.permissionMode;
202
+ }
203
+
204
+ async setPermissionMode(mode: PermissionMode): Promise<void> {
205
+ 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`);
206
+ if (this.modeUnknown) throw new Error(`${this.id} permission mode unknown after an unanswered change; restart the peer before changing modes`);
207
+ const id = KIMI_MODE_IDS[mode];
208
+ if (!this.sessionId || !this.proc || !this.availableModes.has(id)) throw new Error(`${this.id} permission mode ${mode} unavailable: session does not offer ${id}`);
209
+ try {
210
+ await this.request("session/set_mode", { sessionId: this.sessionId, modeId: id }, this.opts.permissionModeTimeoutMs ?? HANDSHAKE_MS);
211
+ } catch (error) {
212
+ if (error instanceof RpcTimeoutError) {
213
+ // No reply means the native mode may have changed. Invalidate the turn before stopping
214
+ // our verified process group; neither a late prompt result nor a late mode ack restores idle.
215
+ this.modeUnknown = true;
216
+ this.turn++;
217
+ this.setState("offline");
218
+ await this.stop().catch((stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
219
+ throw new Error(`${this.id} permission mode ${mode} unknown: ${error.message}; peer is offline, restart it before sending work`);
220
+ }
221
+ throw new Error(`${this.id} permission mode ${mode} refused: ${(error as Error).message}`);
222
+ }
223
+ if (this.modeUnknown) throw new Error(`${this.id} permission mode unknown; restart the peer`);
224
+ this.permissionMode = mode;
225
+ }
226
+
227
+ async stop(reason?: string): Promise<void> {
228
+ this.stopping = true;
229
+ if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
230
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "ACP session stopped before settlement" });
174
231
  this.activeDeliveryId = undefined;
175
232
  const proc = this.proc;
176
- if (!proc) return;
233
+ if (!proc) { this.setState("offline"); return; }
177
234
  await stopOwnedProcess(proc, { group: true }); // also when it exited: what it left in its group fails the stop
178
235
  if (this.proc === proc) this.proc = undefined;
236
+ this.setState("offline");
179
237
  }
180
238
 
181
239
  /** Resolves once the prompt is in flight; the turn result arrives on its own. */
182
240
  async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
183
- if (this.state !== "idle") {
241
+ if (this.state !== "idle" || this.stopping) {
184
242
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
185
243
  throw new Error(`${this.id} is ${this.state}`);
186
244
  }
@@ -260,6 +318,7 @@ export class AcpPeer extends BasePeer {
260
318
  }
261
319
 
262
320
  private onLine(line: string): void {
321
+ if (this.stopping) return; // buffered native callbacks cannot recreate approvals after peer_stop
263
322
  let msg: any;
264
323
  try {
265
324
  msg = JSON.parse(line);
@@ -350,10 +409,18 @@ export class AcpPeer extends BasePeer {
350
409
  });
351
410
  }
352
411
 
353
- private request(method: string, params: unknown): Promise<any> {
412
+ private request(method: string, params: unknown, timeoutMs?: number): Promise<any> {
354
413
  const id = this.nextId++;
355
414
  return new Promise((resolve, reject) => {
356
- this.pending.set(id, { resolve, reject });
415
+ const timer = timeoutMs === undefined ? undefined : setTimeout(() => {
416
+ this.pending.delete(id);
417
+ reject(new RpcTimeoutError(`${method} did not answer within ${timeoutMs / 1000} s`));
418
+ }, timeoutMs);
419
+ timer?.unref();
420
+ this.pending.set(id, {
421
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
422
+ reject: (error) => { clearTimeout(timer); reject(error); },
423
+ });
357
424
  this.send({ jsonrpc: "2.0", id, method, params });
358
425
  });
359
426
  }
@@ -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 readonly nativeApprovalPolicies = new Map<string, unknown>();
67
+ private readonly overriddenApprovals = new Set<string>();
68
+ private readonly restoringApprovals = new Map<string | number, string>();
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> {
@@ -196,13 +203,14 @@ export class CodexPeer extends BasePeer {
196
203
  resolve();
197
204
  },
198
205
  reject: (e) => {
206
+ this.restoringApprovals.delete(id);
199
207
  if (!this.activeTurns.size) this.setState("idle");
200
208
  if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
201
209
  reject(e);
202
210
  },
203
211
  });
204
212
  link.up.send(
205
- JSON.stringify({ method: "turn/start", id, params: { threadId: this.threadId, input: [{ type: "text", text }] } }),
213
+ JSON.stringify({ method: "turn/start", id, params: this.applyPermissionPolicy({ threadId: this.threadId, input: [{ type: "text", text }] }, id) }),
206
214
  );
207
215
  });
208
216
  }
@@ -350,6 +358,7 @@ export class CodexPeer extends BasePeer {
350
358
  if (this.link !== link) return;
351
359
  clearInterval(this.usageTimer);
352
360
  this.link = undefined;
361
+ this.restoringApprovals.clear();
353
362
  this.threadId = "";
354
363
  this.contextEpoch++;
355
364
  this.activeTurns.clear();
@@ -372,8 +381,61 @@ export class CodexPeer extends BasePeer {
372
381
  this.lastAnswer = "";
373
382
  }
374
383
 
384
+ get proxyAttached(): boolean { return !!this.link && this.link.up.readyState === WebSocket.OPEN; }
385
+
386
+ getPermissionMode(): PermissionMode { return this.permissionMode; }
387
+
388
+ /** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
389
+ setStartupPermissionMode(mode: PermissionMode): void {
390
+ if (this.link || this.state !== "offline" || this.overriddenApprovals.size) throw new Error("codex startup mode reconciliation requires a fresh unattached proxy");
391
+ this.permissionMode = mode;
392
+ }
393
+
394
+ /** Clear the overlay while detached, retaining each thread's restoration debt across a resume. */
395
+ clearPermissionMode(): boolean {
396
+ const restorable = [...this.overriddenApprovals].every(thread => this.nativeApprovalPolicies.has(thread));
397
+ this.permissionMode = "ask";
398
+ return restorable;
399
+ }
400
+
401
+ async setPermissionMode(mode: PermissionMode): Promise<void> {
402
+ if (!this.link || this.link.up.readyState !== WebSocket.OPEN) throw new Error("codex permission mode unavailable: attach the TUI through ahub codex first");
403
+ if (mode === "ask" && [...this.overriddenApprovals].some(thread => !this.nativeApprovalPolicies.has(thread))) throw new Error("codex cannot restore ask: native approval policy unavailable; close the TUI, then run ahub permission codex ask before you restart the Codex session with ahub codex");
404
+ this.permissionMode = mode;
405
+ }
406
+
407
+ private applyPermissionPolicy(params: Record<string, unknown>, id: string | number): Record<string, unknown> {
408
+ const thread = typeof params.threadId === "string" ? params.threadId : this.threadId;
409
+ if (this.permissionMode !== "ask") {
410
+ this.overriddenApprovals.add(thread);
411
+ return { ...params, approvalPolicy: this.permissionMode === "never-ask" ? "never" : "on-request" };
412
+ }
413
+ if (this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread)) {
414
+ this.restoringApprovals.set(id, thread);
415
+ return { ...params, approvalPolicy: this.nativeApprovalPolicies.get(thread) };
416
+ }
417
+ return params;
418
+ }
419
+
420
+ private settlePermissionRestore(id: string | number, failed: boolean): void {
421
+ const thread = this.restoringApprovals.get(id);
422
+ this.restoringApprovals.delete(id);
423
+ if (thread !== undefined && !failed && this.permissionMode === "ask") this.overriddenApprovals.delete(thread);
424
+ }
425
+
375
426
  private fromTui(link: Link, raw: string): void {
376
427
  const msg = parse(raw);
428
+ if (msg?.method === "turn/start" && msg.params && link === this.link) {
429
+ const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
430
+ // A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
431
+ const restoring = this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread);
432
+ if (msg.params.approvalPolicy != null && !restoring) this.nativeApprovalPolicies.set(thread, msg.params.approvalPolicy);
433
+ if (this.permissionMode !== "ask" || this.overriddenApprovals.has(thread)) {
434
+ // Native turn overrides persist: ask restores the captured native policy once on successful admission.
435
+ msg.params = this.applyPermissionPolicy(msg.params, msg.id);
436
+ raw = JSON.stringify(msg);
437
+ }
438
+ }
377
439
  if (msg?.id !== undefined && TRACKED.has(msg.method)) link.tracked.set(msg.id, msg.method);
378
440
  if (link.up.readyState === WebSocket.OPEN) link.up.send(raw);
379
441
  else link.backlog.push(raw);
@@ -384,6 +446,7 @@ export class CodexPeer extends BasePeer {
384
446
  if (this.state === "busy") this.touch();
385
447
  if (!msg) return void link.tui.send(raw);
386
448
 
449
+ if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
387
450
  if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
388
451
  const p = this.pending.get(msg.id);
389
452
  this.pending.delete(msg.id);
@@ -393,7 +456,14 @@ export class CodexPeer extends BasePeer {
393
456
  }
394
457
  if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
395
458
  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");
459
+ if (tracked && link.tracked.delete(msg.id)) {
460
+ if (typeof msg.result?.thread?.id === "string") {
461
+ // A resumed response may report the sticky hub override, not the original native choice.
462
+ const thread = msg.result.thread.id;
463
+ if (!this.overriddenApprovals.has(thread) && msg.result.approvalPolicy != null) this.nativeApprovalPolicies.set(thread, msg.result.approvalPolicy);
464
+ this.adopt(link, msg.result.thread.id, tracked === "thread/start");
465
+ }
466
+ }
397
467
  else if (msg.method) this.onNotification(link, msg.method, msg.params ?? {});
398
468
  link.tui.send(raw);
399
469
  }
@@ -8,7 +8,7 @@ import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, t
8
8
  import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
9
9
  import { BasePeer } from "../hub/peers.ts";
10
10
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
11
- import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
11
+ import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
12
12
  import type { Capture } from "../memory/capture.ts";
13
13
  import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/client.ts";
14
14
  import { safeModelLabel } from "../omniroute/usage.ts";
@@ -32,6 +32,7 @@ export interface LocalOptions {
32
32
  onRouteOutcome?: (event: RouteLabelEvent) => void;
33
33
  /** The daemon turn id created synchronously on busy, shared with turn_start/end. */
34
34
  turnId?: () => string | undefined;
35
+ onApprovalStop?: (reason: string) => void;
35
36
  onTool?: (observation: ToolObservation, task?: string) => void;
36
37
  tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[] };
37
38
  capture?: Capture;
@@ -85,6 +86,9 @@ export class LocalPeer extends BasePeer {
85
86
  private abort: AbortController | undefined;
86
87
  private budgetTimer?: ReturnType<typeof setTimeout>;
87
88
  private budgetStopReason = "";
89
+ private approvalExpiries = 0;
90
+ private approvalAnswerEpoch: number | undefined;
91
+ private approvalStopReason = "";
88
92
  private activeDeliveryId: string | undefined;
89
93
  private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
90
94
  /** What served the last call, for `ahub status`. */
@@ -127,15 +131,15 @@ export class LocalPeer extends BasePeer {
127
131
  this.setState("idle");
128
132
  }
129
133
 
130
- async stop(): Promise<void> {
134
+ async stop(reason?: string): Promise<void> {
131
135
  this.endRouteTurn(this.labelTurnId, "failed");
132
- if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn stopped before settlement" });
136
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "turn stopped before settlement" });
133
137
  this.activeDeliveryId = undefined;
134
138
  this.turn++;
135
139
  clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
136
140
  this.abort?.abort();
141
+ this.setState("offline"); // fence direct delivery before the bounded memory request awaits
137
142
  await this.opts.capture?.end();
138
- this.setState("offline");
139
143
  }
140
144
 
141
145
  /** Resolves once the turn is claimed; a turn that cannot reach any model hands the envelopes back through onFailed. */
@@ -146,6 +150,7 @@ export class LocalPeer extends BasePeer {
146
150
  }
147
151
  const turn = ++this.turn;
148
152
  this.budgetStopReason = "";
153
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = "";
149
154
  clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
150
155
  this.activeDeliveryId = deliveryId;
151
156
  this.abort = new AbortController();
@@ -172,6 +177,14 @@ export class LocalPeer extends BasePeer {
172
177
  })
173
178
  .catch((e: Error) => {
174
179
  if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
180
+ if (e instanceof ApprovalWaitStop) {
181
+ this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
182
+ msgs.push({ role: "assistant", content: e.message });
183
+ if (!policy?.pii) this.commit(msgs);
184
+ this.onMessage?.(e.message, reply);
185
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: e.message });
186
+ return; // even without effects, an unanswered approval must not trigger safe replay
187
+ }
175
188
  if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
176
189
  this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
177
190
  if (e instanceof ExecutionBudgetStop) {
@@ -263,8 +276,10 @@ export class LocalPeer extends BasePeer {
263
276
  if (SIDE_EFFECTS.has(call.function.name) && !output.startsWith("error:")) progress.sideEffects++;
264
277
  const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
265
278
  msgs.push(result); results.push(result);
279
+ if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
266
280
  this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
267
- capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
281
+ // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
282
+ if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
268
283
  }
269
284
  } finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
270
285
  }
@@ -276,14 +291,34 @@ export class LocalPeer extends BasePeer {
276
291
  return {
277
292
  cwd: this.opts.cwd,
278
293
  deny: this.opts.tools.deny,
279
- permit: async (title) => {
280
- if (turnSignal.aborted) return false;
281
- return new Promise<boolean>((resolve, reject) => {
282
- const finish = (allowed: boolean) => { turnSignal.removeEventListener("abort", onAbort); resolve(allowed); };
283
- const onAbort = () => finish(false);
294
+ permit: async (title, tool, _signal, canonicalTarget) => {
295
+ if (turnSignal.aborted) return "aborted";
296
+ const generation = this.turn;
297
+ let observed = false;
298
+ const observe = (provenance: ApprovalProvenance) => {
299
+ observed = true;
300
+ if (generation !== this.turn || turnSignal.aborted || this.approvalStopReason) return;
301
+ if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
302
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
303
+ }
304
+ if (provenance.source === "person") this.approvalExpiries = 0;
305
+ if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
306
+ this.approvalStopReason = "Local turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
307
+ try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
308
+ }
309
+ };
310
+ const picked = await new Promise<ToolApproval>((resolve, reject) => {
311
+ const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
312
+ const onAbort = () => finish("aborted");
284
313
  turnSignal.addEventListener("abort", onAbort, { once: true });
285
- this.opts.tools.permit(title).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
314
+ this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
286
315
  });
316
+ if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
317
+ // Legacy permit callbacks can represent a person's denial without provenance. A true
318
+ // result may be automatic, so it must never reset the unanswered-request streak.
319
+ if (!observed && picked === false) observe({ source: "person", answerEpoch: (this.approvalAnswerEpoch ?? 0) + 1 });
320
+ if (!observed && picked === "expired") observe({ source: "expired", answerEpoch: this.approvalAnswerEpoch ?? 0, eligibleExpiry: true });
321
+ return picked;
287
322
  },
288
323
  sandboxProfile: this.sandboxProfile,
289
324
  sandboxEnv: { ...proxyEnv(this.opts.tools.bashNetwork ?? false), AGENTHUB_PEER_ID: this.id },