@staix/agent-hub 0.6.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 +5 -0
- package/README.md +5 -3
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/pi.ts +56 -3
- package/src/hub/daemon.ts +14 -5
- package/src/pi/extension.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 0.6.0
|
|
4
9
|
|
|
5
10
|
- Pi peer with managed local model routing, RPC and native terminal integration (#25).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
|
|
|
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.6.
|
|
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.6.
|
|
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`.
|
|
@@ -99,7 +99,9 @@ does not stop MLX. The relay can fall back from MLX to DGX before streaming
|
|
|
99
99
|
starts. Inspect `ahub status` for requested routes and reported backend models.
|
|
100
100
|
|
|
101
101
|
A project has one managed Pi session owner. Mode changes require the agent to
|
|
102
|
-
settle and preserve the session file.
|
|
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;
|
|
103
105
|
protocol-8 hubs can use controlled upgrade, while protocols 5-7 still require
|
|
104
106
|
their matching CLI and an attended bootstrap.
|
|
105
107
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.6.
|
|
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",
|
|
@@ -15726,7 +15726,7 @@ class ControlClient {
|
|
|
15726
15726
|
// package.json
|
|
15727
15727
|
var package_default = {
|
|
15728
15728
|
name: "@staix/agent-hub",
|
|
15729
|
-
version: "0.6.
|
|
15729
|
+
version: "0.6.1",
|
|
15730
15730
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15731
15731
|
license: "MIT",
|
|
15732
15732
|
type: "module",
|
package/src/adapters/pi.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface PiOptions {
|
|
|
20
20
|
}
|
|
21
21
|
export interface PiTuiLaunch { cmd: string; args: string[]; env: NodeJS.ProcessEnv; }
|
|
22
22
|
type RpcMessage = { type?: string; id?: string | number; command?: string; success?: boolean; data?: any; [key: string]: any };
|
|
23
|
+
type VerifiedEmptyResume = { sessionId: string };
|
|
23
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; } }
|
|
24
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; } }
|
|
25
26
|
|
|
@@ -43,6 +44,10 @@ export class PiPeer extends BasePeer {
|
|
|
43
44
|
private currentReply?: Envelope;
|
|
44
45
|
private sessionId = "";
|
|
45
46
|
private sessionFile = "";
|
|
47
|
+
private emptyResumeVerified = false;
|
|
48
|
+
private verifiedEmptyResume?: VerifiedEmptyResume;
|
|
49
|
+
private activityObserved = false;
|
|
50
|
+
private persistedOnce = false;
|
|
46
51
|
private activeEnvs: Envelope[] = [];
|
|
47
52
|
private activeTools = 0;
|
|
48
53
|
private agentRunning = false;
|
|
@@ -64,15 +69,60 @@ export class PiPeer extends BasePeer {
|
|
|
64
69
|
|
|
65
70
|
constructor(id: PeerId, private readonly opts: PiOptions) { super(id, opts.watchdogMs); }
|
|
66
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
|
+
}
|
|
67
76
|
get acceptingTools(): boolean { return !this.stopping; }
|
|
68
77
|
/** Idle verified owners can be stopped; uncertain live owners and pending launches stay fenced. */
|
|
69
78
|
get recoveryReady(): boolean {
|
|
70
79
|
if (this.state === "busy" || this.starting || this.activeTools) return false;
|
|
71
|
-
if (this.
|
|
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;
|
|
72
82
|
if (this.state === "idle") return this.ownerClaimed && !!this.ownerPid && processSignature(this.ownerPid) === this.ownerSignature;
|
|
73
83
|
return this.stopping && !this.ownerClaimed && (!this.ownerPid || !ownerStillAlive(this.ownerPid, this.ownerSignature));
|
|
74
84
|
}
|
|
75
|
-
recoveryMetadata(): Record<string, unknown> {
|
|
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
|
+
}
|
|
76
126
|
|
|
77
127
|
async start(): Promise<void> {
|
|
78
128
|
if (this.starting) return this.starting;
|
|
@@ -112,6 +162,7 @@ export class PiPeer extends BasePeer {
|
|
|
112
162
|
}
|
|
113
163
|
if (url.pathname === "/tool") {
|
|
114
164
|
if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
|
|
165
|
+
this.noteActivity();
|
|
115
166
|
this.activeTools++;
|
|
116
167
|
if (this.state === "idle") this.setState("busy");
|
|
117
168
|
if (this.state === "busy") this.touch();
|
|
@@ -148,6 +199,7 @@ export class PiPeer extends BasePeer {
|
|
|
148
199
|
throw new Error("Pi get_state session identity does not match the requested recovery session");
|
|
149
200
|
}
|
|
150
201
|
this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
|
|
202
|
+
this.persistedOnce = existsSync(this.sessionFile);
|
|
151
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");
|
|
152
204
|
this.setState("idle");
|
|
153
205
|
}
|
|
@@ -194,6 +246,7 @@ export class PiPeer extends BasePeer {
|
|
|
194
246
|
try {
|
|
195
247
|
const requested = await this.opts.selectModel?.(envs);
|
|
196
248
|
if (requested) await this.setRequestedModel(requested);
|
|
249
|
+
this.noteActivity();
|
|
197
250
|
attempted = true;
|
|
198
251
|
if (this.opts.mode === "tui") await this.sendTui({ type: "prompt", message: renderDigest(envs, true) });
|
|
199
252
|
else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
|
|
@@ -235,7 +288,7 @@ export class PiPeer extends BasePeer {
|
|
|
235
288
|
this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
|
|
236
289
|
}
|
|
237
290
|
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"); }
|
|
291
|
+
if (event.type === "agent_start") { this.noteActivity(); this.agentRunning = true; this.setState("busy"); }
|
|
239
292
|
if (event.type === "activity" && this.state === "busy") this.touch();
|
|
240
293
|
if (event.type === "agent_end") {
|
|
241
294
|
this.settledText = typeof event.text === "string" ? event.text : "";
|
package/src/hub/daemon.ts
CHANGED
|
@@ -558,7 +558,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
558
558
|
}
|
|
559
559
|
const existing = bus.peers.get(peer);
|
|
560
560
|
if (peer === "pi" && existing instanceof PiPeer) {
|
|
561
|
-
|
|
561
|
+
let saved = existing.recoveryMetadata();
|
|
562
562
|
const launch = saved.launch as Record<string, unknown>;
|
|
563
563
|
if ((args.sessionId && saved.sessionId && args.sessionId !== saved.sessionId) || (args.sessionFile && saved.sessionFile && args.sessionFile !== saved.sessionFile)) return { ok: false, error: "Pi already owns a different session; refusing to replace its identity" };
|
|
564
564
|
const mode = args.mode ?? "headless";
|
|
@@ -566,17 +566,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
566
566
|
const unclaimed = existing.state === "offline" && !saved.sessionId && !saved.sessionFile;
|
|
567
567
|
if (changesOwner && (existing.state === "busy" || (existing.state !== "offline" && !existing.recoveryReady))) return { ok: false, error: "Pi is busy; wait for agent_settled before changing mode/backend" };
|
|
568
568
|
if (unclaimed) {
|
|
569
|
+
if (!args.sessionId && !args.sessionFile) args = { ...args, ...existing.pendingResume };
|
|
569
570
|
// Revoke the previous launch bridge before issuing another launch. A late
|
|
570
571
|
// process from the abandoned CLI cannot claim the replacement owner.
|
|
571
572
|
await existing.stop();
|
|
572
573
|
} else if (changesOwner) {
|
|
573
|
-
|
|
574
|
-
|
|
574
|
+
saved = await existing.captureResume();
|
|
575
|
+
if (!saved.sessionId) return { ok: false, error: "Pi session identity is not ready for handover" };
|
|
576
|
+
args = { ...args, backend: args.backend ?? launch.backend as "auto" | "dgx" | "mlx", model: args.model ?? (args.backend === undefined && typeof launch.model === "string" ? launch.model : undefined), sessionId: String(saved.sessionId), sessionFile: typeof saved.sessionFile === "string" ? saved.sessionFile : undefined };
|
|
575
577
|
await existing.stop();
|
|
576
578
|
} else if (existing.state !== "offline") {
|
|
577
579
|
return mode === "tui" ? { ok: false, error: "Pi already owns a native terminal; use that terminal or switch to headless first" } : { ok: true, already: true };
|
|
578
|
-
} else if (saved.sessionId
|
|
579
|
-
|
|
580
|
+
} else if (saved.sessionId) {
|
|
581
|
+
saved = await existing.captureResume();
|
|
582
|
+
args = { ...args, backend: args.backend ?? launch.backend as "auto" | "dgx" | "mlx", model: args.model ?? (args.backend === undefined && typeof launch.model === "string" ? launch.model : undefined), sessionId: String(saved.sessionId), sessionFile: typeof saved.sessionFile === "string" ? saved.sessionFile : undefined };
|
|
580
583
|
}
|
|
581
584
|
}
|
|
582
585
|
if (existing && existing.state !== "offline") {
|
|
@@ -847,6 +850,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
847
850
|
async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
|
|
848
851
|
if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
|
|
849
852
|
if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
|
|
853
|
+
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
854
|
+
const pi = bus.peers.get("pi");
|
|
855
|
+
if (pi instanceof PiPeer && pi.state === "idle") {
|
|
856
|
+
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
857
|
+
}
|
|
858
|
+
}
|
|
850
859
|
if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
851
860
|
if (typeof msg.operationId !== "string" || msg.operationId.length < 1 || msg.operationId.length > 128) return recoveryError("operationId is required");
|
|
852
861
|
const op = msg.operationId as string;
|
package/src/pi/extension.ts
CHANGED
|
@@ -39,6 +39,15 @@ async function poll(pi: ExtensionAPI): Promise<void> {
|
|
|
39
39
|
try {
|
|
40
40
|
if (command.type === "prompt" || command.type === "steer") {
|
|
41
41
|
pi.sendMessage({ customType: "agent-hub", content: String(command.message ?? ""), display: true }, { triggerTurn: command.type === "prompt", deliverAs: command.type === "steer" ? "steer" : "followUp" });
|
|
42
|
+
} else if (command.type === "get_session_state") {
|
|
43
|
+
const entries = runtimeCtx.sessionManager.getEntries();
|
|
44
|
+
await post("/ack", { id: command.id, ok: true, result: {
|
|
45
|
+
sessionId: runtimeCtx.sessionManager.getHeader()?.id,
|
|
46
|
+
sessionFile: runtimeCtx.sessionManager.getSessionFile(),
|
|
47
|
+
idle: runtimeCtx.isIdle() === true,
|
|
48
|
+
empty: entries.every((entry: any) => ["model_change", "thinking_level_change"].includes(entry.type)),
|
|
49
|
+
} });
|
|
50
|
+
continue;
|
|
42
51
|
} else if (command.type === "set_model") {
|
|
43
52
|
const model = modelRegistry?.find(String(command.provider), String(command.modelId));
|
|
44
53
|
if (!model || !(await (pi as any).setModel?.(model))) throw new Error("Pi model is not available");
|