@staix/agent-hub 0.4.0 → 0.6.0

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - Pi peer with managed local model routing, RPC and native terminal integration (#25).
6
+ - Authenticated inference relay for DGX coding/fast aliases and loopback MLX Qwen3 8B inference on Apple Silicon.
7
+ - Pi-first task routing with per-class backend selection; PII tasks remain restricted to the existing local worker.
8
+ - Hub-approved file and shell tools with persistent session/tool-call receipts to prevent duplicate effects.
9
+ - Protocol 9 and Pi session metadata for controlled recovery from protocol 8.
10
+
11
+ ## 0.5.0
12
+
13
+ - Controlled protocol-8 restart with private queue/session snapshots, manual-pause preservation, instance fencing and verified release (#21).
14
+ - Exact-version upgrade planning, immutable package staging, detached resumable operation receipts and machine lifecycle locking.
15
+ - Orca-first native terminal recovery: original Codex/Claude conversations, new Kimi/local sessions with task context, and explicit manual blockers for unsupported sessions.
16
+ - Running projects transition sequentially; the shared Claude plugin and Claude sessions have a final common phase. Global CLI promotion follows verification.
17
+ - Protocols 5–7 require a manual bootstrap using their matching CLI. No automatic legacy shutdown, uncertain side-effect replay or rollback.
18
+
19
+ ## 0.4.1 (included in 0.5.0)
20
+
21
+ - Synchronize the daemon digest integration fixture without changing batching semantics or weakening digest/FYI assertions (#15).
22
+ - Reconcile completed live smoke and npm publication evidence; infrastructure-dependent checks remain tracked in #12.
23
+
3
24
  ## 0.4.0
4
25
 
5
26
  - Independent repository and worktree hubs with canonical project selection, `--project <path|id>`, `projects`, and `status --all` (#19).
package/README.md CHANGED
@@ -1,12 +1,19 @@
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.4.0. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
7
+ Status: 0.6.0. 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
+ Version 0.5.0 adds [controlled upgrade and session recovery](docs/specs/2026-09-20-upgrade-recovery-design.md).
11
+ After bootstrapping a protocol-8 hub, use `ahub restart --dry-run` to inspect one
12
+ project or `ahub upgrade --to <exact-version> --dry-run` to inspect an upgrade of
13
+ the running projects. Remove `--dry-run` to review and schedule the operation;
14
+ `ahub recovery status <operation-id>` reports progress. Existing protocol-5–7
15
+ hubs require their matching CLI and an attended maintenance bootstrap.
16
+
10
17
  ## Start here
11
18
 
12
19
  Requires [Bun](https://bun.sh) >=1.3.0 on PATH; Node alone cannot run the CLI.
@@ -22,7 +29,7 @@ cd <your project> && ahub init && ahub up && ahub tail
22
29
  Or install the same version from GitHub:
23
30
 
24
31
  ```bash
25
- bun add -g github:STAIxBWLB/agent-hub#v0.4.0 && ahub setup
32
+ bun add -g github:STAIxBWLB/agent-hub#v0.6.0 && ahub setup
26
33
  ```
27
34
 
28
35
  The installed commands remain `ahub` and `agent-hub`.
@@ -51,8 +58,51 @@ an N-peer message bus and attaches each agent through its native control surface
51
58
  | Claude Code | channel plugin push (`notifications/claude/channel`) | MCP tools | queued by Claude Code, grouped on next turn |
52
59
  | Codex | app-server `turn/start` (idle) / `turn/steer` (busy) | proxy intercepts `item/agentMessage` | steer or queue |
53
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` |
54
62
  | Local worker | hub-native agent loop | hub-native | hub-owned |
55
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. Protocol 9 adds Pi recovery metadata;
103
+ protocol-8 hubs can use controlled upgrade, while protocols 5-7 still require
104
+ their matching CLI and an attended bootstrap.
105
+
56
106
  ## Runtime
57
107
 
58
108
  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.4.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.0",
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.4.0",
3
+ "version": "0.6.0",
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 = 7;
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.4.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.0",
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)
@@ -2,7 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
- import { stopOwnedProcess } from "../hub/child-process.ts";
5
+ import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
6
6
 
7
7
  export interface PermissionOption {
8
8
  optionId: string;
@@ -18,7 +18,11 @@ export interface PermissionRequest {
18
18
  export interface AcpOptions {
19
19
  /** e.g. ["kimi", "acp"]. `opencode acp` fits the same adapter. */
20
20
  cmd: string[];
21
+ /** Coordinator-visible selected model only; contains no prompts or command arguments. */
22
+ launchModel?: string;
21
23
  cwd: string;
24
+ /** Optional launch environment; recovery authority is always removed before spawn. */
25
+ env?: NodeJS.ProcessEnv;
22
26
  watchdogMs?: number;
23
27
  /** ACP stdio MCP servers for the session (the hub's task tools). */
24
28
  mcpServers?: { name: string; command: string; args: string[]; env: { name: string; value: string }[] }[];
@@ -52,9 +56,13 @@ export class AcpPeer extends BasePeer {
52
56
  super(id, opts.watchdogMs);
53
57
  }
54
58
 
59
+ recoveryMetadata(): Record<string, unknown> {
60
+ return { launch: { kind: "acp", ...(this.opts.launchModel ? { model: this.opts.launchModel } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}) };
61
+ }
62
+
55
63
  async start(): Promise<void> {
56
64
  const [bin, ...args] = this.opts.cmd;
57
- const proc = spawn(bin!, args, { cwd: this.opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
65
+ const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"] });
58
66
  this.proc = proc;
59
67
  proc.on("error", (e) => this.down(`spawn failed: ${e.message}`));
60
68
  proc.on("exit", (code) => this.down(`exited with code ${code}`));
@@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import type { Server, ServerWebSocket } from "bun";
3
3
  import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
- import { stopOwnedProcess } from "../hub/child-process.ts";
5
+ import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
6
6
 
7
7
  export interface CodexOptions {
8
8
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
@@ -21,6 +21,8 @@ export interface CodexOptions {
21
21
  /** How often to ask app-server for the rate limits while a TUI is attached. */
22
22
  usagePollMs?: number;
23
23
  cwd: string;
24
+ /** Optional launch environment; recovery authority is always removed before spawn. */
25
+ env?: NodeJS.ProcessEnv;
24
26
  watchdogMs?: number;
25
27
  log?: (line: string) => void;
26
28
  }
@@ -64,6 +66,13 @@ export class CodexPeer extends BasePeer {
64
66
  super(id, opts.watchdogMs);
65
67
  }
66
68
 
69
+ recoveryMetadata(): Record<string, unknown> {
70
+ return {
71
+ launch: { kind: "codex", bin: this.opts.bin ?? "codex", cwd: this.opts.cwd, appPort: this.opts.appPort, proxyPort: this.opts.proxyPort },
72
+ ...(this.threadId ? { threadId: this.threadId } : {}),
73
+ };
74
+ }
75
+
67
76
  get proxyUrl(): string {
68
77
  return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
69
78
  }
@@ -209,6 +218,7 @@ export class CodexPeer extends BasePeer {
209
218
  let gone = "";
210
219
  this.proc = spawn(this.opts.bin ?? "codex", ["app-server", "--listen", `ws://127.0.0.1:${port}`, ...(this.opts.extraArgs ?? [])], {
211
220
  cwd: this.opts.cwd,
221
+ env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }),
212
222
  stdio: ["ignore", "ignore", "pipe"],
213
223
  });
214
224
  this.proc.stderr?.on("data", (d) => this.opts.log?.(`[${this.id}] ${String(d).trimEnd()}`));
@@ -68,6 +68,10 @@ export class LocalPeer extends BasePeer {
68
68
  this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny);
69
69
  }
