@staix/agent-hub 0.5.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 +8 -0
- package/README.md +46 -3
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10 -8
- package/src/adapters/pi.ts +332 -0
- package/src/cli/main.ts +75 -1
- package/src/cli/terminal-recovery.ts +17 -9
- package/src/cli/upgrade-runtime.ts +46 -16
- package/src/cli/upgrade.ts +3 -0
- package/src/hub/control-client.ts +8 -6
- package/src/hub/daemon.ts +131 -11
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/manager.ts +15 -1
- package/src/hub/routing.ts +25 -3
- package/src/hub/tasks.ts +5 -4
- package/src/models/mlx.ts +419 -0
- package/src/models/relay.ts +334 -0
- package/src/pi/extension.ts +120 -0
- package/src/pi/tool-receipts.ts +52 -0
- package/src/ui/index.html +4 -0
- package/templates/config.json +3 -1
- package/templates/routing.toml +19 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.5.0
|
|
4
12
|
|
|
5
13
|
- 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.
|
|
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
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.
|
|
32
|
+
bun add -g github:STAIxBWLB/agent-hub#v0.6.0 && ahub setup
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
The installed commands remain `ahub` and `agent-hub`.
|
|
@@ -58,8 +58,51 @@ 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. 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
|
+
|
|
63
106
|
## Runtime
|
|
64
107
|
|
|
65
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
|
-
"description": "Native multi-agent hub: Claude Code, Codex, Kimi Code and
|
|
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.
|
|
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 =
|
|
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 !==
|
|
15653
|
-
return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${
|
|
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:
|
|
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.
|
|
15728
|
-
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code and
|
|
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)
|
|
@@ -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
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
5
6
|
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
6
7
|
import { loadConfig } from "../hub/daemon.ts";
|
|
7
8
|
import { projectContext } from "../hub/project.ts";
|
|
@@ -22,6 +23,7 @@ import { childEnv } from "../hub/child-process.ts";
|
|
|
22
23
|
import { abortRecovery, createOperation, publicOperation, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
23
24
|
import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
24
25
|
import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
26
|
+
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
25
27
|
|
|
26
28
|
const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
|
|
27
29
|
|
|
@@ -40,6 +42,8 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
40
42
|
ahub claude [args...] launch Claude Code with the hub channel [--unattended]
|
|
41
43
|
ahub codex [args...] start the Codex adapter and attach the TUI [--unattended]
|
|
42
44
|
ahub kimi [--model <alias>] start Kimi headless under ACP
|
|
45
|
+
ahub pi [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
|
|
46
|
+
ahub models setup|status|start|stop manage the pinned local MLX runtime
|
|
43
47
|
ahub local [--route <id> | --model <id>]
|
|
44
48
|
start the hub-native worker on the self-hosted models (routing.toml)
|
|
45
49
|
ahub say [@peer ...] <text> send as the console user (no @peer = broadcast); delivered at once,
|
|
@@ -108,6 +112,34 @@ function exec(bin: string, argv: string[]): never {
|
|
|
108
112
|
process.exit(res.status ?? 1);
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never {
|
|
116
|
+
const env: NodeJS.ProcessEnv = { ...extra, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
117
|
+
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
118
|
+
delete env.AGENTHUB_UNATTENDED;
|
|
119
|
+
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env });
|
|
120
|
+
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
121
|
+
process.exit(res.status ?? 1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function piFlags(): { mode: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; model?: string; sessionId?: string; sessionFile?: string } {
|
|
125
|
+
for (const flag of ["--mode", "--backend", "--model", "--session-id", "--session-file"]) {
|
|
126
|
+
const index = args.indexOf(flag);
|
|
127
|
+
if (index >= 0 && (!args[index + 1] || args[index + 1]!.startsWith("--"))) fail(`${flag} needs a value`);
|
|
128
|
+
}
|
|
129
|
+
const mode = (args.includes("--mode") ? args[args.indexOf("--mode") + 1] : "headless") as string;
|
|
130
|
+
const backend = (args.includes("--backend") ? args[args.indexOf("--backend") + 1] : undefined) as string | undefined;
|
|
131
|
+
const model = args.includes("--model") ? args[args.indexOf("--model") + 1] : undefined;
|
|
132
|
+
const sessionId = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : undefined;
|
|
133
|
+
const sessionFile = args.includes("--session-file") ? args[args.indexOf("--session-file") + 1] : undefined;
|
|
134
|
+
const valueFlags = new Set(["--mode", "--backend", "--model", "--session-id", "--session-file"]);
|
|
135
|
+
for (let i = 0; i < args.length; i++) {
|
|
136
|
+
if (!valueFlags.has(args[i]!)) fail(`unknown Pi option: ${args[i]}`);
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
if (!["headless", "tui"].includes(mode) || (backend !== undefined && !["auto", "dgx", "mlx"].includes(backend)) || (sessionId && sessionFile)) fail("usage: ahub pi [--mode headless|tui] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id> | --session-file <path>]");
|
|
140
|
+
return { mode: mode as "headless" | "tui", ...(backend ? { backend: backend as "auto" | "dgx" | "mlx" } : {}), ...(model ? { model } : {}), ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}) };
|
|
141
|
+
}
|
|
142
|
+
|
|
111
143
|
async function projectRows() {
|
|
112
144
|
const registry = new Registry();
|
|
113
145
|
try { return await Promise.all(registry.list().map(async (project) => ({ ...project, ...await inspectProject(project) }))); }
|
|
@@ -363,6 +395,25 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
363
395
|
console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
|
|
364
396
|
},
|
|
365
397
|
|
|
398
|
+
pi: async () => {
|
|
399
|
+
const options = piFlags();
|
|
400
|
+
const hub = await connect();
|
|
401
|
+
const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
402
|
+
hub.close();
|
|
403
|
+
if (!res.ok) fail(res.error);
|
|
404
|
+
if (options.mode === "tui") {
|
|
405
|
+
const launch = res.launch;
|
|
406
|
+
if (!launch || typeof launch.cmd !== "string" || !Array.isArray(launch.args) || !launch.args.every((a: unknown) => typeof a === "string") || !launch.env || typeof launch.env !== "object") fail("Pi TUI launch metadata was not verified");
|
|
407
|
+
const launchEnv = { ...(launch.env as NodeJS.ProcessEnv) };
|
|
408
|
+
delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
409
|
+
delete launchEnv.AGENTHUB_UNATTENDED;
|
|
410
|
+
const control = readControl(stateDir);
|
|
411
|
+
if (control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
|
|
412
|
+
return execWithEnv(launch.cmd, launch.args, launchEnv);
|
|
413
|
+
}
|
|
414
|
+
console.log(res.already ? "pi is already attached" : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
|
|
415
|
+
},
|
|
416
|
+
|
|
366
417
|
local: async () => {
|
|
367
418
|
const opt = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] ?? fail(`${flag} needs a value`) : undefined);
|
|
368
419
|
const hub = await connect();
|
|
@@ -372,6 +423,24 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
372
423
|
console.log(res.already ? "local is already attached" : `local attached on ${res.model}. Give it work with: ahub say @local "..."`);
|
|
373
424
|
},
|
|
374
425
|
|
|
426
|
+
models: async () => {
|
|
427
|
+
const action = args[0] ?? "status";
|
|
428
|
+
const configured = loadConfig(cwd).mlx;
|
|
429
|
+
const runtimeDir = configured.runtimeDir ? resolve(cwd, configured.runtimeDir) : join(homedir(), ".agenthub", "runtimes", "mlx");
|
|
430
|
+
const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
|
|
431
|
+
const mlxOptions = { ...configured, runtimeDir, modelPath };
|
|
432
|
+
if (action === "status") return console.log(JSON.stringify(await inspectMlx(mlxOptions), null, 2));
|
|
433
|
+
if (action === "start") { const handle = await ensureMlx(mlxOptions); return console.log(JSON.stringify(handle.status(), null, 2)); }
|
|
434
|
+
if (action === "stop") { await stopMlx(mlxOptions); return console.log("MLX stopped"); }
|
|
435
|
+
if (action !== "setup") fail("usage: ahub models setup|status|start|stop");
|
|
436
|
+
const python = join(runtimeDir, "bin", "python");
|
|
437
|
+
const run = (argv: string[]) => { const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit" }); if (result.status !== 0) fail(`models setup failed: ${argv.join(" ")}`); };
|
|
438
|
+
if (!existsSync(python)) run(["uv", "venv", "--python", "3.12", runtimeDir]);
|
|
439
|
+
run(["uv", "pip", "install", "--python", python, "mlx-lm==0.31.3"]);
|
|
440
|
+
run([python, "-c", `from huggingface_hub import snapshot_download; snapshot_download(repo_id='Qwen/Qwen3-8B-MLX-4bit', revision='383413e909f3bc5303ce195ebbdf0339c5a1a2a3', local_dir=${JSON.stringify(modelPath)}, token=False)`]);
|
|
441
|
+
console.log(`MLX runtime and pinned model prepared at ${runtimeDir}`);
|
|
442
|
+
},
|
|
443
|
+
|
|
375
444
|
say: async () => {
|
|
376
445
|
const lead = args.findIndex((a) => !/^@[a-z][a-z0-9-]*$/.test(a)); // only leading @tokens are recipients
|
|
377
446
|
const to = args.slice(0, lead === -1 ? args.length : lead).map((a) => a.slice(1));
|
|
@@ -503,6 +572,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
503
572
|
console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
|
|
504
573
|
const peers = Object.entries(status.peers as Record<string, { state: string; queued: number }>);
|
|
505
574
|
for (const [id, p] of peers) console.log(` ${id.padEnd(8)} ${p.state.padEnd(8)} queued ${p.queued}${(p as any).paused ? ` (${(p as any).paused})` : ""}${(p as any).servedBy ? ` last call: ${(p as any).servedBy}` : ""}`);
|
|
575
|
+
const models = (status as any).models?.backends as any[] | undefined;
|
|
576
|
+
if (models?.length) for (const backend of models) console.log(` model ${backend.kind ?? "unknown"}/${backend.alias ?? "unknown"} ${backend.state ?? "unknown"} active ${backend.active ?? 0}${backend.requestedModel ? ` requested ${backend.requestedModel}` : ""}${backend.actualModel ? ` actual ${backend.actualModel}` : ""}${backend.provider ? ` provider ${backend.provider}` : ""}`);
|
|
506
577
|
if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
|
|
507
578
|
const counts = Object.entries(status.tasks ?? {}).map(([s, n]) => `${n} ${s}`).join(", ");
|
|
508
579
|
if (counts) console.log(` tasks: ${counts} (ahub board)`);
|
|
@@ -548,6 +619,9 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
548
619
|
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.json");
|
|
549
620
|
const sy = spawnSync(process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server", ["--version"], { encoding: "utf8" });
|
|
550
621
|
row(sy.status === 0 ? true : undefined, "switchyard", sy.status === 0 ? sy.stdout.trim() : "not installed: ahub local uses fixed_model on OmniRoute (cargo install --locked switchyard-server)");
|
|
622
|
+
const mlxConfig = loadConfig(cwd).mlx;
|
|
623
|
+
const mlx = await inspectMlx({ ...mlxConfig, runtimeDir: mlxConfig.runtimeDir ? resolve(cwd, mlxConfig.runtimeDir) : undefined, modelPath: mlxConfig.modelPath ? resolve(cwd, mlxConfig.modelPath) : undefined });
|
|
624
|
+
row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
|
|
551
625
|
|
|
552
626
|
const memory = new MemoryClient();
|
|
553
627
|
const mem = await memory.health();
|