@staix/agent-hub 0.3.2 → 0.5.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Controlled protocol-8 restart with private queue/session snapshots, manual-pause preservation, instance fencing and verified release (#21).
6
+ - Exact-version upgrade planning, immutable package staging, detached resumable operation receipts and machine lifecycle locking.
7
+ - Orca-first native terminal recovery: original Codex/Claude conversations, new Kimi/local sessions with task context, and explicit manual blockers for unsupported sessions.
8
+ - Running projects transition sequentially; the shared Claude plugin and Claude sessions have a final common phase. Global CLI promotion follows verification.
9
+ - Protocols 5–7 require a manual bootstrap using their matching CLI. No automatic legacy shutdown, uncertain side-effect replay or rollback.
10
+
11
+ ## 0.4.1 (included in 0.5.0)
12
+
13
+ - Synchronize the daemon digest integration fixture without changing batching semantics or weakening digest/FYI assertions (#15).
14
+ - Reconcile completed live smoke and npm publication evidence; infrastructure-dependent checks remain tracked in #12.
15
+
16
+ ## 0.4.0
17
+
18
+ - Independent repository and worktree hubs with canonical project selection, `--project <path|id>`, `projects`, and `status --all` (#19).
19
+ - Transactional machine-wide project/port registration, concurrent startup claims, authenticated instance checks and owned shutdown; legacy port allocations are imported without resetting them.
20
+ - `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.
21
+ - Native memory aliases stay compatible with claude-mem, including worktree parent/composite names. Shared aliases are indicated in the manager.
22
+ - A second Codex TUI cannot take over an existing hub connection. Owned child shutdown is awaited before runtime ownership is released.
23
+ - 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.
24
+
3
25
  ## 0.3.2
4
26
 
5
27
  - 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).
package/README.md CHANGED
@@ -1,12 +1,19 @@
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.2. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
7
+ Status: 0.5.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
+ Version 0.5.0 adds [controlled upgrade and session recovery](docs/specs/2026-09-20-upgrade-recovery-design.md).
11
+ After bootstrapping a protocol-8 hub, use `ahub restart --dry-run` to inspect one
12
+ project or `ahub upgrade --to <exact-version> --dry-run` to inspect an upgrade of
13
+ the running projects. Remove `--dry-run` to review and schedule the operation;
14
+ `ahub recovery status <operation-id>` reports progress. Existing protocol-5–7
15
+ hubs require their matching CLI and an attended maintenance bootstrap.
16
+
10
17
  ## Start here
11
18
 
12
19
  Requires [Bun](https://bun.sh) >=1.3.0 on PATH; Node alone cannot run the CLI.
@@ -22,7 +29,7 @@ cd <your project> && ahub init && ahub up && ahub tail
22
29
  Or install the same version from GitHub:
23
30
 
24
31
  ```bash
25
- bun add -g github:STAIxBWLB/agent-hub#v0.3.2 && ahub setup
32
+ bun add -g github:STAIxBWLB/agent-hub#v0.5.0 && ahub setup
26
33
  ```
27
34
 
28
35
  The installed commands remain `ahub` and `agent-hub`.
@@ -69,3 +76,51 @@ peers, and answer approvals. Budget pauses still require the terminal override.
69
76
  Private envelopes and PII tasks stay redacted. Local-worker tool details and
70
77
  allow decisions stay in `ahub tail` / `ahub permit`; the page can deny them.
71
78
  No dashboard port is opened until requested. See [the session security model](docs/security.md#local-dashboard-sessions-issue-6).
79
+
80
+
81
+ ## Multiple projects and worktrees
82
+
83
+ Version 0.4.0 adds one independent hub per repository or Git worktree.
84
+ Subdirectory commands resolve to that project; symlink aliases resolve to the same
85
+ hub. Explicit selection works from any directory:
86
+
87
+ ```bash
88
+ ahub --project /path/to/project-a init
89
+ ahub --project /path/to/project-b init
90
+ ahub --project /path/to/project-a up
91
+ ahub --project /path/to/project-b up
92
+ ahub projects
93
+ ahub status --all
94
+ ahub --project /path/to/project-a codex
95
+ ahub ui --all
96
+ ```
97
+
98
+ The unified dashboard lists registered roots and opens their boards, streams,
99
+ messages, approvals and budgets. It can start hubs and stop a selected hub after
100
+ confirmation. Launch Claude/Codex TUIs from a terminal. The manager stays available
101
+ when a project stops; `ahub ui --all --stop` stops only the manager. Existing
102
+ `ahub ui` still opens the current project's dashboard.
103
+
104
+ `ahub projects --json` returns machine-readable live status. Use a listed ID or a
105
+ path with `--project`; the selector must precede the command. `ahub projects remove
106
+ <id>` forgets a stopped registration without deleting project data. A moved root
107
+ gets a new registration. Missing roots remain visible until removed.
108
+
109
+ Runtime state stays in each project's `.agenthub/state/`. The machine registry is
110
+ `~/.agenthub/registry.db`; existing `ports.json` allocations are imported once.
111
+ `AGENTHUB_HOME` selects an isolated machine registry (also used by tests). An
112
+ inherited state directory from a different project cannot redirect a new CLI
113
+ invocation. Explicit custom state requires a matching project marker or the
114
+ initialization pair `AGENTHUB_PROJECT_DIR` / `AGENTHUB_STATE_DIR`.
115
+
116
+ Manager-started hubs use attended mode and project configuration, including
117
+ credential-file references. They do not inherit the launching project's gateway
118
+ URL/key overrides. Native memory remains shared according to claude-mem aliases;
119
+ worktrees recall their parent and composite identity. A shared-alias notice does
120
+ not mean that task boards or agent sessions are shared. Provider quota readings
121
+ may describe the same account and should not be added across projects.
122
+
123
+ Before upgrading to protocol 7, stop existing hubs with their matching CLI, then
124
+ update the package/plugin and restart the desired projects and agent sessions.
125
+ Incompatible or unverified processes are shown explicitly and are never killed by
126
+ 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.2",
3
+ "version": "0.5.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.2",
3
+ "version": "0.5.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 = 8;
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,15 +15645,33 @@ 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}`));
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}`));
15569
15673
  ws.onclose = (ev) => {
15674
+ clearTimeout(timer);
15570
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" });
@@ -15574,21 +15679,38 @@ class ControlClient {
15574
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.2",
15727
+ version: "0.5.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,12 +15824,13 @@ ${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
  }
@@ -15716,6 +15839,7 @@ var MAX_RECONNECT_DELAY_MS = 30000;
15716
15839
  var TERMINAL_CLOSES = {
15717
15840
  4000: `another session attached to the hub as "${peerId}"; this one is detached (restart it to take the peer back)`,
15718
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",
15719
15843
  4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
15720
15844
  4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
15721
15845
  4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session"
@@ -15772,7 +15896,11 @@ async function connectLoop() {
15772
15896
  for (let attempt = 0;; attempt++) {
15773
15897
  let code;
15774
15898
  try {
15775
- 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
+ });
15776
15904
  client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]);
15777
15905
  hub = client;
15778
15906
  attempt = -1;
@@ -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 { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
5
6
 
6
7
  export interface PermissionOption {
7
8
  optionId: string;
@@ -17,7 +18,11 @@ export interface PermissionRequest {
17
18
  export interface AcpOptions {
18
19
  /** e.g. ["kimi", "acp"]. `opencode acp` fits the same adapter. */
19
20
  cmd: string[];
21
+ /** Coordinator-visible selected model only; contains no prompts or command arguments. */
22
+ launchModel?: string;
20
23
  cwd: string;
24
+ /** Optional launch environment; recovery authority is always removed before spawn. */
25
+ env?: NodeJS.ProcessEnv;
21
26
  watchdogMs?: number;
22
27
  /** ACP stdio MCP servers for the session (the hub's task tools). */
23
28
  mcpServers?: { name: string; command: string; args: string[]; env: { name: string; value: string }[] }[];
@@ -51,9 +56,13 @@ export class AcpPeer extends BasePeer {
51
56
  super(id, opts.watchdogMs);
52
57
  }
53
58
 
59
+ recoveryMetadata(): Record<string, unknown> {
60
+ return { launch: { kind: "acp", ...(this.opts.launchModel ? { model: this.opts.launchModel } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}) };
61
+ }
62
+
54
63
  async start(): Promise<void> {
55
64
  const [bin, ...args] = this.opts.cmd;
56
- const proc = spawn(bin!, args, { cwd: this.opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
65
+ const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"] });
57
66
  this.proc = proc;
58
67
  proc.on("error", (e) => this.down(`spawn failed: ${e.message}`));
59
68
  proc.on("exit", (code) => this.down(`exited with code ${code}`));
@@ -74,14 +83,17 @@ export class AcpPeer extends BasePeer {
74
83
  try {
75
84
  this.sessionId = (await Promise.race([handshake(), timeout])).sessionId;
76
85
  } catch (e) {
77
- proc.kill();
86
+ await stopOwnedProcess(proc);
78
87
  throw e;
79
88
  }
80
89
  this.setState("idle");
81
90
  }
82
91
 
83
92
  async stop(): Promise<void> {
84
- this.proc?.kill();
93
+ const proc = this.proc;
94
+ if (!proc || proc.exitCode !== null) return;
95
+ await stopOwnedProcess(proc);
96
+ if (this.proc === proc) this.proc = undefined;
85
97
  }
86
98
 
87
99
  /** Resolves once the prompt is in flight; the turn result arrives on its own. */
@@ -11,14 +11,18 @@ 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
  }
@@ -28,6 +32,7 @@ const MAX_RECONNECT_DELAY_MS = 30_000;
28
32
  const TERMINAL_CLOSES: Record<number, string> = {
29
33
  4000: `another session attached to the hub as "${peerId}"; this one is detached (restart it to take the peer back)`,
30
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",
31
36
  4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
32
37
  4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
33
38
  4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
@@ -87,7 +92,8 @@ async function connectLoop(): Promise<void> {
87
92
  for (let attempt = 0; ; attempt++) {
88
93
  let code: number | undefined;
89
94
  try {
90
- 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 } : {}) });
91
97
  client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]); // `env`: a daemon older than wire version 2
92
98
  hub = client;
93
99
  attempt = -1;
@@ -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 { childEnv, 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>`. */
@@ -20,6 +21,8 @@ export interface CodexOptions {
20
21
  /** How often to ask app-server for the rate limits while a TUI is attached. */
21
22
  usagePollMs?: number;
22
23
  cwd: string;
24
+ /** Optional launch environment; recovery authority is always removed before spawn. */
25
+ env?: NodeJS.ProcessEnv;
23
26
  watchdogMs?: number;
24
27
  log?: (line: string) => void;
25
28
  }
