@staix/agent-hub 0.5.0 → 0.6.1

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+
5
+ - Preserve Pi session identity when handing an empty native session between headless and TUI modes (#27).
6
+ - Keep recovery metadata and native terminal restoration bound to the verified project session.
7
+
8
+ ## 0.6.0
9
+
10
+ - Pi peer with managed local model routing, RPC and native terminal integration (#25).
11
+ - Authenticated inference relay for DGX coding/fast aliases and loopback MLX Qwen3 8B inference on Apple Silicon.
12
+ - Pi-first task routing with per-class backend selection; PII tasks remain restricted to the existing local worker.
13
+ - Hub-approved file and shell tools with persistent session/tool-call receipts to prevent duplicate effects.
14
+ - Protocol 9 and Pi session metadata for controlled recovery from protocol 8.
15
+
3
16
  ## 0.5.0
4
17
 
5
18
  - Controlled protocol-8 restart with private queue/session snapshots, manual-pause preservation, instance fencing and verified release (#21).
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # agent-hub
2
2
 
3
- Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Code and a
3
+ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Code, Pi and a
4
4
  hub-owned local-LLM worker collaborate as peers in independent project directories, with
5
5
  task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
6
6
 
7
- Status: 0.5.0. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
7
+ Status: 0.6.1. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
8
8
  says what has and has not been verified against real agents.
9
9
 
10
10
  Version 0.5.0 adds [controlled upgrade and session recovery](docs/specs/2026-09-20-upgrade-recovery-design.md).
@@ -29,7 +29,7 @@ cd <your project> && ahub init && ahub up && ahub tail
29
29
  Or install the same version from GitHub:
30
30
 
31
31
  ```bash
32
- bun add -g github:STAIxBWLB/agent-hub#v0.5.0 && ahub setup
32
+ bun add -g github:STAIxBWLB/agent-hub#v0.6.1 && ahub setup
33
33
  ```
34
34
 
35
35
  The installed commands remain `ahub` and `agent-hub`.
@@ -58,8 +58,53 @@ an N-peer message bus and attaches each agent through its native control surface
58
58
  | Claude Code | channel plugin push (`notifications/claude/channel`) | MCP tools | queued by Claude Code, grouped on next turn |
59
59
  | Codex | app-server `turn/start` (idle) / `turn/steer` (busy) | proxy intercepts `item/agentMessage` | steer or queue |
60
60
  | Kimi Code | ACP `session/prompt` | ACP `session/update` stream | queue (`turn.agent_busy`) |
61
+ | Pi | RPC or managed native TUI extension | hub-approved tools | queued until `agent_settled` |
61
62
  | Local worker | hub-native agent loop | hub-native | hub-owned |
62
63
 
64
+ ## Pi with DGX and MLX
65
+
66
+ Pi is optional. Install Pi separately and set `pi.enabled` to `true` in
67
+ `.agenthub/config.json`. Set `pi.auto_start` to `true` to start its background
68
+ peer with the hub. The project uses its existing OmniRoute connection for DGX;
69
+ `pi.dgx_coding` and `pi.dgx_fast` select the gateway's configured model IDs.
70
+
71
+ ```bash
72
+ ahub models setup # dedicated Python 3.12 environment and pinned Qwen3 8B MLX weights
73
+ ahub models start # loopback-only Apple Silicon runtime
74
+ ahub models status
75
+ ahub pi --mode headless --backend auto
76
+ ahub say @pi "Read the project and summarize the next small implementation task"
77
+ ahub pi --mode tui # switch the idle session to its native terminal
78
+ ```
79
+
80
+ The default routing template assigns implementation, bulk edits and tests to
81
+ DGX, and summaries/triage to MLX. Pi and the existing local worker take priority
82
+ before cloud peers; planning and final review stay with Claude/Codex. Existing
83
+ projects keep their routing file, so add `pi` to the relevant `peers` lists and
84
+ set `pi_backend = "dgx"` or `"mlx"` for each class. PII tasks remain local-worker
85
+ only. `--backend dgx` or `--backend mlx` explicitly pins a Pi session's backend.
86
+
87
+ Pi connects to an authenticated relay with model aliases `dgx/coding`,
88
+ `dgx/fast`, and `mlx/fast`; upstream gateway credentials stay in the hub.
89
+ Its managed tools use the existing path guards, shell sandbox and terminal
90
+ approval flow. Keep `ahub tail` open to approve write/edit/shell operations.
91
+ Tool-call receipts survive daemon restart; an interrupted operation with an
92
+ unknown outcome must be reconciled before repeating it. Cancelling a Pi turn
93
+ does not automatically hand its task to a cloud peer.
94
+
95
+ MLX uses a pinned Qwen3 8B 4-bit model, a 16K input budget and one generation
96
+ at a time. `ahub models stop` stops only the runtime whose process identity
97
+ matches its ownership record. Project hubs share the runtime; stopping a hub
98
+ does not stop MLX. The relay can fall back from MLX to DGX before streaming
99
+ starts. Inspect `ahub status` for requested routes and reported backend models.
100
+
101
+ A project has one managed Pi session owner. Mode changes require the agent to
102
+ settle and preserve the session file. Before the first generation, a live source
103
+ verified to be empty can instead retain its exact session ID without a file.
104
+ Missing history from a nonempty session blocks handover. Protocol 9 adds Pi recovery metadata;
105
+ protocol-8 hubs can use controlled upgrade, while protocols 5-7 still require
106
+ their matching CLI and an attended bootstrap.
107
+
63
108
  ## Runtime
64
109
 
65
110
  Bun + TypeScript. Single daemon per project directory, loopback only.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.5.0",
4
- "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code and a local worker as peers in one project",
3
+ "version": "0.6.1",
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",
7
7
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",
@@ -15615,7 +15615,7 @@ function projectContext(cwd, env = process.env) {
15615
15615
  function stateDirFor(cwd) {
15616
15616
  return projectContext(cwd).stateDir;
15617
15617
  }
15618
- var PROTOCOL = 8;
15618
+ var PROTOCOL = 9;
15619
15619
  function readControl(stateDir) {
15620
15620
  try {
15621
15621
  const status = JSON.parse(readFileSync2(join2(stateDir, "status.json"), "utf8"));
@@ -15645,12 +15645,14 @@ class ControlClient {
15645
15645
  constructor(ws) {
15646
15646
  this.ws = ws;
15647
15647
  }
15648
- static connect(stateDir, hello, timeoutMs = 3000) {
15648
+ static connect(stateDir, hello, timeoutMs = 3000, protocol = PROTOCOL) {
15649
+ if (protocol !== 8 && protocol !== PROTOCOL)
15650
+ return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
15649
15651
  const control = readControl(stateDir);
15650
15652
  if (!control)
15651
15653
  return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
15652
- if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
15653
- return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${PROTOCOL}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
15654
+ if (control.protocol !== undefined && control.protocol !== protocol) {
15655
+ return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${protocol}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
15654
15656
  }
15655
15657
  if (hello.projectId && hello.projectId !== control.projectId || hello.instanceId && hello.instanceId !== control.instanceId || hello.projectRoot && hello.projectRoot !== control.cwd) {
15656
15658
  return Promise.reject(Object.assign(new Error("hub project or instance does not match the selected project"), { code: 4404 }));
@@ -15693,7 +15695,7 @@ class ControlClient {
15693
15695
  client.pending.delete(msg.rid);
15694
15696
  done(msg);
15695
15697
  };
15696
- ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
15698
+ ws.onopen = () => void client.request({ t: "hello", v: protocol, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
15697
15699
  if (reply.t !== "welcome" || reply.ok === false)
15698
15700
  return refuse(new Error(reply.error ?? "hub refused handshake"));
15699
15701
  if (expected.projectId && reply.projectId !== expected.projectId || expected.instanceId && reply.instanceId !== expected.instanceId || expected.projectRoot && reply.cwd !== expected.projectRoot) {
@@ -15724,8 +15726,8 @@ class ControlClient {
15724
15726
  // package.json
15725
15727
  var package_default = {
15726
15728
  name: "@staix/agent-hub",
15727
- version: "0.5.0",
15728
- description: "Native multi-agent hub: Claude Code, Codex, Kimi Code and a local worker as peers in one project",
15729
+ version: "0.6.1",
15730
+ description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15729
15731
  license: "MIT",
15730
15732
  type: "module",
15731
15733
  bin: {
@@ -15798,7 +15800,7 @@ var ROLE_TEXT = {
15798
15800
  verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
15799
15801
  reviewer: "reviewer: when asked to review, read the change itself, then hub_review with approved or changes_requested and a note that says what to fix."
15800
15802
  };
15801
- var DEFAULT_ROLES = { claude: ["planner", "reviewer"], codex: ["implementer"], kimi: ["implementer", "verifier"], local: ["implementer", "verifier"] };
15803
+ var DEFAULT_ROLES = { claude: ["planner", "reviewer"], codex: ["implementer"], kimi: ["implementer", "verifier"], local: ["implementer", "verifier"], pi: ["implementer", "verifier"] };
15802
15804
  function roleContract(peer, roles = DEFAULT_ROLES) {
15803
15805
  const mine = (roles[peer] ?? []).map((r) => ROLE_TEXT[r]).filter(Boolean);
15804
15806
  if (!mine.length)
@@ -0,0 +1,385 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { join, resolve, relative } from "node:path";
4
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5
+ import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
6
+ import { BasePeer } from "../hub/peers.ts";
7
+ import { stopOwnedProcess } from "../hub/child-process.ts";
8
+
9
+ export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
10
+ export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
11
+ export interface PiRelay { url: string; token: string; models: PiModelDescriptor[]; }
12
+ export interface PiOptions {
13
+ cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
14
+ model?: string;
15
+ relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string) => Promise<string>; tools: PiToolSchema[];
16
+ preamble?: string;
17
+ selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
18
+ onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void>;
19
+ watchdogMs?: number; log?: (line: string) => void;
20
+ }
21
+ export interface PiTuiLaunch { cmd: string; args: string[]; env: NodeJS.ProcessEnv; }
22
+ type RpcMessage = { type?: string; id?: string | number; command?: string; success?: boolean; data?: any; [key: string]: any };
23
+ type VerifiedEmptyResume = { sessionId: string };
24
+ function processSignature(pid: number): string | undefined { try { const result = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart=,comm="], { stdout: "pipe", stderr: "pipe" }); if (result.exitCode !== 0) return undefined; const text = result.stdout.toString().trim(); return text ? new Bun.CryptoHasher("sha256").update(text).digest("hex") : undefined; } catch { return undefined; } }
25
+ function ownerStillAlive(pid: number, signature: string | undefined): boolean { const current = processSignature(pid); if (current !== undefined) return current === signature; try { process.kill(pid, 0); return true; } catch { return false; } }
26
+
27
+ const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
28
+ const ids = models.map((m) => m.id);
29
+ const find = (needle: string) => ids.find((id) => id === needle) ?? needle;
30
+ if (backend === "mlx") return find("mlx/fast");
31
+ if (backend === "dgx") return find("dgx/coding");
32
+ return find(ids.find((id) => id === "dgx/coding") ? "dgx/coding" : ids.find((id) => id === "mlx/fast") ?? "dgx/coding");
33
+ };
34
+
35
+ export class PiPeer extends BasePeer {
36
+ private proc?: ChildProcessWithoutNullStreams;
37
+ private server?: ReturnType<typeof Bun.serve>;
38
+ private buffer = "";
39
+ private seq = 0;
40
+ private readonly pending = new Map<string, { resolve: (m: RpcMessage) => void; reject: (e: Error) => void }>();
41
+ private settledText = "";
42
+ private settledError = "";
43
+ private settledCancelled = false;
44
+ private currentReply?: Envelope;
45
+ private sessionId = "";
46
+ private sessionFile = "";
47
+ private emptyResumeVerified = false;
48
+ private verifiedEmptyResume?: VerifiedEmptyResume;
49
+ private activityObserved = false;
50
+ private persistedOnce = false;
51
+ private activeEnvs: Envelope[] = [];
52
+ private activeTools = 0;
53
+ private agentRunning = false;
54
+ private owner = randomUUID();
55
+ private _tuiLaunch?: PiTuiLaunch;
56
+ private requestedModel = "";
57
+ private starting?: Promise<void>;
58
+ private ownerClaimed = false;
59
+ private ownerToken = "";
60
+ private stopping = true;
61
+ private ownerPid?: number;
62
+ private ownerSignature?: string;
63
+ private ownerMonitor?: ReturnType<typeof setInterval>;
64
+ private tuiExit?: Promise<void>;
65
+ private resolveTuiExit?: () => void;
66
+ private tuiCommands = new Map<string, { command: Record<string, unknown>; resolve: (value: any) => void; reject: (error: Error) => void }>();
67
+ private tuiQueue: Record<string, unknown>[] = [];
68
+ private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
69
+
70
+ constructor(id: PeerId, private readonly opts: PiOptions) { super(id, opts.watchdogMs); }
71
+ get tuiLaunch(): PiTuiLaunch | undefined { return this._tuiLaunch; }
72
+ get pendingResume(): { sessionId?: string; sessionFile?: string } {
73
+ if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
74
+ return { sessionId: this.opts.sessionId, sessionFile: this.opts.sessionFile };
75
+ }
76
+ get acceptingTools(): boolean { return !this.stopping; }
77
+ /** Idle verified owners can be stopped; uncertain live owners and pending launches stay fenced. */
78
+ get recoveryReady(): boolean {
79
+ if (this.state === "busy" || this.starting || this.activeTools) return false;
80
+ if (this.sessionFile && !existsSync(this.sessionFile) && (this.activityObserved || this.persistedOnce)) return false;
81
+ if (this.opts.mode === "headless") return this.state === "idle" || !this.proc || this.proc.exitCode !== null || this.proc.signalCode !== null;
82
+ if (this.state === "idle") return this.ownerClaimed && !!this.ownerPid && processSignature(this.ownerPid) === this.ownerSignature;
83
+ return this.stopping && !this.ownerClaimed && (!this.ownerPid || !ownerStillAlive(this.ownerPid, this.ownerSignature));
84
+ }
85
+ recoveryMetadata(): Record<string, unknown> {
86
+ const exists = !!this.sessionFile && existsSync(this.sessionFile);
87
+ if (exists) { this.persistedOnce = true; this.verifiedEmptyResume = undefined; }
88
+ const empty = this.emptyResumeVerified && !this.activityObserved && !this.persistedOnce;
89
+ const file = this.sessionFile && (exists || !empty) ? this.sessionFile : undefined;
90
+ return { launch: { kind: "pi", cwd: this.opts.cwd, mode: this.opts.mode, backend: this.opts.backend, ...(this.opts.model ? { model: this.opts.model } : {}), ...(file ? { sessionFile: file } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}), ...(file ? { sessionFile: file } : {}) };
91
+ }
92
+
93
+ /** Verify the live source before choosing ID-only restoration. Never infer emptiness from a missing file. */
94
+ async captureResume(): Promise<Record<string, unknown>> {
95
+ if (this.state === "offline") {
96
+ if (this.canReuseVerifiedEmptyResume()) return this.recoveryMetadata();
97
+ if (this.recoveryReady && this.sessionFile && existsSync(this.sessionFile)) return this.recoveryMetadata();
98
+ throw new Error("Pi source session is offline and has no verified persisted resume state");
99
+ }
100
+ this.emptyResumeVerified = false;
101
+ if (this.state !== "idle" || this.stopping || this.activeTools) throw new Error("Pi must settle before session capture");
102
+ if (this.sessionFile && existsSync(this.sessionFile)) return this.recoveryMetadata();
103
+ const snapshot = this.opts.mode === "headless"
104
+ ? (await this.waitRpc("get_state", 15_000)).data
105
+ : await this.sendTui({ type: "get_session_state" });
106
+ if (this.state !== "idle" || this.activeTools || snapshot?.sessionId !== this.sessionId || snapshot?.sessionFile !== this.sessionFile) throw new Error("Pi source session changed during capture");
107
+ const empty = this.opts.mode === "headless"
108
+ ? snapshot.messageCount === 0 && snapshot.pendingMessageCount === 0 && snapshot.isStreaming === false && snapshot.isCompacting !== true && !snapshot.sessionName
109
+ : snapshot.empty === true && snapshot.idle === true;
110
+ if (!empty || this.activityObserved || this.persistedOnce) throw new Error("Pi session history is not persisted; refusing to recreate it");
111
+ this.emptyResumeVerified = true;
112
+ const captured = this.recoveryMetadata();
113
+ if (typeof captured.sessionId === "string" && !captured.sessionFile) this.verifiedEmptyResume = { sessionId: captured.sessionId };
114
+ return captured;
115
+ }
116
+
117
+ private canReuseVerifiedEmptyResume(): boolean {
118
+ return !!this.verifiedEmptyResume && this.recoveryReady && !this.activityObserved && !this.persistedOnce && this.sessionId === this.verifiedEmptyResume.sessionId && (!this.sessionFile || !existsSync(this.sessionFile));
119
+ }
120
+
121
+ private noteActivity(): void {
122
+ this.activityObserved = true;
123
+ this.emptyResumeVerified = false;
124
+ this.verifiedEmptyResume = undefined;
125
+ }
126
+
127
+ async start(): Promise<void> {
128
+ if (this.starting) return this.starting;
129
+ this.starting = this.startImpl();
130
+ try { await this.starting; } finally { this.starting = undefined; }
131
+ }
132
+ private async startImpl(): Promise<void> {
133
+ this.stopping = false;
134
+ mkdirSync(this.opts.stateDir, { recursive: true });
135
+ const sessions = join(this.opts.stateDir, "pi-sessions");
136
+ mkdirSync(sessions, { recursive: true, mode: 0o700 });
137
+ if (this.opts.sessionFile) {
138
+ const file = realpathSync(this.opts.sessionFile);
139
+ const rel = relative(realpathSync(sessions), file);
140
+ if (!rel || rel.startsWith("..") || resolve(realpathSync(sessions), rel) !== file) throw new Error("Pi session file is outside the managed project session directory");
141
+ const header = JSON.parse(readFileSync(file, "utf8").split("\n", 1)[0]!);
142
+ if (header.type !== "session" || typeof header.cwd !== "string" || realpathSync(header.cwd) !== realpathSync(this.opts.cwd) || (this.opts.sessionId && header.id !== this.opts.sessionId)) throw new Error("Pi session header does not match the requested project/session");
143
+ }
144
+ const token = randomUUID();
145
+ this.tuiExit = new Promise((resolvePromise) => { this.resolveTuiExit = resolvePromise; });
146
+ const bridge = Bun.serve({ hostname: "127.0.0.1", port: 0, idleTimeout: 60, maxRequestBodySize: 2_000_000, fetch: async (request) => {
147
+ const origin = request.headers.get("origin"); if (origin) return new Response("forbidden", { status: 403 });
148
+ if (request.headers.get("authorization") !== `Bearer ${token}`) return new Response("forbidden", { status: 403 });
149
+ const url = new URL(request.url);
150
+ if (url.pathname === "/commands" && request.method === "GET") {
151
+ const command = await this.nextTuiCommand(25_000);
152
+ return Response.json(command ? { command } : { command: null });
153
+ }
154
+ if (request.method !== "POST") return new Response("method not allowed", { status: 405 });
155
+ const body = await request.json() as any;
156
+ if (url.pathname === "/event") { if (body.type === "session_start" && this.stopping) return Response.json({ ok: false, error: "Pi launch has ended" }, { status: 409 }); if (body.type === "session_start" && (typeof body.sessionId !== "string" || !body.sessionId || typeof body.sessionFile !== "string" || !body.sessionFile)) return Response.json({ ok: false, error: "Pi session identity is missing" }, { status: 400 }); if (body.type === "session_start" && this.ownerClaimed && (body.ownerToken !== this.ownerToken || body.pid !== this.ownerPid || body.signature !== this.ownerSignature || (this.sessionId && body.sessionId !== this.sessionId) || (this.sessionFile && body.sessionFile !== this.sessionFile))) return Response.json({ ok: false, error: "Pi session owner or identity already claimed" }, { status: 409 }); if (body.type === "session_start" && (!Number.isInteger(body.pid) || !body.signature || processSignature(body.pid) !== body.signature)) return Response.json({ ok: false, error: "Pi process identity is not verified" }, { status: 409 }); this.handleBridgeEvent(body); return Response.json({ ok: true }); }
157
+ if (url.pathname === "/ack") {
158
+ const pending = this.tuiCommands.get(String(body.id));
159
+ if (!pending) return Response.json({ ok: false }, { status: 404 });
160
+ this.tuiCommands.delete(String(body.id)); body.ok ? pending.resolve(body.result) : pending.reject(new Error(String(body.error ?? "Pi TUI command failed")));
161
+ return Response.json({ ok: true });
162
+ }
163
+ if (url.pathname === "/tool") {
164
+ if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
165
+ this.noteActivity();
166
+ this.activeTools++;
167
+ if (this.state === "idle") this.setState("busy");
168
+ if (this.state === "busy") this.touch();
169
+ try { return Response.json({ text: await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId) }); }
170
+ catch (error) { return Response.json({ text: `error: ${(error as Error).message}` }, { status: 200 }); }
171
+ finally {
172
+ this.activeTools--;
173
+ if (this.state === "busy") {
174
+ if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
175
+ else this.touch();
176
+ }
177
+ }
178
+ }
179
+ return new Response("not found", { status: 404 });
180
+ } });
181
+ this.server = bridge;
182
+ const extension = resolve(join(import.meta.dir, "../pi/extension.ts"));
183
+ const inherited: NodeJS.ProcessEnv = {};
184
+ for (const key of ["PATH", "HOME", "USER", "SHELL", "TMPDIR", "TERM", "TERM_PROGRAM", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "CODEX_HOME"]) if (process.env[key]) inherited[key] = process.env[key];
185
+ const env: NodeJS.ProcessEnv = { ...inherited, PI_CODING_AGENT_DIR: join(this.opts.stateDir, "pi"), AGENTHUB_PI_BRIDGE_URL: `http://127.0.0.1:${bridge.port}`, AGENTHUB_PI_BRIDGE_TOKEN: token, AGENTHUB_PI_OWNER_TOKEN: token, AGENTHUB_PI_RELAY_URL: this.opts.relay.url, AGENTHUB_PI_RELAY_TOKEN: this.opts.relay.token, AGENTHUB_PI_MODELS: JSON.stringify(this.opts.relay.models), AGENTHUB_PI_TOOLS: JSON.stringify(this.opts.tools), AGENTHUB_PI_MAX_STEPS: String(this.opts.maxSteps ?? 30) };
186
+ const args = [ ...(this.opts.mode === "headless" ? ["--mode", "rpc"] : []), "--provider", "agent-hub-local", "--model", this.opts.model ?? modelFor(this.opts.backend, this.opts.relay.models), "--models", this.opts.relay.models.map((model) => `agent-hub-local/${model.id}`).join(","), "--no-builtin-tools", "--no-skills", "--no-prompt-templates", "--no-extensions", "--extension", extension, ...(this.opts.preamble ? ["--append-system-prompt", this.opts.preamble] : []), "--session-dir", join(this.opts.stateDir, "pi-sessions")];
187
+ if (this.opts.sessionFile) args.push("--session", this.opts.sessionFile); else if (this.opts.sessionId) args.push("--session-id", this.opts.sessionId);
188
+ const command = this.opts.cmd ?? ["pi"];
189
+ this._tuiLaunch = { cmd: command[0]!, args: [...command.slice(1), ...args], env };
190
+ if (this.opts.mode === "tui") { return; }
191
+ this.proc = spawn(command[0]!, [...command.slice(1), ...args], { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
192
+ this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
193
+ this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
194
+ this.proc.on("error", (error) => this.fail(error));
195
+ this.proc.on("exit", (code) => { if (code !== 0) this.fail(new Error(`pi exited with code ${code}`)); else this.setState("offline"); });
196
+ const state = await this.waitRpc("get_state", 15_000);
197
+ 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");
198
+ if ((this.opts.sessionId && state.data.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && state.data.sessionFile !== this.opts.sessionFile)) {
199
+ throw new Error("Pi get_state session identity does not match the requested recovery session");
200
+ }
201
+ this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
202
+ this.persistedOnce = existsSync(this.sessionFile);
203
+ 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");
204
+ this.setState("idle");
205
+ }
206
+
207
+ async stop(): Promise<void> {
208
+ this.stopping = true;
209
+ this.clearOwnerMonitor();
210
+ if (this.opts.mode === "tui") {
211
+ const alive = () => !!this.ownerPid && ownerStillAlive(this.ownerPid, this.ownerSignature);
212
+ if (this.ownerClaimed && alive()) {
213
+ try {
214
+ await Promise.race([this.sendTui({ type: "shutdown" }), (async () => {
215
+ const deadline = Date.now() + 5_000;
216
+ while (alive() && Date.now() < deadline) await Bun.sleep(50);
217
+ if (alive()) throw new Error("Pi TUI shutdown was not acknowledged");
218
+ })()]);
219
+ }
220
+ catch (error) {
221
+ if (alive()) { this.setState("busy"); this.startOwnerMonitor(); throw error; }
222
+ }
223
+ }
224
+ const deadline = Date.now() + 5_000;
225
+ while (alive() && Date.now() < deadline) await Bun.sleep(50);
226
+ if (alive()) { this.setState("busy"); this.startOwnerMonitor(); throw new Error("Pi TUI owner process did not exit"); }
227
+ this.ownerClaimed = false;
228
+ this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
229
+ }
230
+ const proc = this.proc;
231
+ if (proc && proc.exitCode === null) await stopOwnedProcess(proc);
232
+ this.proc = undefined;
233
+ for (const waiter of this.tuiWaiters) { clearTimeout(waiter.timer); waiter.resolve(undefined); }
234
+ this.tuiWaiters = [];
235
+ for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner stopped"));
236
+ this.tuiCommands.clear(); this.tuiQueue = [];
237
+ for (const pending of this.pending.values()) pending.reject(new Error("Pi owner stopped"));
238
+ this.pending.clear();
239
+ this.server?.stop(true); this.server = undefined; this.setState("offline");
240
+ }
241
+
242
+ async deliver(envs: Envelope[]): Promise<void> {
243
+ if (this.state !== "idle" || this.stopping) throw new Error(`${this.id} is not ready`);
244
+ this.setState("busy"); this.activeEnvs = envs; this.currentReply = replyParent(envs); this.settledText = "";
245
+ let attempted = false;
246
+ try {
247
+ const requested = await this.opts.selectModel?.(envs);
248
+ if (requested) await this.setRequestedModel(requested);
249
+ this.noteActivity();
250
+ attempted = true;
251
+ if (this.opts.mode === "tui") await this.sendTui({ type: "prompt", message: renderDigest(envs, true) });
252
+ else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
253
+ } catch (error) {
254
+ if (!attempted) { this.activeEnvs = []; this.setState("idle"); throw error; }
255
+ // The prompt may have reached Pi even if its acknowledgement was lost.
256
+ // Fence the owner before reporting failure; never return it to the bus for replay.
257
+ await this.terminateFailedTurn(error instanceof Error ? error : new Error(String(error)));
258
+ }
259
+ }
260
+
261
+ async steer(envs: Envelope[]): Promise<void> {
262
+ if (this.state !== "busy" || this.stopping || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) throw new Error("Pi workflow/private messages must queue");
263
+ const reply = replyParent([...this.activeEnvs, ...envs]);
264
+ this.activeEnvs.push(...envs); this.currentReply = reply;
265
+ try {
266
+ if (this.opts.mode === "tui") await this.sendTui({ type: "steer", message: renderDigest(envs, true) });
267
+ else await this.sendRpc({ type: "steer", message: renderDigest(envs, true) });
268
+ } catch (error) {
269
+ await this.terminateFailedTurn(error instanceof Error ? error : new Error(String(error)));
270
+ }
271
+ }
272
+
273
+ getRequestedModel(): string { return this.requestedModel; }
274
+ async setRequestedModel(model: string): Promise<void> {
275
+ const slash = model.indexOf("/");
276
+ const provider = "agent-hub-local";
277
+ const modelId = model;
278
+ if (this.opts.mode === "tui") await this.sendTui({ type: "set_model", provider, modelId }); else await this.sendRpc({ type: "set_model", provider, modelId });
279
+ this.requestedModel = model;
280
+ }
281
+
282
+ private handleBridgeEvent(event: any): void {
283
+ if (event.type === "session_start") {
284
+ if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
285
+ this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
286
+ this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
287
+ 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; }
288
+ this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
289
+ }
290
+ if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.setState("offline"); }
291
+ if (event.type === "agent_start") { this.noteActivity(); this.agentRunning = true; this.setState("busy"); }
292
+ if (event.type === "activity" && this.state === "busy") this.touch();
293
+ if (event.type === "agent_end") {
294
+ this.settledText = typeof event.text === "string" ? event.text : "";
295
+ this.settledCancelled = event.cancelled === true;
296
+ this.settledError = event.failed ? String(event.error ?? "Pi agent run failed") : "";
297
+ }
298
+ if (event.type === "agent_settled") {
299
+ this.agentRunning = false;
300
+ if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
301
+ const text = this.settledText.trim(); const error = this.settledError; const cancelled = this.settledCancelled;
302
+ this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
303
+ if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", { inReplyTo: this.currentReply });
304
+ else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
305
+ else if (text) this.onMessage?.(text, { inReplyTo: this.currentReply });
306
+ this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
307
+ }
308
+ }
309
+
310
+ private onOutput(chunk: string): void {
311
+ this.buffer += chunk;
312
+ let index: number;
313
+ while ((index = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, index).replace(/\r$/, ""); this.buffer = this.buffer.slice(index + 1); if (!line) continue; try { this.handleRpc(JSON.parse(line) as RpcMessage); } catch (error) { this.opts.log?.(`[${this.id}] invalid Pi RPC output: ${(error as Error).message}`); } }
314
+ }
315
+ private nextTuiCommand(timeout: number): Promise<Record<string, unknown> | undefined> {
316
+ const next = this.tuiQueue.shift(); if (next) return Promise.resolve(next);
317
+ return new Promise((resolvePromise) => { const entry = { resolve: resolvePromise, timer: setTimeout(() => { const i = this.tuiWaiters.indexOf(entry); if (i >= 0) this.tuiWaiters.splice(i, 1); resolvePromise(undefined); }, timeout) }; this.tuiWaiters.push(entry); });
318
+ }
319
+ private sendTui(command: Record<string, unknown>): Promise<any> {
320
+ const id = `ahub-${++this.seq}`;
321
+ const wire = { id, ...command };
322
+ return new Promise((resolvePromise, reject) => {
323
+ const timer = setTimeout(() => {
324
+ this.tuiCommands.delete(id);
325
+ this.tuiQueue = this.tuiQueue.filter((queued) => queued.id !== id);
326
+ reject(new Error(`Pi TUI ${String(command.type)} acknowledgement timed out`));
327
+ }, 30_000);
328
+ this.tuiCommands.set(id, { command: wire, resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
329
+ const waiter = this.tuiWaiters.shift();
330
+ if (waiter) { clearTimeout(waiter.timer); waiter.resolve(wire); } else this.tuiQueue.push(wire);
331
+ });
332
+ }
333
+ private handleRpc(message: RpcMessage): void { if (["message_update", "tool_execution_update"].includes(message.type ?? "") && this.state === "busy") this.touch(); if (message.type === "agent_settled") return; // The authenticated extension is the single lifecycle source.
334
+ if (message.id !== undefined) { const pending = this.pending.get(String(message.id)); if (pending) { this.pending.delete(String(message.id)); message.success === false ? pending.reject(new Error(message.error ?? "Pi RPC command failed")) : pending.resolve(message); } } }
335
+ private sendRpc(command: Record<string, unknown>): Promise<RpcMessage> { if (!this.proc?.stdin.writable) return Promise.reject(new Error("Pi process is not running")); const id = `ahub-${++this.seq}`; return new Promise((resolvePromise, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Pi RPC ${String(command.type)} timed out`)); }, 30_000); this.pending.set(id, { resolve: (m) => { clearTimeout(timer); resolvePromise(m); }, reject: (e) => { clearTimeout(timer); reject(e); } }); this.proc!.stdin.write(`${JSON.stringify({ id, ...command })}\n`); }); }
336
+ private async waitRpc(command: string, timeout: number): Promise<RpcMessage> {
337
+ let timer: ReturnType<typeof setTimeout> | undefined;
338
+ try { return await Promise.race([this.sendRpc({ type: command }), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`Pi ${command} timed out`)), timeout); })]); }
339
+ finally { clearTimeout(timer); }
340
+ }
341
+ private fail(error: Error): void {
342
+ for (const pending of this.pending.values()) pending.reject(error);
343
+ this.pending.clear();
344
+ const envs = this.activeEnvs; this.activeEnvs = [];
345
+ if (envs.length) {
346
+ this.onMessage?.("Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
347
+ void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
348
+ }
349
+ this.currentReply = undefined;
350
+ this.setState("offline");
351
+ }
352
+ private startOwnerMonitor(): void {
353
+ this.clearOwnerMonitor();
354
+ if (this.opts.mode !== "tui" || !this.ownerPid || !this.ownerSignature) return;
355
+ this.ownerMonitor = setInterval(() => {
356
+ if (!this.ownerPid || ownerStillAlive(this.ownerPid, this.ownerSignature)) return;
357
+ this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
358
+ this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
359
+ for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
360
+ this.tuiCommands.clear(); this.tuiQueue = [];
361
+ this.fail(new Error("Pi owner process exited without session_shutdown"));
362
+ }, 500);
363
+ this.ownerMonitor.unref?.();
364
+ }
365
+ private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
366
+ private async terminateFailedTurn(error: Error): Promise<void> {
367
+ const envs = this.activeEnvs.slice(), reply = this.currentReply;
368
+ this.stopping = true;
369
+ try {
370
+ await this.stop();
371
+ // The process exit handler may already have reported this failure.
372
+ if (this.activeEnvs.length) this.fail(error);
373
+ } catch {
374
+ this.activeEnvs = envs; this.currentReply = reply;
375
+ this.stopping = true;
376
+ this.setState("busy");
377
+ this.opts.log?.("Pi owner could not be stopped; session remains fenced, no automatic escalation");
378
+ }
379
+ }
380
+ protected override onWatchdog(): void {
381
+ if (this.stopping) return;
382
+ if (this.activeTools) { this.touch(); return; }
383
+ void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
384
+ }
385
+ }