@staix/agent-hub 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2
4
+
5
+ - Address a peer's reply at whoever asked instead of broadcasting it, so one directed question no longer costs every attached peer a turn (#29).
6
+ - Cap the priority a hub-native peer claims for itself: `[IMPORTANT]` on an unsolicited report no longer interrupts the other peers (#29).
7
+ - A session whose peer id was taken over stands by and reclaims it once the hub reports the peer offline, instead of staying detached until the whole session is restarted (#30).
8
+ - Show the command a Kimi tool call will run in its approval prompt, taken from the `tool_call` update when the permission request omits it; a prompt whose payload cannot be resolved says so and offers no session-wide grant (#31).
9
+ - `ahub status` prints a namespaced backend alias once (`dgx/coding`, not `dgx/dgx/coding`) and names the backend Pi asked for on its last turn (#32).
10
+
11
+ ## 0.6.1
12
+
13
+ - Preserve Pi session identity when handing an empty native session between headless and TUI modes (#27).
14
+ - Keep recovery metadata and native terminal restoration bound to the verified project session.
15
+
3
16
  ## 0.6.0
4
17
 
5
18
  - 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.2. 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.2 && 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.2",
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.2",
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.2",
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",
@@ -15839,14 +15839,24 @@ function roles() {
15839
15839
  }
15840
15840
  var MAX_RECONNECT_DELAY_MS = 30000;
15841
15841
  var TERMINAL_CLOSES = {
15842
- 4000: `another session attached to the hub as "${peerId}"; this one is detached (restart it to take the peer back)`,
15843
15842
  4401: "the hub refused the control token",
15844
15843
  4404: "the hub belongs to a different project or instance; restart this session from the intended project",
15845
15844
  4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
15846
15845
  4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
15847
15846
  4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session"
15848
15847
  };
15848
+ var HELD_CLOSE = 4000;
15849
+ var HELD = `another session is attached to the hub as "${peerId}"; this one is standing by and takes the peer back when that session leaves`;
15849
15850
  var INBOX_CAP = 200;
15851
+ function peerHeld() {
15852
+ try {
15853
+ const status = JSON.parse(readFileSync3(join3(stateDir, "status.json"), "utf8"));
15854
+ const peer = status.peers?.[peerId];
15855
+ return !!peer && (peer.state !== "offline" || peer.claiming === true);
15856
+ } catch {
15857
+ return false;
15858
+ }
15859
+ }
15850
15860
  var INSTRUCTIONS = [
15851
15861
  "agent-hub connects you to other coding agents working in this project (for example codex, kimi, local) and to the hub console user.",
15852
15862
  'Their messages arrive as <channel source="agent-hub" ...> tags; meta.source names the sender and meta.message_id identifies the message.',
@@ -15895,7 +15905,12 @@ ${sanitize(e.body)}`).join(`
15895
15905
  }
15896
15906
  }
15897
15907
  async function connectLoop() {
15908
+ let standingBy = false;
15898
15909
  for (let attempt = 0;; attempt++) {
15910
+ if (standingBy && peerHeld()) {
15911
+ await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
15912
+ continue;
15913
+ }
15899
15914
  let code;
15900
15915
  try {
15901
15916
  const client = await ControlClient.connect(stateDir, {
@@ -15906,6 +15921,8 @@ async function connectLoop() {
15906
15921
  client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]);
15907
15922
  hub = client;
15908
15923
  attempt = -1;
15924
+ standingBy = false;
15925
+ detached = undefined;
15909
15926
  log(`connected to hub as "${peerId}"`);
15910
15927
  code = await new Promise((r) => client.onClose = r);
15911
15928
  hub = undefined;
@@ -15919,6 +15936,12 @@ async function connectLoop() {
15919
15936
  detached = TERMINAL_CLOSES[code];
15920
15937
  return log(`stopped reconnecting: ${detached}`);
15921
15938
  }
15939
+ if (code === HELD_CLOSE) {
15940
+ if (!standingBy)
15941
+ log(`standing by: ${HELD}`);
15942
+ standingBy = true;
15943
+ detached = HELD;
15944
+ }
15922
15945
  await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
15923
15946
  }
15924
15947
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
- import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
3
+ import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
5
  import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
6
6
 
@@ -30,12 +30,15 @@ export interface AcpOptions {
30
30
  preamble?: string;
31
31
  /** The session's running token total from `usage_update` (inferred shape: totalTokens, else input + output, else `used`). Cumulative, not a delta. */
32
32
  onTokens?: (sessionTotal: number) => void;
33
- /** Resolve with an optionId, or undefined to cancel. Absent = every request is cancelled. */
33
+ /** Resolve with an optionId, or undefined to cancel. Absent = every request is cancelled.
34
+ * A request whose payload could not be resolved is titled as such and carries no session-wide allow option. */
34
35
  onPermission?: (req: PermissionRequest) => Promise<string | undefined>;
35
36
  log?: (line: string) => void;
36
37
  }
37
38
 
38
39
  const HANDSHAKE_MS = 30_000;
40
+ /** Tool calls whose arguments are remembered until their permission request arrives. */
41
+ const TOOL_INPUT_CAP = 64;
39
42
 
40
43
  type Pending = { resolve: (v: any) => void; reject: (e: Error) => void };
41
44
 
@@ -46,6 +49,7 @@ export class AcpPeer extends BasePeer {
46
49
  private nextId = 1;
47
50
  private readonly pending = new Map<number, Pending>();
48
51
  private chunks: string[] = [];
52
+ private readonly toolInputs = new Map<string, unknown>();
49
53
  private primed = false;
50
54
  private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
51
55
 
@@ -108,7 +112,7 @@ export class AcpPeer extends BasePeer {
108
112
  this.primed = true;
109
113
  if (turn !== this.turn) return; // superseded: these chunks belong to a later turn
110
114
  const body = this.chunks.join("").trim();
111
- if (body) this.onMessage?.(body, { inReplyTo: replyParent(envs) });
115
+ if (body) this.onMessage?.(body, { inReplyTo: replyParent(envs), to: replyAudience(envs) });
112
116
  })
113
117
  .catch((e: Error) => {
114
118
  this.opts.log?.(`[${this.id}] prompt failed: ${e.message}`);
@@ -144,6 +148,13 @@ export class AcpPeer extends BasePeer {
144
148
  if (msg.method === "session/update") {
145
149
  const u = msg.params?.update;
146
150
  if (u?.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") this.chunks.push(u.content.text);
151
+ // The permission request that follows may carry no `rawInput` (Kimi 2.0.1 does not), and then the
152
+ // console would be asked to approve a bare tool name. Keep what the call said it would run (issue #31).
153
+ else if ((u?.sessionUpdate === "tool_call" || u?.sessionUpdate === "tool_call_update") && typeof u.toolCallId === "string") {
154
+ if (u.rawInput !== undefined) this.toolInputs.set(u.toolCallId, u.rawInput);
155
+ if (u.status === "completed" || u.status === "failed") this.toolInputs.delete(u.toolCallId);
156
+ while (this.toolInputs.size > TOOL_INPUT_CAP) this.toolInputs.delete(this.toolInputs.keys().next().value as string);
157
+ }
147
158
  else if (u?.sessionUpdate === "usage_update" && this.opts.onTokens) {
148
159
  const f = { ...u, ...(typeof u.usage === "object" ? u.usage : {}) } as Record<string, unknown>;
149
160
  const num = (k: string) => (typeof f[k] === "number" ? (f[k] as number) : 0);
@@ -163,11 +174,14 @@ export class AcpPeer extends BasePeer {
163
174
  }
164
175
 
165
176
  private async answerPermission(msg: any): Promise<void> {
166
- const options: PermissionOption[] = msg.params?.options ?? [];
167
177
  const call = msg.params?.toolCall ?? {};
168
- // The approver has to see what runs, not only the tool's name ("Bash").
169
- const input = call.rawInput === undefined ? "" : `: ${(typeof call.rawInput === "string" ? call.rawInput : JSON.stringify(call.rawInput)).slice(0, 600)}`;
170
- const title: string = `${call.title ?? "tool call"}${input}`;
178
+ // The approver has to see what runs, not only the tool's name ("Bash"). The request itself carries it
179
+ // for some agents; for the rest it was on the `tool_call` update that announced the call.
180
+ const raw = call.rawInput ?? (typeof call.toolCallId === "string" ? this.toolInputs.get(call.toolCallId) : undefined);
181
+ const input = raw === undefined ? "" : `: ${(typeof raw === "string" ? raw : JSON.stringify(raw)).slice(0, 600)}`;
182
+ // An unknown payload is never dressed up as a description, and it must not buy a blanket grant.
183
+ const title: string = raw === undefined ? `${call.title ?? "tool call"} (payload not reported by the agent)` : `${call.title ?? "tool call"}${input}`;
184
+ const options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => raw !== undefined || o.kind !== "allow_always");
171
185
  const picked = await this.opts.onPermission?.({ peer: this.id, title, options }).catch(() => undefined);
172
186
  const valid = options.some((o) => o.optionId === picked);
173
187
  this.send({
@@ -28,17 +28,36 @@ function roles(): Record<string, string[]> {
28
28
  }
29
29
  }
30
30
  const MAX_RECONNECT_DELAY_MS = 30_000;
31
- /** Close codes a retry cannot fix: another session took this peer id (4000), bad token, reserved or taken id, wire version. */
31
+ /** Close codes a retry cannot fix: bad token, wrong project, reserved or taken id, wire version. */
32
32
  const TERMINAL_CLOSES: Record<number, string> = {
33
- 4000: `another session attached to the hub as "${peerId}"; this one is detached (restart it to take the peer back)`,
34
33
  4401: "the hub refused the control token",
35
34
  4404: "the hub belongs to a different project or instance; restart this session from the intended project",
36
35
  4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
37
36
  4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
38
37
  4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
39
38
  };
39
+ /** Another session attached under this peer id. Unlike the other closes this one ends on its own, so the
40
+ * session stands by and takes the peer back once the hub reports it offline (issue #30). */
41
+ const HELD_CLOSE = 4000;
42
+ const HELD = `another session is attached to the hub as "${peerId}"; this one is standing by and takes the peer back when that session leaves`;
40
43
  const INBOX_CAP = 200;
41
44
 
45
+ /**
46
+ * Whether a live session holds this peer id, per the daemon's own status file. The hub gives the id to
47
+ * whoever said hello last, so reconnecting blind would evict the session that just took it and the two
48
+ * would trade the peer forever. No readable status means no live hub holds it, and a connect can only fail.
49
+ */
50
+ function peerHeld(): boolean {
51
+ try {
52
+ const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8")) as { peers?: Record<string, { state?: string; claiming?: boolean }> };
53
+ const peer = status.peers?.[peerId];
54
+ // `claiming`: a hello that has not finished its preface. It reads as offline but a session is arriving.
55
+ return !!peer && (peer.state !== "offline" || peer.claiming === true);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
42
61
  const INSTRUCTIONS = [
43
62
  "agent-hub connects you to other coding agents working in this project (for example codex, kimi, local) and to the hub console user.",
44
63
  'Their messages arrive as <channel source="agent-hub" ...> tags; meta.source names the sender and meta.message_id identifies the message.',
@@ -89,7 +108,13 @@ async function push(envs: Envelope[]): Promise<void> {
89
108
  }
90
109
 
91
110
  async function connectLoop(): Promise<void> {
111
+ let standingBy = false;
92
112
  for (let attempt = 0; ; attempt++) {
113
+ // Standing by after a take-over: wait for the slot rather than evicting whoever holds it now.
114
+ if (standingBy && peerHeld()) {
115
+ await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
116
+ continue;
117
+ }
93
118
  let code: number | undefined;
94
119
  try {
95
120
  const client = await ControlClient.connect(stateDir, { role: toolsOnly ? "tools" : "peer", peer: peerId,
@@ -97,6 +122,8 @@ async function connectLoop(): Promise<void> {
97
122
  client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]); // `env`: a daemon older than wire version 2
98
123
  hub = client;
99
124
  attempt = -1;
125
+ standingBy = false;
126
+ detached = undefined;
100
127
  log(`connected to hub as "${peerId}"`);
101
128
  code = await new Promise<number>((r) => (client.onClose = r));
102
129
  hub = undefined;
@@ -105,11 +132,16 @@ async function connectLoop(): Promise<void> {
105
132
  code = (e as { code?: number }).code;
106
133
  if (attempt === 0) log((e as Error).message);
107
134
  }
108
- // Retrying these only fights another session or hammers a hub that will refuse again.
135
+ // Retrying these only hammers a hub that will refuse again.
109
136
  if (code !== undefined && TERMINAL_CLOSES[code]) {
110
137
  detached = TERMINAL_CLOSES[code];
111
138
  return log(`stopped reconnecting: ${detached}`);
112
139
  }
140
+ if (code === HELD_CLOSE) {
141
+ if (!standingBy) log(`standing by: ${HELD}`);
142
+ standingBy = true;
143
+ detached = HELD;
144
+ }
113
145
  await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
114
146
  }
115
147
  }
@@ -344,6 +344,8 @@ export class CodexPeer extends BasePeer {
344
344
  const inReplyTo = this.injected;
345
345
  this.injected = undefined;
346
346
  this.deltas.clear();
347
+ // ponytail: the reply is addressed to the highest-hop sender only (newEnvelope's default). A digest that
348
+ // mixed senders answers the last of them; track the audience alongside `injected` if that starts to matter.
347
349
  if (this.lastAnswer) this.onMessage?.(this.lastAnswer, inReplyTo ? { inReplyTo } : {});
348
350
  this.lastAnswer = "";
349
351
  this.setState("idle");
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { renderDigest, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
2
+ import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
3
3
  import { TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
5
  import { profile } from "../local/sandbox.ts";
@@ -52,6 +52,7 @@ const system = (cwd: string, preamble = "") =>
52
52
 
53
53
  /** Hub-native agent loop: the hub owns every model call, so it can choose the model per call (L2) and the gateway (L3). */
54
54
  export class LocalPeer extends BasePeer {
55
+ readonly hubNative = true;
55
56
  private readonly history: ChatMessage[] = [];
56
57
  private readonly sessionId = `agent-hub-local-${randomUUID()}`;
57
58
  private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
@@ -96,7 +97,7 @@ export class LocalPeer extends BasePeer {
96
97
  const policy = this.opts.turnPolicy?.(envs);
97
98
  // A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
98
99
  // The task id travels with it, so the board can keep the text (`ahub task show`) while tail and log show a stub.
99
- const reply: EnvelopeOpts = { inReplyTo: replyParent(envs), ...(policy?.pii ? { to: [USER], private: true, priority: "important" as const, ...(policy.task ? { refs: { task: policy.task } } : {}) } : {}) };
100
+ const reply: EnvelopeOpts = { inReplyTo: replyParent(envs), to: replyAudience(envs), ...(policy?.pii ? { to: [USER], private: true, priority: "important" as const, ...(policy.task ? { refs: { task: policy.task } } : {}) } : {}) };
100
101
  this.run(envs, turn, msgs, progress, policy, reply)
101
102
  .then((answer) => {
102
103
  if (turn !== this.turn) return;
@@ -148,7 +149,7 @@ export class LocalPeer extends BasePeer {
148
149
  permit: this.opts.tools.permit,
149
150
  sandboxProfile: this.sandboxProfile,
150
151
  send: (text, to) => {
151
- this.onMessage?.(text, policy?.pii ? reply : { inReplyTo: replyParent(envs), ...(to?.length ? { to } : {}) });
152
+ this.onMessage?.(text, policy?.pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
152
153
  return policy?.pii ? "sent to the console user only (PII task)" : "sent";
153
154
  },
154
155
  };
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { join, resolve, relative } from "node:path";
4
4
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5
- import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
5
+ import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
6
6
  import { BasePeer } from "../hub/peers.ts";
7
7
  import { stopOwnedProcess } from "../hub/child-process.ts";
8
8
 
@@ -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
 
@@ -32,6 +33,7 @@ const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): s
32
33
  };
33
34
 
34
35
  export class PiPeer extends BasePeer {
36
+ readonly hubNative = true;
35
37
  private proc?: ChildProcessWithoutNullStreams;
36
38
  private server?: ReturnType<typeof Bun.serve>;
37
39
  private buffer = "";
@@ -43,6 +45,10 @@ export class PiPeer extends BasePeer {
43
45
  private currentReply?: Envelope;
44
46
  private sessionId = "";
45
47
  private sessionFile = "";
48
+ private emptyResumeVerified = false;
49
+ private verifiedEmptyResume?: VerifiedEmptyResume;
50
+ private activityObserved = false;
51
+ private persistedOnce = false;
46
52
  private activeEnvs: Envelope[] = [];
47
53
  private activeTools = 0;
48
54
  private agentRunning = false;
@@ -64,15 +70,60 @@ export class PiPeer extends BasePeer {
64
70
 
65
71
  constructor(id: PeerId, private readonly opts: PiOptions) { super(id, opts.watchdogMs); }
66
72
  get tuiLaunch(): PiTuiLaunch | undefined { return this._tuiLaunch; }
73
+ get pendingResume(): { sessionId?: string; sessionFile?: string } {
74
+ if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
75
+ return { sessionId: this.opts.sessionId, sessionFile: this.opts.sessionFile };
76
+ }
67
77
  get acceptingTools(): boolean { return !this.stopping; }
68
78
  /** Idle verified owners can be stopped; uncertain live owners and pending launches stay fenced. */
69
79
  get recoveryReady(): boolean {
70
80
  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;
81
+ if (this.sessionFile && !existsSync(this.sessionFile) && (this.activityObserved || this.persistedOnce)) return false;
82
+ if (this.opts.mode === "headless") return this.state === "idle" || !this.proc || this.proc.exitCode !== null || this.proc.signalCode !== null;
72
83
  if (this.state === "idle") return this.ownerClaimed && !!this.ownerPid && processSignature(this.ownerPid) === this.ownerSignature;
73
84
  return this.stopping && !this.ownerClaimed && (!this.ownerPid || !ownerStillAlive(this.ownerPid, this.ownerSignature));
74
85
  }
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 } : {}) }; }
86
+ recoveryMetadata(): Record<string, unknown> {
87
+ const exists = !!this.sessionFile && existsSync(this.sessionFile);
88
+ if (exists) { this.persistedOnce = true; this.verifiedEmptyResume = undefined; }
89
+ const empty = this.emptyResumeVerified && !this.activityObserved && !this.persistedOnce;
90
+ const file = this.sessionFile && (exists || !empty) ? this.sessionFile : undefined;
91
+ 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 } : {}) };
92
+ }
93
+
94
+ /** Verify the live source before choosing ID-only restoration. Never infer emptiness from a missing file. */
95
+ async captureResume(): Promise<Record<string, unknown>> {
96
+ if (this.state === "offline") {
97
+ if (this.canReuseVerifiedEmptyResume()) return this.recoveryMetadata();
98
+ if (this.recoveryReady && this.sessionFile && existsSync(this.sessionFile)) return this.recoveryMetadata();
99
+ throw new Error("Pi source session is offline and has no verified persisted resume state");
100
+ }
101
+ this.emptyResumeVerified = false;
102
+ if (this.state !== "idle" || this.stopping || this.activeTools) throw new Error("Pi must settle before session capture");
103
+ if (this.sessionFile && existsSync(this.sessionFile)) return this.recoveryMetadata();
104
+ const snapshot = this.opts.mode === "headless"
105
+ ? (await this.waitRpc("get_state", 15_000)).data
106
+ : await this.sendTui({ type: "get_session_state" });
107
+ if (this.state !== "idle" || this.activeTools || snapshot?.sessionId !== this.sessionId || snapshot?.sessionFile !== this.sessionFile) throw new Error("Pi source session changed during capture");
108
+ const empty = this.opts.mode === "headless"
109
+ ? snapshot.messageCount === 0 && snapshot.pendingMessageCount === 0 && snapshot.isStreaming === false && snapshot.isCompacting !== true && !snapshot.sessionName
110
+ : snapshot.empty === true && snapshot.idle === true;
111
+ if (!empty || this.activityObserved || this.persistedOnce) throw new Error("Pi session history is not persisted; refusing to recreate it");
112
+ this.emptyResumeVerified = true;
113
+ const captured = this.recoveryMetadata();
114
+ if (typeof captured.sessionId === "string" && !captured.sessionFile) this.verifiedEmptyResume = { sessionId: captured.sessionId };
115
+ return captured;
116
+ }
117
+
118
+ private canReuseVerifiedEmptyResume(): boolean {
119
+ return !!this.verifiedEmptyResume && this.recoveryReady && !this.activityObserved && !this.persistedOnce && this.sessionId === this.verifiedEmptyResume.sessionId && (!this.sessionFile || !existsSync(this.sessionFile));
120
+ }
121
+
122
+ private noteActivity(): void {
123
+ this.activityObserved = true;
124
+ this.emptyResumeVerified = false;
125
+ this.verifiedEmptyResume = undefined;
126
+ }
76
127
 
77
128
  async start(): Promise<void> {
78
129
  if (this.starting) return this.starting;
@@ -112,6 +163,7 @@ export class PiPeer extends BasePeer {
112
163
  }
113
164
  if (url.pathname === "/tool") {
114
165
  if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
166
+ this.noteActivity();
115
167
  this.activeTools++;
116
168
  if (this.state === "idle") this.setState("busy");
117
169
  if (this.state === "busy") this.touch();
@@ -148,6 +200,7 @@ export class PiPeer extends BasePeer {
148
200
  throw new Error("Pi get_state session identity does not match the requested recovery session");
149
201
  }
150
202
  this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
203
+ this.persistedOnce = existsSync(this.sessionFile);
151
204
  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
205
  this.setState("idle");
153
206
  }
@@ -194,6 +247,7 @@ export class PiPeer extends BasePeer {
194
247
  try {
195
248
  const requested = await this.opts.selectModel?.(envs);
196
249
  if (requested) await this.setRequestedModel(requested);
250
+ this.noteActivity();
197
251
  attempted = true;
198
252
  if (this.opts.mode === "tui") await this.sendTui({ type: "prompt", message: renderDigest(envs, true) });
199
253
  else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
@@ -235,7 +289,7 @@ export class PiPeer extends BasePeer {
235
289
  this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
236
290
  }
237
291
  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"); }
292
+ if (event.type === "agent_start") { this.noteActivity(); this.agentRunning = true; this.setState("busy"); }
239
293
  if (event.type === "activity" && this.state === "busy") this.touch();
240
294
  if (event.type === "agent_end") {
241
295
  this.settledText = typeof event.text === "string" ? event.text : "";
@@ -247,9 +301,12 @@ export class PiPeer extends BasePeer {
247
301
  if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
248
302
  const text = this.settledText.trim(); const error = this.settledError; const cancelled = this.settledCancelled;
249
303
  this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
250
- if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", { inReplyTo: this.currentReply });
304
+ // Answer the peers this turn was for, not every peer on the bus (issue #29). activeEnvs still holds the
305
+ // whole delivery here, steered additions included.
306
+ const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
307
+ if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
251
308
  else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
252
- else if (text) this.onMessage?.(text, { inReplyTo: this.currentReply });
309
+ else if (text) this.onMessage?.(text, reply);
253
310
  this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
254
311
  }
255
312
  }
package/src/cli/main.ts CHANGED
@@ -25,6 +25,8 @@ import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-r
25
25
  import { recordTerminalLaunch } from "./terminal-recovery.ts";
26
26
  import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
27
27
 
28
+ import { backendLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
29
+
28
30
  const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
29
31
 
30
32
  ahub --project <path|id> <command> select a repository or worktree explicitly
@@ -570,10 +572,10 @@ const commands: Record<string, () => Promise<void> | void> = {
570
572
  const { status } = await hub.request({ t: "status" });
571
573
  hub.close();
572
574
  console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
573
- const peers = Object.entries(status.peers as Record<string, { state: string; queued: number }>);
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}` : ""}`);
575
+ const peers = Object.entries(status.peers as Record<string, PeerRow>);
576
+ for (const [id, p] of peers) console.log(peerLine(id, p));
577
+ const models = (status as any).models?.backends as BackendRow[] | undefined;
578
+ if (models?.length) for (const backend of models) console.log(backendLine(backend));
577
579
  if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
578
580
  const counts = Object.entries(status.tasks ?? {}).map(([s, n]) => `${n} ${s}`).join(", ");
579
581
  if (counts) console.log(` tasks: ${counts} (ahub board)`);
@@ -0,0 +1,27 @@
1
+ // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
2
+
3
+ export interface PeerRow { state?: string; queued?: number; paused?: string; servedBy?: string; requestedModel?: string }
4
+ export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
5
+
6
+ /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
7
+ export function backendLabel(backend: BackendRow): string {
8
+ const kind = backend.kind ?? "unknown";
9
+ const alias = backend.alias ?? "unknown";
10
+ return alias.startsWith(`${kind}/`) ? alias : `${kind}/${alias}`;
11
+ }
12
+
13
+ export function peerLine(id: string, p: PeerRow): string {
14
+ return ` ${id.padEnd(8)} ${(p.state ?? "unknown").padEnd(8)} queued ${p.queued ?? 0}` +
15
+ (p.paused ? ` (${p.paused})` : "") +
16
+ (p.servedBy ? ` last call: ${p.servedBy}` : "") +
17
+ // Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
18
+ // meant reading status.json by hand.
19
+ (p.requestedModel ? ` model: ${p.requestedModel}` : "");
20
+ }
21
+
22
+ export function backendLine(backend: BackendRow): string {
23
+ return ` model ${backendLabel(backend)} ${backend.state ?? "unknown"} active ${backend.active ?? 0}` +
24
+ (backend.requestedModel ? ` requested ${backend.requestedModel}` : "") +
25
+ (backend.actualModel ? ` actual ${backend.actualModel}` : "") +
26
+ (backend.provider ? ` provider ${backend.provider}` : "");
27
+ }
package/src/hub/bus.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HUB, MAX_HOP, newEnvelope, parseMarker, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
1
+ import { DIGEST, HUB, MAX_HOP, newEnvelope, parseMarker, replyAudience, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter } from "./peers.ts";
3
3
 
4
4
  export type BusEvent =
@@ -30,6 +30,27 @@ export interface BusSnapshot {
30
30
 
31
31
  export const DEFAULT_BUS: BusOptions = { retryMs: 1000, batchMax: 3, batchMs: 15_000, queueCap: 200 };
32
32
 
33
+ /**
34
+ * What a peer may claim for its own message. A hub-native peer writes `[IMPORTANT]` on routine status reports
35
+ * (issue #29), and `important` interrupts every recipient at once, so it only keeps it when answering an
36
+ * `important` request that was addressed to it. `fyi` and `status` are never capped.
37
+ */
38
+ function capPriority(peer: PeerAdapter, priority: Priority, parent: EnvelopeOpts["inReplyTo"], delivery: Envelope[] | undefined): Priority {
39
+ if (!peer.hubNative || priority !== "important") return priority;
40
+ if (!parent) return "status"; // nothing was asked: an unsolicited report is never urgent enough to interrupt
41
+ // The whole delivery, not just `replyParent`: an important request addressed to the peer can share a hop with a
42
+ // later status item, and then the parent is that status item.
43
+ const asked = delivery ?? [parent];
44
+ return asked.some((e) => e.priority === "important" && !!e.to?.includes(peer.id)) ? "important" : "status";
45
+ }
46
+
47
+ /** `digest` is not a peer. A reply to a condensed delivery is for the senders that condensation replaced. */
48
+ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
49
+ if (!to.includes(DIGEST)) return to;
50
+ const senders = originals ? replyAudience(originals) : [];
51
+ return [...new Set(to.flatMap((id) => (id === DIGEST ? senders : [id])))];
52
+ }
53
+
33
54
  const SEEN_CAP = 2048;
34
55
  const MAX_ATTEMPTS = 3;
35
56
  const DIGEST_MAX = 10;
@@ -66,7 +87,17 @@ export class Bus {
66
87
  if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
67
88
  peer.onMessage = (text, opts) => {
68
89
  const { priority, body } = parseMarker(text);
69
- if (body) this.publish(newEnvelope(peer.id, body, { priority, ...opts }));
90
+ if (!body) return;
91
+ // What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
92
+ // and the priority ceiling are about what it stands for.
93
+ const last = this.lastDelivery.get(peer.id);
94
+ const answers = opts?.inReplyTo?.id !== undefined && last?.out.some((e) => e.id === opts.inReplyTo!.id);
95
+ const originals = answers ? last!.originals : undefined;
96
+ this.publish(newEnvelope(peer.id, body, {
97
+ ...opts,
98
+ ...(opts?.to?.length ? { to: resolveTo(opts.to, originals) } : {}),
99
+ priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals),
100
+ }));
70
101
  };
71
102
  peer.onFailed = (envs) => {
72
103
  // The adapter got the condensed list; what has to come back is what that list replaced.
package/src/hub/daemon.ts CHANGED
@@ -131,6 +131,11 @@ class WsPeer extends BasePeer {
131
131
  claim(sock: Sock): void {
132
132
  this.claimed = sock;
133
133
  }
134
+ /** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
135
+ * for the id must not take it from a claimant that is still arriving. */
136
+ get claiming(): boolean {
137
+ return !!this.claimed && this.claimed !== this.sock && this.claimed.readyState === WebSocket.OPEN;
138
+ }
134
139
  attach(sock: Sock): void {
135
140
  if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
136
141
  this.sock?.close(4000, "replaced");
@@ -493,7 +498,7 @@ export async function startDaemon(opts: DaemonOptions) {
493
498
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
494
499
  codexProxyPort: opts.codexProxyPort,
495
500
  peers: Object.fromEntries(
496
- [...bus.peers].map(([id, p]) => [id, { state: bus.stateOf(id), queued: bus.queued(id), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]),
501
+ [...bus.peers].map(([id, p]) => [id, { state: bus.stateOf(id), queued: bus.queued(id), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]),
497
502
  ),
498
503
  ...(sidecar ? { switchyard: sidecar.status } : {}),
499
504
  ...(modelRelay ? { models: modelRelay.status() } : {}),
@@ -558,7 +563,7 @@ export async function startDaemon(opts: DaemonOptions) {
558
563
  }
559
564
  const existing = bus.peers.get(peer);
560
565
  if (peer === "pi" && existing instanceof PiPeer) {
561
- const saved = existing.recoveryMetadata();
566
+ let saved = existing.recoveryMetadata();
562
567
  const launch = saved.launch as Record<string, unknown>;
563
568
  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
569
  const mode = args.mode ?? "headless";
@@ -566,17 +571,20 @@ export async function startDaemon(opts: DaemonOptions) {
566
571
  const unclaimed = existing.state === "offline" && !saved.sessionId && !saved.sessionFile;
567
572
  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
573
  if (unclaimed) {
574
+ if (!args.sessionId && !args.sessionFile) args = { ...args, ...existing.pendingResume };
569
575
  // Revoke the previous launch bridge before issuing another launch. A late
570
576
  // process from the abandoned CLI cannot claim the replacement owner.
571
577
  await existing.stop();
572
578
  } 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) };
579
+ saved = await existing.captureResume();
580
+ if (!saved.sessionId) return { ok: false, error: "Pi session identity is not ready for handover" };
581
+ 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
582
  await existing.stop();
576
583
  } else if (existing.state !== "offline") {
577
584
  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) };
585
+ } else if (saved.sessionId) {
586
+ saved = await existing.captureResume();
587
+ 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
588
  }
581
589
  }
582
590
  if (existing && existing.state !== "offline") {
@@ -847,6 +855,12 @@ export async function startDaemon(opts: DaemonOptions) {
847
855
  async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
848
856
  if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
849
857
  if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
858
+ if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
859
+ const pi = bus.peers.get("pi");
860
+ if (pi instanceof PiPeer && pi.state === "idle") {
861
+ try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
862
+ }
863
+ }
850
864
  if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
851
865
  if (typeof msg.operationId !== "string" || msg.operationId.length < 1 || msg.operationId.length > 128) return recoveryError("operationId is required");
852
866
  const op = msg.operationId as string;
@@ -970,6 +984,7 @@ export async function startDaemon(opts: DaemonOptions) {
970
984
  if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
971
985
  const ws = peer;
972
986
  ws.claim(sock);
987
+ writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
973
988
  void ensurePreface(c.peer).finally(() => {
974
989
  if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
975
990
  });
@@ -25,13 +25,15 @@ export const MAX_HOP = 3;
25
25
  export const USER: PeerId = "user";
26
26
  /** The hub itself: sender of recall and workflow events. */
27
27
  export const HUB: PeerId = "hub";
28
+ /** A hub-written condensation of several status messages. Not a peer: a reply to it is for the senders it replaced. */
29
+ export const DIGEST: PeerId = "digest";
28
30
 
29
31
  export interface EnvelopeOpts {
30
32
  to?: PeerId[];
31
33
  kind?: Kind;
32
34
  priority?: Priority;
33
- /** The envelope this one answers: inherits its trace, hop + 1. */
34
- inReplyTo?: Pick<Envelope, "trace" | "hop">;
35
+ /** The envelope this one answers: inherits its trace, hop + 1, and - when `to` is absent - its sender as the addressee. */
36
+ inReplyTo?: Pick<Envelope, "trace" | "hop"> & Partial<Pick<Envelope, "id" | "from" | "to" | "priority">>;
35
37
  refs?: Envelope["refs"];
36
38
  private?: boolean;
37
39
  }
@@ -42,7 +44,10 @@ export function newEnvelope(from: PeerId, body: string, opts: EnvelopeOpts = {})
42
44
  trace: opts.inReplyTo?.trace ?? randomUUID(),
43
45
  hop: opts.inReplyTo ? opts.inReplyTo.hop + 1 : 0,
44
46
  from,
45
- ...(opts.to?.length ? { to: opts.to } : {}),
47
+ // An answer goes back to whoever asked. Without this a reply carries no `to` and the bus fans it out to
48
+ // every peer, so one directed question costs every other agent a turn (issue #29). `user` and `hub` are not
49
+ // peers, so a reply to them reaches the console and the log and nobody's turn.
50
+ ...(opts.to?.length ? { to: opts.to } : opts.inReplyTo?.from ? { to: [opts.inReplyTo.from] } : {}),
46
51
  kind: opts.kind ?? "chat",
47
52
  priority: opts.priority ?? "status",
48
53
  body,
@@ -80,6 +85,11 @@ export function renderDigest(envs: Envelope[], primed: boolean): string {
80
85
  return primed ? items : `${STANDING_INSTRUCTION}\n\n${items}`;
81
86
  }
82
87
 
88
+ /** Who a reply to a delivery is for: everyone whose message it answers, each once, in the order they arrived. */
89
+ export function replyAudience(envs: Envelope[]): PeerId[] {
90
+ return [...new Set(envs.filter((e) => !(e.from === HUB && e.kind === "presence")).map((e) => e.from))];
91
+ }
92
+
83
93
  /** What a reply to a delivery answers: the highest-hop item, so a digest cannot be used to reset the hop cap. */
84
94
  export function replyParent(envs: Envelope[]): Envelope {
85
95
  // Never the hub's own context block (it was not published, so nothing can resolve it); ties go to the later item.
@@ -1,7 +1,7 @@
1
1
  import type { OmniRoute } from "../omniroute/client.ts";
2
2
  import type { Sidecar } from "../switchyard/sidecar.ts";
3
3
  import { CLASSES, type TaskClass } from "./board.ts";
4
- import { newEnvelope, type Envelope } from "./envelope.ts";
4
+ import { DIGEST, newEnvelope, type Envelope } from "./envelope.ts";
5
5
 
6
6
  export interface InferenceConfig {
7
7
  enabled: boolean;
@@ -14,7 +14,7 @@ export interface InferenceConfig {
14
14
  export const DEFAULT_INFERENCE: InferenceConfig = { enabled: true, digest_min_items: 5, digest_min_chars: 8000, triage: true };
15
15
 
16
16
  /** Sender of a condensed digest. Not the hub's own id: replies to it must keep the hop count of what it replaced. */
17
- export const DIGEST = "digest";
17
+ export { DIGEST };
18
18
 
19
19
  const TIMEOUT_MS = 8000;
20
20
  const BACKOFF_MS = 5 * 60_000;
package/src/hub/peers.ts CHANGED
@@ -3,6 +3,8 @@ import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
3
3
  export interface PeerAdapter {
4
4
  readonly id: PeerId;
5
5
  readonly state: PeerState;
6
+ /** A peer the hub drives itself (Pi, the local worker). It cannot be trusted to rate its own urgency: the bus caps it. */
7
+ readonly hubNative?: boolean;
6
8
  /** Inject now, as one prompt. Only called while `state === "idle"`. Rejecting puts the envelopes back at the queue head. */
7
9
  deliver(envs: Envelope[]): Promise<void>;
8
10
  /** Optional: feed envelopes into the turn that is running now. Only called while `state === "busy"`. */
@@ -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");