@@ -43,6 +46,8 @@ export class CodexPeer extends BasePeer {
43
46
  private proc: ChildProcess | undefined;
44
47
  private server: Server<Link> | undefined;
45
48
  private link: Link | undefined; // the connection that owns the current thread
49
+ /** Claimed as soon as a TUI WebSocket opens, before it can start a thread. */
50
+ private claimedTui: Link | undefined;
46
51
  private threadId = "";
47
52
  private readonly activeTurns = new Set<string>();
48
53
  private nextId = -1;
@@ -61,13 +66,21 @@ export class CodexPeer extends BasePeer {
61
66
  super(id, opts.watchdogMs);
62
67
  }
63
68
 
69
+ recoveryMetadata(): Record<string, unknown> {
70
+ return {
71
+ launch: { kind: "codex", bin: this.opts.bin ?? "codex", cwd: this.opts.cwd, appPort: this.opts.appPort, proxyPort: this.opts.proxyPort },
72
+ ...(this.threadId ? { threadId: this.threadId } : {}),
73
+ };
74
+ }
75
+
64
76
  get proxyUrl(): string {
65
77
  return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
66
78
  }
67
79
 
68
80
  async start(): Promise<void> {
69
81
  const upstream = this.opts.upstreamUrl ?? (await this.spawnAppServer());
70
- this.server = Bun.serve<Link>({
82
+ try {
83
+ this.server = Bun.serve<Link>({
71
84
  hostname: "127.0.0.1",
72
85
  port: this.opts.proxyPort,
73
86
  fetch: (req, server) => {
@@ -77,21 +90,40 @@ export class CodexPeer extends BasePeer {
77
90
  return new Response("agent-hub codex proxy");
78
91
  },
79
92
  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),
93
+ open: (tui) => {
94
+ if (this.claimedTui) {
95
+ tui.close(1013, "codex TUI already attached to this hub");
96
+ return;
97
+ }
98
+ this.attach(tui, upstream);
99
+ },
100
+ message: (tui, data) => {
101
+ if (tui.data?.up) this.fromTui(tui.data, String(data));
102
+ },
103
+ close: (tui) => {
104
+ // A rejected second attachment never passed through attach(), so it has no Link.
105
+ if (tui.data?.up) this.detach(tui.data);
106
+ },
83
107
  },
84
- });
108
+ });
109
+ } catch (error) {
110
+ const proc = this.proc;
111
+ if (proc) {
112
+ await stopOwnedProcess(proc);
113
+ if (this.proc === proc) this.proc = undefined;
114
+ }
115
+ throw error;
116
+ }
85
117
  }
