@staix/agent-hub 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Independent repository and worktree hubs with canonical project selection, `--project <path|id>`, `projects`, and `status --all` (#19).
6
+ - Transactional machine-wide project/port registration, concurrent startup claims, authenticated instance checks and owned shutdown; legacy port allocations are imported without resetting them.
7
+ - `ahub ui --all` opens a separate local manager for project selection, hub start/stop and existing dashboard actions. `ahub ui --all --stop` leaves project hubs running.
8
+ - Native memory aliases stay compatible with claude-mem, including worktree parent/composite names. Shared aliases are indicated in the manager.
9
+ - A second Codex TUI cannot take over an existing hub connection. Owned child shutdown is awaited before runtime ownership is released.
10
+ - Control protocol 7: stop old hubs with their matching CLI before upgrading; refresh the channel bundle with `ahub setup`, then restart hubs and agent sessions. No automatic restarts or account configuration changes.
11
+
12
+ ## 0.3.2
13
+
14
+ - 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).
15
+ - 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).
16
+ - 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.
17
+
3
18
  ## 0.3.1
4
19
 
5
20
  - 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
@@ -1,10 +1,10 @@
1
1
  # agent-hub
2
2
 
3
3
  Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Code and a
4
- hub-owned local-LLM worker collaborate as peers in one project directory, with
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.3.1. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
7
+ Status: 0.4.0. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
8
8
  says what has and has not been verified against real agents.
9
9
 
10
10
  ## 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.4.0 && ahub setup
