@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.
- package/CHANGELOG.md +32 -0
- package/README.md +17 -9
- package/docs/agent-notes/adapters.md +13 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +7 -3
- package/docs/agent-notes/daemon.md +12 -2
- package/docs/agent-notes/local-worker.md +5 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +5 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +15 -5
- package/docs/operations.md +510 -54
- package/docs/quickstart.md +16 -11
- package/docs/research.md +92 -0
- package/docs/security.md +49 -11
- package/docs/smoke.md +138 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +77 -10
- package/src/adapters/codex-appserver.ts +72 -2
- package/src/adapters/local-worker.ts +46 -11
- package/src/adapters/pi.ts +215 -22
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +16 -5
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +272 -18
- package/src/cli/main.ts +290 -30
- package/src/cli/preview.ts +13 -3
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +123 -20
- package/src/cli/upgrade.ts +108 -19
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +24 -7
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +477 -86
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +4 -2
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -2
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +59 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +99 -55
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +34 -11
- package/src/pi/extension.ts +43 -20
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +24 -2
- package/src/ui/index.html +98 -17
- package/src/ui/task-progress.ts +33 -0
package/docs/verified.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"documents": {
|
|
4
4
|
"README.md": {
|
|
5
|
-
"verifiedAgainst": "
|
|
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": "
|
|
14
|
+
"verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
|
|
15
15
|
"paths": [
|
|
16
16
|
"src/",
|
|
17
17
|
"templates/"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"docs/operations.md": {
|
|
21
|
-
"verifiedAgainst": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "agent-hub",
|
|
3
|
-
"version": "0.12.
|
|
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.
|
|
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",
|
package/src/adapters/acp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 },
|