@staix/agent-hub 0.3.1 → 0.3.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2
4
+
5
+ - The channel plugin no longer fights a second session attached as the same peer: the replaced one stays detached and its tools say so, instead of taking the peer back every second (#16).
6
+ - The plugin MCP server exits when its host goes away, and stops retrying a hub that refused it (wire version, token, peer id), reporting the hub's reason (#17).
7
+ - After upgrading, run `ahub setup` to refresh the Claude plugin and restart agent sessions; stale `server.js` processes from earlier versions have to be ended by hand once.
8
+
3
9
  ## 0.3.1
4
10
 
5
11
  - Hub task, review and budget events are distinguished from reference-only presence/recall in agent instructions and rendered message kinds, including mixed Claude digests.
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 one project directory, with
5
5
  task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
6
6
 
7
- Status: 0.3.1. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
7
+ Status: 0.3.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
  ## Start here
@@ -22,7 +22,7 @@ cd <your project> && ahub init && ahub up && ahub tail
22
22
  Or install the same version from GitHub:
23
23
 
24
24
  ```bash
25
- bun add -g github:STAIxBWLB/agent-hub#v0.3.1 && ahub setup
25
+ bun add -g github:STAIxBWLB/agent-hub#v0.3.2 && ahub setup
26
26
  ```
27
27
 
28
28
  The installed commands remain `ahub` and `agent-hub`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code and a local worker 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.3.1",
3
+ "version": "0.3.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",
@@ -15566,12 +15566,12 @@ class ControlClient {
15566
15566
  const ws = new WebSocket(control.url);
15567
15567
  const client = new ControlClient(ws);
15568
15568
  ws.onerror = () => reject(new Error(`cannot reach hub at ${control.url}`));
15569
- ws.onclose = () => {
15570
- reject(new Error("hub closed the connection (stale token?)"));
15569
+ ws.onclose = (ev) => {
15570
+ reject(Object.assign(new Error(`hub closed the connection: ${ev.reason || "stale token?"}`), { code: ev.code }));
15571
15571
  for (const done of client.pending.values())
15572
15572
  done({ ok: false, error: "hub connection closed" });
15573
15573
  client.pending.clear();
15574
- client.onClose();
15574
+ client.onClose(ev.code, ev.reason);
15575
15575
  };
15576
15576
  ws.onmessage = (ev) => {
15577
15577
  const msg = JSON.parse(String(ev.data));
@@ -15602,7 +15602,7 @@ class ControlClient {
15602
15602
  // package.json
15603
15603
  var package_default = {
15604
15604
  name: "@staix/agent-hub",
15605
- version: "0.3.1",
15605
+ version: "0.3.2",
15606
15606
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code and a local worker as peers in one project",
15607
15607
  license: "MIT",
15608
15608
  type: "module",
@@ -15713,6 +15713,13 @@ function roles() {
15713
15713
  }
15714
15714
  }
15715
15715
  var MAX_RECONNECT_DELAY_MS = 30000;
15716
+ var TERMINAL_CLOSES = {
15717
+ 4000: `another session attached to the hub as "${peerId}"; this one is detached (restart it to take the peer back)`,
15718
+ 4401: "the hub refused the control token",
15719
+ 4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
15720
+ 4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
15721
+ 4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session"
15722
+ };
15716
15723
  var INBOX_CAP = 200;
15717
15724
  var INSTRUCTIONS = [
15718
15725
  "agent-hub connects you to other coding agents working in this project (for example codex, kimi, local) and to the hub console user.",
@@ -15734,6 +15741,8 @@ var text = (s) => ({ content: [{ type: "text", text: s }] });
15734
15741
  var server = new Server({ name: "agent-hub", version: VERSION }, toolsOnly ? { capabilities: { tools: {} }, instructions: TOOLS_INSTRUCTIONS } : { capabilities: { experimental: { "claude/channel": {} }, tools: {} }, instructions: INSTRUCTIONS });
15735
15742
  var inbox = [];
15736
15743
  var hub;
15744
+ var detached;
15745
+ var offline = () => detached ?? "hub is not running for this project (start it with: ahub up).";
15737
15746
  async function push(envs) {
15738
15747
  const parent = replyParent(envs);
15739
15748
  const single = envs.length === 1;
@@ -15761,19 +15770,25 @@ ${sanitize(e.body)}`).join(`
15761
15770
  }
15762
15771
  async function connectLoop() {
15763
15772
  for (let attempt = 0;; attempt++) {
15773
+ let code;
15764
15774
  try {
15765
15775
  const client = await ControlClient.connect(stateDir, { role: toolsOnly ? "tools" : "peer", peer: peerId });
15766
15776
  client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]);
15767
15777
  hub = client;
15768
15778
  attempt = -1;
15769
15779
  log(`connected to hub as "${peerId}"`);
15770
- await new Promise((r) => client.onClose = r);
15780
+ code = await new Promise((r) => client.onClose = r);
15771
15781
  hub = undefined;
15772
15782
  log("hub connection lost");
15773
15783
  } catch (e) {
15784
+ code = e.code;
15774
15785
  if (attempt === 0)
15775
15786
  log(e.message);
15776
15787
  }
15788
+ if (code !== undefined && TERMINAL_CLOSES[code]) {
15789
+ detached = TERMINAL_CLOSES[code];
15790
+ return log(`stopped reconnecting: ${detached}`);
15791
+ }
15777
15792
  await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
15778
15793
  }
15779
15794
  }
@@ -15814,7 +15829,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
15814
15829
  if (name === "hub_send") {
15815
15830
  const { text: body, to, reply_to } = args ?? {};
15816
15831
  if (!hub)
15817
- return text("hub is not running for this project (start it with: ahub up). Message not sent.");
15832
+ return text(`${offline()} Message not sent.`);
15818
15833
  const res = await hub.request({ t: "send", body, to, reply_to });
15819
15834
  if (!res.ok)
15820
15835
  return text(`not sent: ${res.error}`);
@@ -15824,11 +15839,13 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
15824
15839
  }
15825
15840
  if (TASK_TOOL_NAMES.has(name)) {
15826
15841
  if (!hub)
15827
- return text("hub is not running for this project (start it with: ahub up).");
15842
+ return text(offline());
15828
15843
  const res = await hub.request({ t: "task", op: name, args: args ?? {} });
15829
15844
  return text(res.ok ? res.text : `error: ${res.error}`);
15830
15845
  }
15831
15846
  return text(`unknown tool ${name}`);
15832
15847
  });
15833
15848
  await server.connect(new StdioServerTransport);
15849
+ process.stdin.on("end", () => process.exit(0));
15850
+ process.stdin.on("close", () => process.exit(0));
15834
15851
  connectLoop();
@@ -24,6 +24,14 @@ function roles(): Record<string, string[]> {
24
24
  }
25
25
  }
26
26
  const MAX_RECONNECT_DELAY_MS = 30_000;
27
+ /** Close codes a retry cannot fix: another session took this peer id (4000), bad token, reserved or taken id, wire version. */
28
+ const TERMINAL_CLOSES: Record<number, string> = {
29
+ 4000: `another session attached to the hub as "${peerId}"; this one is detached (restart it to take the peer back)`,
30
+ 4401: "the hub refused the control token",
31
+ 4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
32
+ 4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
33
+ 4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
34
+ };
27
35
  const INBOX_CAP = 200;
28
36
 
29
37
  const INSTRUCTIONS = [
@@ -50,6 +58,8 @@ const server = new Server(
50
58
 
51
59
  const inbox: string[] = []; // pushes that failed; drained by hub_inbox
52
60
  let hub: ControlClient | undefined;
61
+ let detached: string | undefined; // why this server stopped reconnecting; tool calls report it
62
+ const offline = () => detached ?? "hub is not running for this project (start it with: ahub up).";
53
63
 
54
64
  /** One delivery = one notification, because every notification can cost Claude a turn. */
55
65
  async function push(envs: Envelope[]): Promise<void> {
@@ -75,18 +85,25 @@ async function push(envs: Envelope[]): Promise<void> {
75
85
 
76
86
  async function connectLoop(): Promise<void> {
77
87
  for (let attempt = 0; ; attempt++) {
88
+ let code: number | undefined;
78
89
  try {
79
90
  const client = await ControlClient.connect(stateDir, { role: toolsOnly ? "tools" : "peer", peer: peerId });
80
91
  client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]); // `env`: a daemon older than wire version 2
81
92
  hub = client;
82
93
  attempt = -1;
83
94
  log(`connected to hub as "${peerId}"`);
84
- await new Promise<void>((r) => (client.onClose = r));
95
+ code = await new Promise<number>((r) => (client.onClose = r));
85
96
  hub = undefined;
86
97
  log("hub connection lost");
87
98
  } catch (e) {
99
+ code = (e as { code?: number }).code;
88
100
  if (attempt === 0) log((e as Error).message);
89
101
  }
102
+ // Retrying these only fights another session or hammers a hub that will refuse again.
103
+ if (code !== undefined && TERMINAL_CLOSES[code]) {
104
+ detached = TERMINAL_CLOSES[code];
105
+ return log(`stopped reconnecting: ${detached}`);
106
+ }
90
107
  await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
91
108
  }
92
109
  }
@@ -129,14 +146,14 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
129
146
  }
130
147
  if (name === "hub_send") {
131
148
  const { text: body, to, reply_to } = (args ?? {}) as { text?: string; to?: string[]; reply_to?: string };
132
- if (!hub) return text("hub is not running for this project (start it with: ahub up). Message not sent.");
149
+ if (!hub) return text(`${offline()} Message not sent.`);
133
150
  const res = await hub.request({ t: "send", body, to, reply_to });
134
151
  if (!res.ok) return text(`not sent: ${res.error}`);
135
152
  if (res.recorded) return text("recorded only ([FYI]): it is on the hub console and log, and no peer spent a turn on it");
136
153
  return text(`sent to: ${res.targets.join(", ") || "(no other peers attached)"}`);
137
154
  }
138
155
  if (TASK_TOOL_NAMES.has(name)) {
139
- if (!hub) return text("hub is not running for this project (start it with: ahub up).");
156
+ if (!hub) return text(offline());
140
157
  const res = await hub.request({ t: "task", op: name, args: args ?? {} });
141
158
  return text(res.ok ? res.text : `error: ${res.error}`);
142
159
  }
@@ -144,4 +161,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
144
161
  });
