@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 +15 -0
- package/README.md +51 -3
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +171 -26
- package/src/adapters/acp.ts +6 -2
- package/src/adapters/claude-channel.ts +32 -6
- package/src/adapters/codex-appserver.ts +37 -9
- package/src/cli/main.ts +117 -65
- package/src/hub/child-process.ts +44 -0
- package/src/hub/control-client.ts +56 -13
- package/src/hub/daemon.ts +106 -42
- package/src/hub/lifecycle.ts +181 -0
- package/src/hub/manager.ts +232 -0
- package/src/hub/ports.ts +40 -11
- package/src/hub/project.ts +93 -0
- package/src/hub/registry.ts +204 -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,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
|
|
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.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.
|
|
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": "agent-hub",
|
|
3
|
-
"version": "0.
|
|
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
|
|
15616
|
+
return projectContext(cwd).stateDir;
|
|
15540
15617
|
}
|
|
15541
|
-
var PROTOCOL =
|
|
15618
|
+
var PROTOCOL = 7;
|
|
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,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
|
-
|
|
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
|
-
|
|
15569
|
-
|
|
15570
|
-
|
|
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
|
-
|
|
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.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(
|
|
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, {
|
|
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(
|
|
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(
|
|
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();
|
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 { 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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
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<
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
93
|
-
proc.
|
|
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
|
|
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);
|