86
118
 
87
119
  async stop(): Promise<void> {
88
120
  this.server?.stop(true);
121
+ this.claimedTui?.tui.close(1001, "hub shutting down");
89
122
  const proc = this.proc;
90
123
  if (proc && proc.exitCode === null) {
91
124
  // 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)]);
125
+ await stopOwnedProcess(proc);
126
+ if (this.proc === proc) this.proc = undefined;
95
127
  }
96
128
  this.setState("offline");
97
129
  }
@@ -186,6 +218,7 @@ export class CodexPeer extends BasePeer {
186
218
  let gone = "";
187
219
  this.proc = spawn(this.opts.bin ?? "codex", ["app-server", "--listen", `ws://127.0.0.1:${port}`, ...(this.opts.extraArgs ?? [])], {
188
220
  cwd: this.opts.cwd,
221
+ env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }),
189
222
  stdio: ["ignore", "ignore", "pipe"],
190
223
  });
191
224
  this.proc.stderr?.on("data", (d) => this.opts.log?.(`[${this.id}] ${String(d).trimEnd()}`));
@@ -200,13 +233,16 @@ export class CodexPeer extends BasePeer {
200
233
  await Bun.sleep(100);
201
234
  }
202
235
  if (gone) throw new Error(gone);
203
- this.proc.kill();
236
+ const proc = this.proc;
237
+ await stopOwnedProcess(proc);
238
+ if (this.proc === proc) this.proc = undefined;
204
239
  throw new Error("codex app-server did not become healthy within 10 s");