145
162
 
146
163
  await server.connect(new StdioServerTransport());
164
+ // The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
165
+ process.stdin.on("end", () => process.exit(0));
166
+ process.stdin.on("close", () => process.exit(0));
147
167
  void connectLoop();
package/src/cli/main.ts CHANGED
@@ -388,7 +388,7 @@ const commands: Record<string, () => Promise<void> | void> = {
388
388
  const hub = await connect().catch(() => undefined);
389
389
  if (hub) {
390
390
  hub.send({ t: "kill" });
391
- await new Promise<void>((r) => (hub.onClose = r));
391
+ await new Promise<void>((r) => (hub.onClose = () => r()));
392
392
  return console.log("hub stopped");
393
393
  }
394
394
  const pidFile = join(stateDir, "hub.pid");
@@ -30,7 +30,8 @@ export class ControlClient {
30
30
  private nextRid = 1;
31
31
  private readonly pending = new Map<number, (msg: any) => void>();
32
32
  onPush: (msg: any) => void = () => {};
33
- onClose: () => void = () => {};
33
+ /** `code`/`reason`: what the hub closed with, e.g. 4000 when another client attached as the same peer. */
34
+ onClose: (code: number, reason: string) => void = () => {};
34
35
 
35
36
  private constructor(private readonly ws: WebSocket) {}
36
37
 
@@ -41,11 +42,12 @@ export class ControlClient {
41
42
  const ws = new WebSocket(control.url);
42
43
  const client = new ControlClient(ws);
43
44
  ws.onerror = () => reject(new Error(`cannot reach hub at ${control.url}`));
44
- ws.onclose = () => {
45
- reject(new Error("hub closed the connection (stale token?)"));
45
+ ws.onclose = (ev) => {
46
+ // A refused hello carries its reason (bad token, wire version, peer id); say that, not a guess.
47
+ reject(Object.assign(new Error(`hub closed the connection: ${ev.reason || "stale token?"}`), { code: ev.code }));
46
48
  for (const done of client.pending.values()) done({ ok: false, error: "hub connection closed" });
47
49
  client.pending.clear();
48
- client.onClose();
50
+ client.onClose(ev.code, ev.reason);
49
51
  };
50
52
  ws.onmessage = (ev) => {
51
53
  const msg = JSON.parse(String(ev.data));
package/src/hub/daemon.ts CHANGED
@@ -107,7 +107,13 @@ type Sock = ServerWebSocket<Client>;
107
107
  /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
108
108
  class WsPeer extends BasePeer {
109
109
  private sock: Sock | undefined;
110
+ private claimed: Sock | undefined;
111
+ /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
112
+ claim(sock: Sock): void {
113
+ this.claimed = sock;
114
+ }
110
115
  attach(sock: Sock): void {
116
+ if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
111
117
  this.sock?.close(4000, "replaced");
112
118
  this.sock = sock;
113
119
  this.setState("idle");
@@ -584,6 +590,7 @@ export async function startDaemon(opts: DaemonOptions) {
584
590
  if (!peer) bus.add((peer = new WsPeer(c.peer)));
585
591
  if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
586
592
  const ws = peer;
593
+ ws.claim(sock);
587
594
  void ensurePreface(c.peer).finally(() => {
588
595
  if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
589
596
  });