@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.
- package/CHANGELOG.md +22 -0
- package/README.md +6 -5
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/bus.md +5 -2
- package/docs/agent-notes/daemon.md +8 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +2 -1
- package/docs/operations.md +271 -46
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +45 -10
- package/docs/smoke.md +79 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +194 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +68 -10
- package/docs/verified.json +28 -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 +67 -6
- package/src/adapters/codex-appserver.ts +67 -2
- package/src/adapters/local-worker.ts +4 -3
- package/src/adapters/pi.ts +58 -4
- 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 +10 -0
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +96 -17
- package/src/cli/main.ts +159 -18
- package/src/cli/preview.ts +13 -3
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-runtime.ts +67 -18
- package/src/cli/upgrade.ts +81 -9
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +19 -4
- 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 +346 -40
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +2 -1
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +21 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +70 -43
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/tools.ts +14 -10
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +83 -6
- package/src/ui/task-progress.ts +33 -0
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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,
|
package/src/adapters/pi.ts
CHANGED
|
@@ -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
|
-
|
|
279
|
-
|
|
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
|
}
|