70
70
 
71
+ recoveryMetadata(): Record<string, unknown> {
72
+ return { launch: { kind: "local", cwd: this.opts.cwd, model: this.opts.fixedModel, ...(this.opts.route ? { route: this.opts.route } : {}) }, sessionId: this.sessionId };
73
+ }
74
+
71
75
  async start(): Promise<void> {
72
76
  this.opts.capture?.init(this.sessionId, "agent-hub local worker session");
73
77
  this.setState("idle");
@@ -0,0 +1,332 @@
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
+ 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; } }
24
+ 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; } }
25
+
26
+ const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
27
+ const ids = models.map((m) => m.id);
28
+ const find = (needle: string) => ids.find((id) => id === needle) ?? needle;
29
+ if (backend === "mlx") return find("mlx/fast");
30
+ if (backend === "dgx") return find("dgx/coding");
31
+ return find(ids.find((id) => id === "dgx/coding") ? "dgx/coding" : ids.find((id) => id === "mlx/fast") ?? "dgx/coding");
32
+ };
33
+
34
+ export class PiPeer extends BasePeer {
35
+ private proc?: ChildProcessWithoutNullStreams;
36
+ private server?: ReturnType<typeof Bun.serve>;
37
+ private buffer = "";
38
+ private seq = 0;
39
+ private readonly pending = new Map<string, { resolve: (m: RpcMessage) => void; reject: (e: Error) => void }>();
40
+ private settledText = "";
41
+ private settledError = "";
42
+ private settledCancelled = false;
43
+ private currentReply?: Envelope;
44
+ private sessionId = "";
45
+ private sessionFile = "";
46
+ private activeEnvs: Envelope[] = [];
47
+ private activeTools = 0;
48
+ private agentRunning = false;
49
+ private owner = randomUUID();
50
+ private _tuiLaunch?: PiTuiLaunch;
51
+ private requestedModel = "";
52
+ private starting?: Promise<void>;
53
+ private ownerClaimed = false;
54
+ private ownerToken = "";
55
+ private stopping = true;
56
+ private ownerPid?: number;
57
+ private ownerSignature?: string;
58
+ private ownerMonitor?: ReturnType<typeof setInterval>;
59
+ private tuiExit?: Promise<void>;
60
+ private resolveTuiExit?: () => void;
61
+ private tuiCommands = new Map<string, { command: Record<string, unknown>; resolve: (value: any) => void; reject: (error: Error) => void }>();
62
+ private tuiQueue: Record<string, unknown>[] = [];
63
+ private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
64
+
65
+ constructor(id: PeerId, private readonly opts: PiOptions) { super(id, opts.watchdogMs); }
66
+ get tuiLaunch(): PiTuiLaunch | undefined { return this._tuiLaunch; }
67
+ get acceptingTools(): boolean { return !this.stopping; }
68
+ /** Idle verified owners can be stopped; uncertain live owners and pending launches stay fenced. */
69
+ get recoveryReady(): boolean {
70
+ if (this.state === "busy" || this.starting || this.activeTools) return false;
71
+ if (this.opts.mode === "headless") return this.state === "idle" || !this.proc || this.proc.exitCode !== null;
72
+ if (this.state === "idle") return this.ownerClaimed && !!this.ownerPid && processSignature(this.ownerPid) === this.ownerSignature;
73
+ return this.stopping && !this.ownerClaimed && (!this.ownerPid || !ownerStillAlive(this.ownerPid, this.ownerSignature));
74
+ }
75
+ recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "pi", cwd: this.opts.cwd, mode: this.opts.mode, backend: this.opts.backend, ...(this.opts.model ? { model: this.opts.model } : {}), ...(this.sessionFile ? { sessionFile: this.sessionFile } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}), ...(this.sessionFile ? { sessionFile: this.sessionFile } : {}) }; }
76
+
77
+ async start(): Promise<void> {
78
+ if (this.starting) return this.starting;
79
+ this.starting = this.startImpl();
80
+ try { await this.starting; } finally { this.starting = undefined; }
81
+ }
82
+ private async startImpl(): Promise<void> {
83
+ this.stopping = false;
84
+ mkdirSync(this.opts.stateDir, { recursive: true });
85
+ const sessions = join(this.opts.stateDir, "pi-sessions");
86
+ mkdirSync(sessions, { recursive: true, mode: 0o700 });
87
+ if (this.opts.sessionFile) {
88
+ const file = realpathSync(this.opts.sessionFile);
89
+ const rel = relative(realpathSync(sessions), file);
90
+ if (!rel || rel.startsWith("..") || resolve(realpathSync(sessions), rel) !== file) throw new Error("Pi session file is outside the managed project session directory");
91
+ const header = JSON.parse(readFileSync(file, "utf8").split("\n", 1)[0]!);
92
+ 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");
93
+ }
94
+ const token = randomUUID();
95
+ this.tuiExit = new Promise((resolvePromise) => { this.resolveTuiExit = resolvePromise; });
96
+ const bridge = Bun.serve({ hostname: "127.0.0.1", port: 0, idleTimeout: 60, maxRequestBodySize: 2_000_000, fetch: async (request) => {
97
+ const origin = request.headers.get("origin"); if (origin) return new Response("forbidden", { status: 403 });
98
+ if (request.headers.get("authorization") !== `Bearer ${token}`) return new Response("forbidden", { status: 403 });
99
+ const url = new URL(request.url);
100
+ if (url.pathname === "/commands" && request.method === "GET") {
101
+ const command = await this.nextTuiCommand(25_000);
102
+ return Response.json(command ? { command } : { command: null });
103
+ }
104
+ if (request.method !== "POST") return new Response("method not allowed", { status: 405 });
105
+ const body = await request.json() as any;
106
+ 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 }); }
107
+ if (url.pathname === "/ack") {
108
+ const pending = this.tuiCommands.get(String(body.id));
109
+ if (!pending) return Response.json({ ok: false }, { status: 404 });
110
+ this.tuiCommands.delete(String(body.id)); body.ok ? pending.resolve(body.result) : pending.reject(new Error(String(body.error ?? "Pi TUI command failed")));
111
+ return Response.json({ ok: true });
112
+ }
113
+ if (url.pathname === "/tool") {
114
+ if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
115
+ this.activeTools++;
116
+ if (this.state === "idle") this.setState("busy");
117
+ if (this.state === "busy") this.touch();
118
+ try { return Response.json({ text: await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId) }); }
119
+ catch (error) { return Response.json({ text: `error: ${(error as Error).message}` }, { status: 200 }); }
120
+ finally {
121
+ this.activeTools--;
122
+ if (this.state === "busy") {
123
+ if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
124
+ else this.touch();
125
+ }
126
+ }
127
+ }
128
+ return new Response("not found", { status: 404 });
129
+ } });
130
+ this.server = bridge;
131
+ const extension = resolve(join(import.meta.dir, "../pi/extension.ts"));
132
+ const inherited: NodeJS.ProcessEnv = {};
133
+ 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];
134
+ 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) };
135
+ 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")];
136
+ if (this.opts.sessionFile) args.push("--session", this.opts.sessionFile); else if (this.opts.sessionId) args.push("--session-id", this.opts.sessionId);
137
+ const command = this.opts.cmd ?? ["pi"];
138
+ this._tuiLaunch = { cmd: command[0]!, args: [...command.slice(1), ...args], env };
139
+ if (this.opts.mode === "tui") { return; }
140
+ this.proc = spawn(command[0]!, [...command.slice(1), ...args], { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
141
+ this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
142
+ this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
143
+ this.proc.on("error", (error) => this.fail(error));
144
+ this.proc.on("exit", (code) => { if (code !== 0) this.fail(new Error(`pi exited with code ${code}`)); else this.setState("offline"); });
145
+ const state = await this.waitRpc("get_state", 15_000);
146
+ 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");
147
+ if ((this.opts.sessionId && state.data.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && state.data.sessionFile !== this.opts.sessionFile)) {
148
+ throw new Error("Pi get_state session identity does not match the requested recovery session");
149
+ }
150
+ this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
151
+ 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");
152
+ this.setState("idle");
153
+ }
154
+
155
+ async stop(): Promise<void> {
156
+ this.stopping = true;
157
+ this.clearOwnerMonitor();
158
+ if (this.opts.mode === "tui") {
159
+ const alive = () => !!this.ownerPid && ownerStillAlive(this.ownerPid, this.ownerSignature);
160
+ if (this.ownerClaimed && alive()) {
161
+ try {
162
+ await Promise.race([this.sendTui({ type: "shutdown" }), (async () => {
163
+ const deadline = Date.now() + 5_000;
164
+ while (alive() && Date.now() < deadline) await Bun.sleep(50);
165
+ if (alive()) throw new Error("Pi TUI shutdown was not acknowledged");
166
+ })()]);
167
+ }
168
+ catch (error) {
169
+ if (alive()) { this.setState("busy"); this.startOwnerMonitor(); throw error; }
170
+ }
171
+ }
172
+ const deadline = Date.now() + 5_000;
173
+ while (alive() && Date.now() < deadline) await Bun.sleep(50);
174
+ if (alive()) { this.setState("busy"); this.startOwnerMonitor(); throw new Error("Pi TUI owner process did not exit"); }
175
+ this.ownerClaimed = false;
176
+ this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
177
+ }
178
+ const proc = this.proc;
179
+ if (proc && proc.exitCode === null) await stopOwnedProcess(proc);
180
+ this.proc = undefined;
181
+ for (const waiter of this.tuiWaiters) { clearTimeout(waiter.timer); waiter.resolve(undefined); }
182
+ this.tuiWaiters = [];
183
+ for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner stopped"));
184
+ this.tuiCommands.clear(); this.tuiQueue = [];
185
+ for (const pending of this.pending.values()) pending.reject(new Error("Pi owner stopped"));
186
+ this.pending.clear();
187
+ this.server?.stop(true); this.server = undefined; this.setState("offline");
188
+ }
189
+
190
+ async deliver(envs: Envelope[]): Promise<void> {
191
+ if (this.state !== "idle" || this.stopping) throw new Error(`${this.id} is not ready`);
192
+ this.setState("busy"); this.activeEnvs = envs; this.currentReply = replyParent(envs); this.settledText = "";
193
+ let attempted = false;
194
+ try {
195
+ const requested = await this.opts.selectModel?.(envs);
196
+ if (requested) await this.setRequestedModel(requested);
197
+ attempted = true;
198
+ if (this.opts.mode === "tui") await this.sendTui({ type: "prompt", message: renderDigest(envs, true) });
199
+ else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
200
+ } catch (error) {
201
+ if (!attempted) { this.activeEnvs = []; this.setState("idle"); throw error; }
202
+ // The prompt may have reached Pi even if its acknowledgement was lost.
203
+ // Fence the owner before reporting failure; never return it to the bus for replay.
204
+ await this.terminateFailedTurn(error instanceof Error ? error : new Error(String(error)));
205
+ }
206
+ }
207
+
208
+ async steer(envs: Envelope[]): Promise<void> {
209
+ 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");
210
+ const reply = replyParent([...this.activeEnvs, ...envs]);
211
+ this.activeEnvs.push(...envs); this.currentReply = reply;
212
+ try {
213
+ if (this.opts.mode === "tui") await this.sendTui({ type: "steer", message: renderDigest(envs, true) });
214
+ else await this.sendRpc({ type: "steer", message: renderDigest(envs, true) });
215
+ } catch (error) {
216
+ await this.terminateFailedTurn(error instanceof Error ? error : new Error(String(error)));
217
+ }
218
+ }
219
+
220
+ getRequestedModel(): string { return this.requestedModel; }
221
+ async setRequestedModel(model: string): Promise<void> {
222
+ const slash = model.indexOf("/");
223
+ const provider = "agent-hub-local";
224
+ const modelId = model;
225
+ if (this.opts.mode === "tui") await this.sendTui({ type: "set_model", provider, modelId }); else await this.sendRpc({ type: "set_model", provider, modelId });
226
+ this.requestedModel = model;
227
+ }
228
+
229
+ private handleBridgeEvent(event: any): void {
230
+ if (event.type === "session_start") {
231
+ if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
232
+ this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
233
+ this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
234
+ 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; }
235
+ this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
236
+ }
237
+ if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.setState("offline"); }
238
+ if (event.type === "agent_start") { this.agentRunning = true; this.setState("busy"); }
239
+ if (event.type === "activity" && this.state === "busy") this.touch();
240
+ if (event.type === "agent_end") {
241
+ this.settledText = typeof event.text === "string" ? event.text : "";
242
+ this.settledCancelled = event.cancelled === true;
243
+ this.settledError = event.failed ? String(event.error ?? "Pi agent run failed") : "";
244
+ }
245
+ if (event.type === "agent_settled") {
246
+ this.agentRunning = false;
247
+ if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
248
+ const text = this.settledText.trim(); const error = this.settledError; const cancelled = this.settledCancelled;
249
+ this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
250
+ if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", { inReplyTo: this.currentReply });
251
+ else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
252
+ else if (text) this.onMessage?.(text, { inReplyTo: this.currentReply });
253
+ this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
254
+ }
255
+ }
256
+
257
+ private onOutput(chunk: string): void {
258
+ this.buffer += chunk;
259
+ let index: number;
260
+ 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}`); } }
261
+ }
262
+ private nextTuiCommand(timeout: number): Promise<Record<string, unknown> | undefined> {
263
+ const next = this.tuiQueue.shift(); if (next) return Promise.resolve(next);
264
+ 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); });
265
+ }
266
+ private sendTui(command: Record<string, unknown>): Promise<any> {
267
+ const id = `ahub-${++this.seq}`;
268
+ const wire = { id, ...command };
269
+ return new Promise((resolvePromise, reject) => {
270
+ const timer = setTimeout(() => {
271
+ this.tuiCommands.delete(id);
272
+ this.tuiQueue = this.tuiQueue.filter((queued) => queued.id !== id);
273
+ reject(new Error(`Pi TUI ${String(command.type)} acknowledgement timed out`));
274
+ }, 30_000);
275
+ this.tuiCommands.set(id, { command: wire, resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
276
+ const waiter = this.tuiWaiters.shift();
277
+ if (waiter) { clearTimeout(waiter.timer); waiter.resolve(wire); } else this.tuiQueue.push(wire);
278
+ });
279
+ }
280
+ 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.
281
+ 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); } } }
282
+ 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`); }); }
283
+ private async waitRpc(command: string, timeout: number): Promise<RpcMessage> {
284
+ let timer: ReturnType<typeof setTimeout> | undefined;
285
+ try { return await Promise.race([this.sendRpc({ type: command }), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`Pi ${command} timed out`)), timeout); })]); }
286
+ finally { clearTimeout(timer); }
287
+ }
288
+ private fail(error: Error): void {
289
+ for (const pending of this.pending.values()) pending.reject(error);
290
+ this.pending.clear();
291
+ const envs = this.activeEnvs; this.activeEnvs = [];
292
+ if (envs.length) {
293
+ this.onMessage?.("Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
294
+ void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
295
+ }
296
+ this.currentReply = undefined;
297
+ this.setState("offline");
298
+ }
299
+ private startOwnerMonitor(): void {
300
+ this.clearOwnerMonitor();
301
+ if (this.opts.mode !== "tui" || !this.ownerPid || !this.ownerSignature) return;
302
+ this.ownerMonitor = setInterval(() => {
303
+ if (!this.ownerPid || ownerStillAlive(this.ownerPid, this.ownerSignature)) return;
304
+ this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
305
+ this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
306
+ for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
307
+ this.tuiCommands.clear(); this.tuiQueue = [];
308
+ this.fail(new Error("Pi owner process exited without session_shutdown"));
309
+ }, 500);
310
+ this.ownerMonitor.unref?.();
311
+ }
312
+ private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
313
+ private async terminateFailedTurn(error: Error): Promise<void> {
314
+ const envs = this.activeEnvs.slice(), reply = this.currentReply;
315
+ this.stopping = true;
316
+ try {
317
+ await this.stop();
318
+ // The process exit handler may already have reported this failure.
319
+ if (this.activeEnvs.length) this.fail(error);
320
+ } catch {
321
+ this.activeEnvs = envs; this.currentReply = reply;
322
+ this.stopping = true;
323
+ this.setState("busy");
324
+ this.opts.log?.("Pi owner could not be stopped; session remains fenced, no automatic escalation");
325
+ }
326
+ }
327
+ protected override onWatchdog(): void {
328
+ if (this.stopping) return;
329
+ if (this.activeTools) { this.touch(); return; }
330
+ void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
331
+ }
332
+ }