@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 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.0. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
7
+ Status: 0.6.1. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
8
8
  says what has and has not been verified against real agents.
9
9
 
10
10
  Version 0.5.0 adds [controlled upgrade and session recovery](docs/specs/2026-09-20-upgrade-recovery-design.md).
@@ -29,7 +29,7 @@ cd <your project> && ahub init && ahub up && ahub tail
29
29
  Or install the same version from GitHub:
30
30
 
31
31
  ```bash
32
- bun add -g github:STAIxBWLB/agent-hub#v0.6.0 && ahub setup
32
+ bun add -g github:STAIxBWLB/agent-hub#v0.6.1 && ahub setup
33
33
  ```
34
34
 
35
35
  The installed commands remain `ahub` and `agent-hub`.
@@ -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. Protocol 9 adds Pi recovery metadata;
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": "@staix/agent-hub",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -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.0",
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",
@@ -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.opts.mode === "headless") return this.state === "idle" || !this.proc || this.proc.exitCode !== null;
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> { 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 } : {}) }; }
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
- const saved = existing.recoveryMetadata();
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
- if (!saved.sessionId || !saved.sessionFile) return { ok: false, error: "Pi session identity is not ready for handover" };
574
- 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: String(saved.sessionFile) };
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 && saved.sessionFile) {
579
- 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: String(saved.sessionFile) };
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;
@@ -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");