205
240
  }
206
241
 
207
242
  private attach(tui: ServerWebSocket<Link>, upstream: string): void {
208
243
  const up = new WebSocket(upstream);
209
244
  const link: Link = { tui, up, backlog: [], tracked: new Map() };
245
+ this.claimedTui = link;
210
246
  tui.data = link;
211
247
  up.onopen = () => {
212
248
  for (const frame of link.backlog.splice(0)) up.send(frame);
@@ -217,6 +253,7 @@ export class CodexPeer extends BasePeer {
217
253
 
218
254
  private detach(link: Link): void {
219
255
  link.up.close();
256
+ if (this.claimedTui === link) this.claimedTui = undefined;
220
257
  if (this.link !== link) return;
221
258
  clearInterval(this.usageTimer);
222
259
  this.link = undefined;
@@ -314,6 +351,7 @@ export class CodexPeer extends BasePeer {
314
351
  }
315
352
  }
316
353
 
354
+
317
355
  function parse(raw: string): any {
318
356
  try {
319
357
  return JSON.parse(raw);
@@ -68,6 +68,10 @@ export class LocalPeer extends BasePeer {
68
68
  this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny);
69
69
  }
70
70
 
71
+ recoveryMetadata(): Record<string, unknown> {
72
+ return { launch: { kind: "local", cwd: this.opts.cwd, model: this.opts.fixedModel, ...(this.opts.route ? { route: this.opts.route } : {}) }, sessionId: this.sessionId };
73
+ }
74
+
71
75
  async start(): Promise<void> {
72
76
  this.opts.capture?.init(this.sessionId, "agent-hub local worker session");
73
77
  this.setState("idle");