26
26
  ```
27
27
 
28
28
  The installed commands remain `ahub` and `agent-hub`.
@@ -69,3 +69,51 @@ peers, and answer approvals. Budget pauses still require the terminal override.
69
69
  Private envelopes and PII tasks stay redacted. Local-worker tool details and
70
70
  allow decisions stay in `ahub tail` / `ahub permit`; the page can deny them.
71
71
  No dashboard port is opened until requested. See [the session security model](docs/security.md#local-dashboard-sessions-issue-6).
72
+
73
+
74
+ ## Multiple projects and worktrees
75
+
76
+ Version 0.4.0 adds one independent hub per repository or Git worktree.
77
+ Subdirectory commands resolve to that project; symlink aliases resolve to the same
78
+ hub. Explicit selection works from any directory:
79
+
80
+ ```bash
81
+ ahub --project /path/to/project-a init
82
+ ahub --project /path/to/project-b init
83
+ ahub --project /path/to/project-a up
84
+ ahub --project /path/to/project-b up
85
+ ahub projects
86
+ ahub status --all
87
+ ahub --project /path/to/project-a codex
88
+ ahub ui --all
89
+ ```
90
+
91
+ The unified dashboard lists registered roots and opens their boards, streams,
92
+ messages, approvals and budgets. It can start hubs and stop a selected hub after
93
+ confirmation. Launch Claude/Codex TUIs from a terminal. The manager stays available
94
+ when a project stops; `ahub ui --all --stop` stops only the manager. Existing
95
+ `ahub ui` still opens the current project's dashboard.
96
+
97
+ `ahub projects --json` returns machine-readable live status. Use a listed ID or a
98
+ path with `--project`; the selector must precede the command. `ahub projects remove
99
+ <id>` forgets a stopped registration without deleting project data. A moved root
100
+ gets a new registration. Missing roots remain visible until removed.
101
+
102
+ Runtime state stays in each project's `.agenthub/state/`. The machine registry is
103
+ `~/.agenthub/registry.db`; existing `ports.json` allocations are imported once.
104
+ `AGENTHUB_HOME` selects an isolated machine registry (also used by tests). An
105
+ inherited state directory from a different project cannot redirect a new CLI
106
+ invocation. Explicit custom state requires a matching project marker or the
107
+ initialization pair `AGENTHUB_PROJECT_DIR` / `AGENTHUB_STATE_DIR`.
108
+
109
+ Manager-started hubs use attended mode and project configuration, including
110
+ credential-file references. They do not inherit the launching project's gateway
111
+ URL/key overrides. Native memory remains shared according to claude-mem aliases;
112
+ worktrees recall their parent and composite identity. A shared-alias notice does
113
+ not mean that task boards or agent sessions are shared. Provider quota readings
114
+ may describe the same account and should not be added across projects.
115
+
116
+ Before upgrading to protocol 7, stop existing hubs with their matching CLI, then
117
+ update the package/plugin and restart the desired projects and agent sessions.
118
+ Incompatible or unverified processes are shown explicitly and are never killed by
119
+ PID-name matching. See the [multi-project specification](docs/specs/2026-09-19-multi-project-design.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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.4.0",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -15529,21 +15529,108 @@ class StdioServerTransport {
15529
15529
  }
15530
15530
 
15531
15531
  // src/adapters/claude-channel.ts
15532
+ import { readFileSync as readFileSync3 } from "fs";
15533
+ import { join as join3 } from "path";
15534
+
15535
+ // src/hub/control-client.ts
15532
15536
  import { readFileSync as readFileSync2 } from "fs";
15533
15537
  import { join as join2 } from "path";
15534
15538
 
15539
+ // src/hub/project.ts
15540
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "fs";
15541
+ import { basename, dirname, isAbsolute, join, resolve } from "path";
15542
+ import { spawnSync } from "child_process";
15543
+ var canonical = (path) => {
15544
+ const absolute = resolve(path);
15545
+ try {
15546
+ return realpathSync(absolute);
15547
+ } catch {
15548
+ const parent = dirname(absolute);
15549
+ return parent === absolute ? absolute : join(canonical(parent), basename(absolute));
15550
+ }
15551
+ };
15552
+ function gitRoot(dir) {
15553
+ const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
15554
+ if (result.status !== 0)
15555
+ return;
15556
+ const root = result.stdout.trim();
15557
+ return root ? canonical(root) : undefined;
15558
+ }
15559
+ function hasConfig(dir) {
15560
+ return existsSync(join(dir, ".agenthub", "config.json"));
15561
+ }
15562
+ function projectRoot(cwd) {
15563
+ const input = isAbsolute(cwd) ? cwd : resolve(cwd);
15564
+ let dir;
15565
+ try {
15566
+ dir = canonical(input);
15567
+ if (!lstatSync(dir).isDirectory())
15568
+ throw new Error(`${cwd} is not a directory`);
15569
+ } catch (error2) {
15570
+ throw new Error(`cannot resolve project directory ${cwd}: ${error2.message}`);
15571
+ }
15572
+ const boundary = gitRoot(dir);
15573
+ const stop = boundary ?? parseRoot(dir);
15574
+ for (let current = dir;; current = dirname(current)) {
15575
+ if (hasConfig(current))
15576
+ return canonical(current);
15577
+ if (current === stop || current === dirname(current))
15578
+ break;
15579
+ }
15580
+ return boundary ?? dir;
15581
+ }
15582
+ function parseRoot(dir) {
15583
+ let current = dir;
15584
+ while (dirname(current) !== current)
15585
+ current = dirname(current);
15586
+ return current;
15587
+ }
15588
+ function stateMarker(stateDir) {
15589
+ for (const file of [join(stateDir, "project.json"), join(stateDir, "status.json")]) {
15590
+ try {
15591
+ const value = JSON.parse(readFileSync(file, "utf8"));
15592
+ const root = value.root ?? value.cwd;
15593
+ if (typeof root === "string")
15594
+ return canonical(root);
15595
+ } catch {}
15596
+ }
15597
+ return;
15598
+ }
15599
+ function projectContext(cwd, env = process.env) {
15600
+ const root = projectRoot(cwd);
15601
+ const override = env.AGENTHUB_STATE_DIR?.trim();
15602
+ if (!override)
15603
+ return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
15604
+ const declared = env.AGENTHUB_PROJECT_DIR?.trim();
15605
+ const stateDir = canonical(override);
15606
+ const recorded = stateMarker(stateDir);
15607
+ if (recorded === root)
15608
+ return { root, stateDir };
15609
+ if (!recorded && declared && canonical(declared) === root)
15610
+ return { root, stateDir };
15611
+ return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
15612
+ }
15613
+
15535
15614
  // src/hub/control-client.ts
15536
- import { readFileSync } from "fs";
15537
- import { join } from "path";
15538
15615
  function stateDirFor(cwd) {
15539
- return process.env.AGENTHUB_STATE_DIR ?? join(cwd, ".agenthub", "state");
15616
+ return projectContext(cwd).stateDir;
15540
15617
  }
15541
- var PROTOCOL = 6;
15618
+ var PROTOCOL = 7;
15542
15619
  function readControl(stateDir) {
15543
15620
  try {
15544
- const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
15545
- const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
15546
- return { url: `ws://127.0.0.1:${status.controlPort}`, token };
15621
+ const status = JSON.parse(readFileSync2(join2(stateDir, "status.json"), "utf8"));
15622
+ const token = readFileSync2(join2(stateDir, "control-token"), "utf8").trim();
15623
+ if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token)
15624
+ return;
15625
+ return {
15626
+ url: `ws://127.0.0.1:${status.controlPort}`,
15627
+ token,
15628
+ projectId: status.projectId,
15629
+ instanceId: status.instanceId,
15630
+ cwd: status.cwd,
15631
+ protocol: status.protocol,
15632
+ pid: status.pid
15633
+ };
15547
15634
  } catch {
15548
15635
  return;
15549
15636
  }
