@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 +22 -0
- package/README.md +58 -3
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +148 -20
- package/src/adapters/acp.ts +15 -3
- package/src/adapters/claude-channel.ts +9 -3
- package/src/adapters/codex-appserver.ts +47 -9
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/main.ts +188 -68
- package/src/cli/recovery-package.ts +99 -0
- package/src/cli/statusline-tee.ts +11 -2
- package/src/cli/terminal-recovery.ts +580 -0
- package/src/cli/upgrade-runtime.ts +299 -0
- package/src/cli/upgrade.ts +295 -0
- package/src/hub/budget.ts +32 -1
- package/src/hub/bus.ts +79 -4
- package/src/hub/child-process.ts +55 -0
- package/src/hub/control-client.ts +50 -9
- package/src/hub/daemon.ts +326 -45
- package/src/hub/lifecycle.ts +189 -0
- package/src/hub/manager.ts +232 -0
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +40 -11
- package/src/hub/project.ts +93 -0
- package/src/hub/recovery-store.ts +89 -0
- package/src/hub/registry.ts +204 -0
- package/src/hub/restart.ts +97 -0
- package/src/hub/ui.ts +29 -4
- package/src/memory/recall.ts +26 -3
- package/src/switchyard/sidecar.ts +39 -5
- package/src/ui/index.html +103 -18
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
|
|
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.
|
|
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.
|
|
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": "agent-hub",
|
|
3
|
-
"version": "0.
|
|
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
|
|
15616
|
+
return projectContext(cwd).stateDir;
|
|
15540
15617
|
}
|
|
15541
|
-
var PROTOCOL =
|
|
15618
|
+
var PROTOCOL = 8;
|
|
15542
15619
|
function readControl(stateDir) {
|
|
15543
15620
|
try {
|
|
15544
|
-
const status = JSON.parse(
|
|
15545
|
-
const token =
|
|
15546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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((
|
|
15590
|
-
const timer = timeoutMs ? setTimeout(() => (this.pending.delete(rid),
|
|
15591
|
-
this.pending.set(rid, (reply) => (clearTimeout(timer),
|
|
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.
|
|
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(
|
|
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, {
|
|
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;
|
package/src/adapters/acp.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
93
|
-
proc.
|
|
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
|
|
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");
|