@@ -15558,37 +15645,72 @@ class ControlClient {
15558
15645
  constructor(ws) {
15559
15646
  this.ws = ws;
15560
15647
  }
15561
- static connect(stateDir, hello) {
15648
+ static connect(stateDir, hello, timeoutMs = 3000) {
15562
15649
  const control = readControl(stateDir);
15563
15650
  if (!control)
15564
15651
  return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
15565
- return new Promise((resolve, reject) => {
15652
+ if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
15653
+ return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${PROTOCOL}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
15654
+ }
15655
+ if (hello.projectId && hello.projectId !== control.projectId || hello.instanceId && hello.instanceId !== control.instanceId || hello.projectRoot && hello.projectRoot !== control.cwd) {
15656
+ return Promise.reject(Object.assign(new Error("hub project or instance does not match the selected project"), { code: 4404 }));
15657
+ }
15658
+ const expected = {
15659
+ projectId: hello.projectId ?? control.projectId,
15660
+ instanceId: hello.instanceId ?? control.instanceId,
15661
+ projectRoot: hello.projectRoot ?? control.cwd
15662
+ };
15663
+ return new Promise((resolve2, reject) => {
15566
15664
  const ws = new WebSocket(control.url);
15567
15665
  const client = new ControlClient(ws);
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?)"));
15666
+ const timer = setTimeout(() => refuse(new Error(`hub connection timed out at ${control.url}`)), timeoutMs);
15667
+ const refuse = (error2) => {
15668
+ clearTimeout(timer);
15669
+ reject(error2);
15670
+ ws.close();
15671
+ };
15672
+ ws.onerror = () => refuse(new Error(`cannot reach hub at ${control.url}`));
15673
+ ws.onclose = (ev) => {
15674
+ clearTimeout(timer);
15675
+ reject(Object.assign(new Error(`hub closed the connection: ${ev.reason || "stale token?"}`), { code: ev.code }));
15571
15676
  for (const done of client.pending.values())
15572
15677
  done({ ok: false, error: "hub connection closed" });
15573
15678
  client.pending.clear();
15574
- client.onClose();
15679
+ client.onClose(ev.code, ev.reason);
15575
15680
  };
15576
15681
  ws.onmessage = (ev) => {
15577
- const msg = JSON.parse(String(ev.data));
15682
+ let msg;
15683
+ try {
15684
+ msg = JSON.parse(String(ev.data));
15685
+ } catch {
15686
+ return refuse(new Error("invalid hub response"));
15687
+ }
15688
+ if (!msg || typeof msg !== "object" || Array.isArray(msg))
15689
+ return refuse(new Error("invalid hub response"));
15578
15690
  const done = msg.rid !== undefined ? client.pending.get(msg.rid) : undefined;
15579
15691
  if (!done)
15580
15692
  return client.onPush(msg);
15581
15693
  client.pending.delete(msg.rid);
15582
15694
  done(msg);
15583
15695
  };
15584
- ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello }).then(() => resolve(client));
15696
+ ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
15697
+ if (reply.t !== "welcome" || reply.ok === false)
15698
+ return refuse(new Error(reply.error ?? "hub refused handshake"));
15699
+ if (expected.projectId && reply.projectId !== expected.projectId || expected.instanceId && reply.instanceId !== expected.instanceId || expected.projectRoot && reply.cwd !== expected.projectRoot) {
15700
+ return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
15701
+ }
15702
+ clearTimeout(timer);
15703
+ resolve2(client);
15704
+ });
15585
15705
  });
15586
15706
  }
15587
- request(msg, timeoutMs) {
15707
+ request(msg, timeoutMs = 30000) {
15708
+ if (this.ws.readyState !== WebSocket.OPEN)
15709
+ return Promise.resolve({ ok: false, error: "hub connection is not open" });
15588
15710
  const rid = this.nextRid++;
15589
- return new Promise((resolve) => {
15590
- const timer = timeoutMs ? setTimeout(() => (this.pending.delete(rid), resolve({ ok: false, error: `no answer from the hub within ${Math.round(timeoutMs / 1000)} s` })), timeoutMs) : undefined;
15591
- this.pending.set(rid, (reply) => (clearTimeout(timer), resolve(reply)));
15711
+ return new Promise((resolve2) => {
15712
+ const timer = timeoutMs ? setTimeout(() => (this.pending.delete(rid), resolve2({ ok: false, error: `no answer from the hub within ${Math.round(timeoutMs / 1000)} s` })), timeoutMs) : undefined;
15713
+ this.pending.set(rid, (reply) => (clearTimeout(timer), resolve2(reply)));
15592
15714
  this.ws.send(JSON.stringify({ ...msg, rid }));
15593
15715
  });
15594
15716
  }
@@ -15602,7 +15724,7 @@ class ControlClient {
15602
15724
  // package.json
15603
15725
  var package_default = {
15604
15726
  name: "@staix/agent-hub",
15605
- version: "0.3.1",
15727
+ version: "0.4.0",
15606
15728
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code and a local worker as peers in one project",
15607
15729
  license: "MIT",
15608
15730
  type: "module",
@@ -15702,17 +15824,26 @@ ${sanitize(env.body)}`;
15702
15824
  }
15703
15825
 
15704
15826
  // src/adapters/claude-channel.ts
15705
- var stateDir = stateDirFor(process.cwd());
15827
+ var stateDir = process.env.AGENTHUB_STATE_DIR ?? stateDirFor(process.cwd());
15828
+ var projectRoot2 = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
15706
15829
  var peerId = process.env.AGENTHUB_PEER_ID ?? "claude";
15707
15830
  var toolsOnly = process.env.AGENTHUB_MODE === "tools";
15708
15831
  function roles() {
15709
15832
  try {
15710
- return { ...DEFAULT_ROLES, ...JSON.parse(readFileSync2(join2(process.cwd(), ".agenthub", "config.json"), "utf8")).roles };
15833
+ return { ...DEFAULT_ROLES, ...JSON.parse(readFileSync3(join3(projectRoot2, ".agenthub", "config.json"), "utf8")).roles };
15711
15834
  } catch {
15712
15835
  return DEFAULT_ROLES;
15713
15836
  }
15714
15837
  }
15715
15838
  var MAX_RECONNECT_DELAY_MS = 30000;
15839
+ var TERMINAL_CLOSES = {
15840
+ 4000: `another session attached to the hub as "${peerId}"; this one is detached (restart it to take the peer back)`,
15841
+ 4401: "the hub refused the control token",
15842
+ 4404: "the hub belongs to a different project or instance; restart this session from the intended project",
15843
+ 4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
15844
+ 4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
15845
+ 4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session"
15846
+ };
15716
15847
  var INBOX_CAP = 200;
15717
15848
  var INSTRUCTIONS = [
15718
15849
  "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 +15865,8 @@ var text = (s) => ({ content: [{ type: "text", text: s }] });
15734
15865
  var server = new Server({ name: "agent-hub", version: VERSION }, toolsOnly ? { capabilities: { tools: {} }, instructions: TOOLS_INSTRUCTIONS } : { capabilities: { experimental: { "claude/channel": {} }, tools: {} }, instructions: INSTRUCTIONS });
15735
15866
  var inbox = [];
15736
15867
  var hub;
15868
+ var detached;
15869
+ var offline = () => detached ?? "hub is not running for this project (start it with: ahub up).";
15737
15870
  async function push(envs) {
15738
15871
  const parent = replyParent(envs);
15739
15872
  const single = envs.length === 1;
@@ -15761,19 +15894,29 @@ ${sanitize(e.body)}`).join(`
15761
15894
  }
15762
15895
  async function connectLoop() {
15763
15896
  for (let attempt = 0;; attempt++) {
15897
+ let code;
15764
15898
  try {
15765
- const client = await ControlClient.connect(stateDir, { role: toolsOnly ? "tools" : "peer", peer: peerId });
15899
+ const client = await ControlClient.connect(stateDir, {
15900
+ role: toolsOnly ? "tools" : "peer",
15901
+ peer: peerId,
15902
+ ...process.env.AGENTHUB_PROJECT_DIR ? { projectRoot: projectRoot2 } : {}
15903
+ });
15766
15904
  client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]);
15767
15905
  hub = client;
15768
15906
  attempt = -1;
15769
15907
  log(`connected to hub as "${peerId}"`);
15770
- await new Promise((r) => client.onClose = r);
15908
+ code = await new Promise((r) => client.onClose = r);
15771
15909
  hub = undefined;
15772
15910
  log("hub connection lost");
15773
15911
  } catch (e) {
15912
+ code = e.code;
15774
15913
  if (attempt === 0)
15775
15914
  log(e.message);
15776
15915
  }
15916
+ if (code !== undefined && TERMINAL_CLOSES[code]) {
15917
+ detached = TERMINAL_CLOSES[code];
15918
+ return log(`stopped reconnecting: ${detached}`);
15919
+ }
15777
15920
  await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
15778
15921
  }
15779
15922
  }
@@ -15814,7 +15957,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
15814
15957
  if (name === "hub_send") {
15815
15958
  const { text: body, to, reply_to } = args ?? {};
15816
15959
  if (!hub)
15817
- return text("hub is not running for this project (start it with: ahub up). Message not sent.");
15960
+ return text(`${offline()} Message not sent.`);
15818
15961
  const res = await hub.request({ t: "send", body, to, reply_to });
15819
15962
  if (!res.ok)
15820
15963
  return text(`not sent: ${res.error}`);
@@ -15824,11 +15967,13 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
15824
15967
  }
15825
15968
  if (TASK_TOOL_NAMES.has(name)) {
15826
15969
  if (!hub)
15827
- return text("hub is not running for this project (start it with: ahub up).");
15970
+ return text(offline());
15828
15971
  const res = await hub.request({ t: "task", op: name, args: args ?? {} });
15829
15972
  return text(res.ok ? res.text : `error: ${res.error}`);
15830
15973
  }
15831
15974
  return text(`unknown tool ${name}`);
15832
15975
  });
15833
15976
  await server.connect(new StdioServerTransport);
15977
+ process.stdin.on("end", () => process.exit(0));
15978
+ process.stdin.on("close", () => process.exit(0));
15834
15979
  connectLoop();
@@ -2,6 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
+ import { stopOwnedProcess } from "../hub/child-process.ts";
5
6
 
6
7
  export interface PermissionOption {
7
8
  optionId: string;
@@ -74,14 +75,17 @@ export class AcpPeer extends BasePeer {
74
75
  try {
75
76
  this.sessionId = (await Promise.race([handshake(), timeout])).sessionId;
76
77
  } catch (e) {
77
- proc.kill();
78
+ await stopOwnedProcess(proc);
78
79
  throw e;
79
80
  }
80
81
  this.setState("idle");
81
82
  }
82
83
 
83
84
  async stop(): Promise<void> {
84
- this.proc?.kill();
85
+ const proc = this.proc;
86
+ if (!proc || proc.exitCode !== null) return;
87
+ await stopOwnedProcess(proc);
88
+ if (this.proc === proc) this.proc = undefined;
85
89
  }
86
90
 
87
91
  /** Resolves once the prompt is in flight; the turn result arrives on its own. */
@@ -11,19 +11,32 @@ import { VERSION } from "../version.ts";
11
11
  import { DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
12
12
  import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
13
13
 
14
- const stateDir = stateDirFor(process.cwd());
14
+ // A native session is pinned at launch. Unlike a new CLI invocation after `cd`,
15
+ // its MCP server must not silently move to another hub (including while offline).
16
+ // ControlClient verifies the manifest identity on every connection.
17
+ const stateDir = process.env.AGENTHUB_STATE_DIR ?? stateDirFor(process.cwd());
18
+ const projectRoot = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
15
19
  const peerId = process.env.AGENTHUB_PEER_ID ?? "claude";
16
20
  /** tools mode: the same server, run by Kimi (ACP mcpServers) or Codex (mcp_servers override). Their messages arrive through their own adapters, so no channel here. */
17
21
  const toolsOnly = process.env.AGENTHUB_MODE === "tools";
18
22
 
19
23
  function roles(): Record<string, string[]> {
20
24
  try {
21
- return { ...DEFAULT_ROLES, ...JSON.parse(readFileSync(join(process.cwd(), ".agenthub", "config.json"), "utf8")).roles };
25
+ return { ...DEFAULT_ROLES, ...JSON.parse(readFileSync(join(projectRoot, ".agenthub", "config.json"), "utf8")).roles };
22
26
  } catch {
23
27
  return DEFAULT_ROLES;
24
28
  }
25
29
  }
26
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. */
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
+ 4401: "the hub refused the control token",
35
+ 4404: "the hub belongs to a different project or instance; restart this session from the intended project",
36
+ 4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
37
+ 4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
38
+ 4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
39
+ };
27
40
  const INBOX_CAP = 200;
28
41
 
29
42
  const INSTRUCTIONS = [
@@ -50,6 +63,8 @@ const server = new Server(
50
63
 
51
64
  const inbox: string[] = []; // pushes that failed; drained by hub_inbox
52
65
  let hub: ControlClient | undefined;
66
+ let detached: string | undefined; // why this server stopped reconnecting; tool calls report it
67
+ const offline = () => detached ?? "hub is not running for this project (start it with: ahub up).";
53
68
 
54
69
  /** One delivery = one notification, because every notification can cost Claude a turn. */
55
70
  async function push(envs: Envelope[]): Promise<void> {
@@ -75,18 +90,26 @@ async function push(envs: Envelope[]): Promise<void> {
75
90
 
76
91
  async function connectLoop(): Promise<void> {
77
92
  for (let attempt = 0; ; attempt++) {
93
+ let code: number | undefined;
78
94
  try {
79
- const client = await ControlClient.connect(stateDir, { role: toolsOnly ? "tools" : "peer", peer: peerId });
95
+ const client = await ControlClient.connect(stateDir, { role: toolsOnly ? "tools" : "peer", peer: peerId,
96
+ ...(process.env.AGENTHUB_PROJECT_DIR ? { projectRoot } : {}) });
80
97
  client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]); // `env`: a daemon older than wire version 2
81
98
  hub = client;
82
99
  attempt = -1;
83
100
  log(`connected to hub as "${peerId}"`);
84
- await new Promise<void>((r) => (client.onClose = r));
101
+ code = await new Promise<number>((r) => (client.onClose = r));
85
102
  hub = undefined;
86
103
  log("hub connection lost");
87
104
  } catch (e) {
105
+ code = (e as { code?: number }).code;
88
106
  if (attempt === 0) log((e as Error).message);
89
107
  }
108
+ // Retrying these only fights another session or hammers a hub that will refuse again.
109
+ if (code !== undefined && TERMINAL_CLOSES[code]) {
110
+ detached = TERMINAL_CLOSES[code];
111
+ return log(`stopped reconnecting: ${detached}`);
112
+ }
90
113
  await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
91
114
  }
92
115
  }
@@ -129,14 +152,14 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
129
152
  }
130
153
  if (name === "hub_send") {
131
154
  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.");
155
+ if (!hub) return text(`${offline()} Message not sent.`);
133
156
  const res = await hub.request({ t: "send", body, to, reply_to });
134
157
  if (!res.ok) return text(`not sent: ${res.error}`);
135
158
  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
159
  return text(`sent to: ${res.targets.join(", ") || "(no other peers attached)"}`);
137
160
  }
138
161
  if (TASK_TOOL_NAMES.has(name)) {
139
- if (!hub) return text("hub is not running for this project (start it with: ahub up).");
162
+ if (!hub) return text(offline());
140
163
  const res = await hub.request({ t: "task", op: name, args: args ?? {} });
141
164
  return text(res.ok ? res.text : `error: ${res.error}`);
142
165
  }
@@ -144,4 +167,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
144
167
  });
145
168
 
146
169
  await server.connect(new StdioServerTransport());
170
+ // The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
171
+ process.stdin.on("end", () => process.exit(0));
172
+ process.stdin.on("close", () => process.exit(0));
147
173
  void connectLoop();
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import type { Server, ServerWebSocket } from "bun";
3
3
  import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
+ import { stopOwnedProcess } from "../hub/child-process.ts";
5
6
 
6
7
  export interface CodexOptions {
7
8
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
@@ -43,6 +44,8 @@ export class CodexPeer extends BasePeer {
43
44
  private proc: ChildProcess | undefined;
44
45
  private server: Server<Link> | undefined;
45
46
  private link: Link | undefined; // the connection that owns the current thread
47
+ /** Claimed as soon as a TUI WebSocket opens, before it can start a thread. */
48
+ private claimedTui: Link | undefined;
46
49
  private threadId = "";
47
50
  private readonly activeTurns = new Set<string>();
48
51
  private nextId = -1;
@@ -67,7 +70,8 @@ export class CodexPeer extends BasePeer {
67
70
 
68
71
  async start(): Promise<void> {
69
72
  const upstream = this.opts.upstreamUrl ?? (await this.spawnAppServer());
70
- this.server = Bun.serve<Link>({
73
+ try {
74
+ this.server = Bun.serve<Link>({
71
75
  hostname: "127.0.0.1",
72
76
  port: this.opts.proxyPort,
73
77
  fetch: (req, server) => {
@@ -77,21 +81,40 @@ export class CodexPeer extends BasePeer {
77
81
  return new Response("agent-hub codex proxy");
78
82
  },
79
83
  websocket: {
80
- open: (tui) => this.attach(tui, upstream),
81
- message: (tui, data) => this.fromTui(tui.data, String(data)),
82
- close: (tui) => this.detach(tui.data),
84
+ open: (tui) => {
85
+ if (this.claimedTui) {
86
+ tui.close(1013, "codex TUI already attached to this hub");
87
+ return;
88
+ }
89
+ this.attach(tui, upstream);
90
+ },
91
+ message: (tui, data) => {
92
+ if (tui.data?.up) this.fromTui(tui.data, String(data));
93
+ },
94
+ close: (tui) => {
95
+ // A rejected second attachment never passed through attach(), so it has no Link.
96
+ if (tui.data?.up) this.detach(tui.data);
97
+ },
83
98
  },
84
- });
99
+ });
100
+ } catch (error) {
101
+ const proc = this.proc;
102
+ if (proc) {
103
+ await stopOwnedProcess(proc);
104
+ if (this.proc === proc) this.proc = undefined;
105
+ }
106
+ throw error;
107
+ }
85
108
  }
86
109
 
87
110
  async stop(): Promise<void> {
88
111
  this.server?.stop(true);
112
+ this.claimedTui?.tui.close(1001, "hub shutting down");
89
113
  const proc = this.proc;
90
114
  if (proc && proc.exitCode === null) {
91
115
  // Wait for the port to be released: `ahub codex` may restart the adapter right away.
92
- const exited = new Promise((r) => proc.once("exit", r));
93
- proc.kill();
94
- await Promise.race([exited, Bun.sleep(3000)]);
116
+ await stopOwnedProcess(proc);
117
+ if (this.proc === proc) this.proc = undefined;
95
118
  }
96
119
  this.setState("offline");
97
120
  }
@@ -200,13 +223,16 @@ export class CodexPeer extends BasePeer {
200
223
  await Bun.sleep(100);
201
224
  }
202
225
  if (gone) throw new Error(gone);
203
- this.proc.kill();
226
+ const proc = this.proc;
227
+ await stopOwnedProcess(proc);
228
+ if (this.proc === proc) this.proc = undefined;
204
229
  throw new Error("codex app-server did not become healthy within 10 s");
205
230
  }
206
231
 
207
232
  private attach(tui: ServerWebSocket<Link>, upstream: string): void {
208
233
  const up = new WebSocket(upstream);
209
234
  const link: Link = { tui, up, backlog: [], tracked: new Map() };
235
+ this.claimedTui = link;
210
236
  tui.data = link;
211
237
  up.onopen = () => {
212
238
  for (const frame of link.backlog.splice(0)) up.send(frame);
@@ -217,6 +243,7 @@ export class CodexPeer extends BasePeer {
217
243
 
218
244
  private detach(link: Link): void {
219
245
  link.up.close();
246
+ if (this.claimedTui === link) this.claimedTui = undefined;
220
247
  if (this.link !== link) return;
221
248
  clearInterval(this.usageTimer);
222
249
  this.link = undefined;
@@ -314,6 +341,7 @@ export class CodexPeer extends BasePeer {
314
341
  }
315
342
  }
316
343
 
344
+
317
345
  function parse(raw: string): any {
318
346
  try {
319
347
  return JSON.parse(raw);