@staix/agent-hub 0.6.1 → 0.6.3
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 +12 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +25 -2
- package/src/adapters/acp.ts +21 -7
- package/src/adapters/claude-channel.ts +35 -3
- package/src/adapters/codex-appserver.ts +2 -0
- package/src/adapters/local-worker.ts +4 -3
- package/src/adapters/pi.ts +7 -3
- package/src/cli/main.ts +6 -4
- package/src/cli/status-lines.ts +27 -0
- package/src/hub/bus.ts +33 -2
- package/src/hub/daemon.ts +7 -1
- package/src/hub/envelope.ts +13 -3
- package/src/hub/inference.ts +2 -2
- package/src/hub/peers.ts +2 -0
- package/src/ui/index.html +71 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3
|
|
4
|
+
|
|
5
|
+
- The dashboard follows the system light/dark scheme with two tuned palettes, adds relative timestamps, kind filters for the message stream, a state filter for the task list and colored usage bars for budgets (#38).
|
|
6
|
+
|
|
7
|
+
## 0.6.2
|
|
8
|
+
|
|
9
|
+
- Address a peer's reply at whoever asked instead of broadcasting it, so one directed question no longer costs every attached peer a turn (#29).
|
|
10
|
+
- Cap the priority a hub-native peer claims for itself: `[IMPORTANT]` on an unsolicited report no longer interrupts the other peers (#29).
|
|
11
|
+
- A session whose peer id was taken over stands by and reclaims it once the hub reports the peer offline, instead of staying detached until the whole session is restarted (#30).
|
|
12
|
+
- Show the command a Kimi tool call will run in its approval prompt, taken from the `tool_call` update when the permission request omits it; a prompt whose payload cannot be resolved says so and offers no session-wide grant (#31).
|
|
13
|
+
- `ahub status` prints a namespaced backend alias once (`dgx/coding`, not `dgx/dgx/coding`) and names the backend Pi asked for on its last turn (#32).
|
|
14
|
+
|
|
3
15
|
## 0.6.1
|
|
4
16
|
|
|
5
17
|
- Preserve Pi session identity when handing an empty native session between headless and TUI modes (#27).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
|
|
|
4
4
|
hub-owned local-LLM worker collaborate as peers in independent project directories, with
|
|
5
5
|
task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
|
|
6
6
|
|
|
7
|
-
Status: 0.6.
|
|
7
|
+
Status: 0.6.3. 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
|
Version 0.5.0 adds [controlled upgrade and session recovery](docs/specs/2026-09-20-upgrade-recovery-design.md).
|
|
@@ -29,7 +29,7 @@ cd <your project> && ahub init && ahub up && ahub tail
|
|
|
29
29
|
Or install the same version from GitHub:
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
bun add -g github:STAIxBWLB/agent-hub#v0.6.
|
|
32
|
+
bun add -g github:STAIxBWLB/agent-hub#v0.6.3 && ahub setup
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
The installed commands remain `ahub` and `agent-hub`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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",
|
|
@@ -15726,7 +15726,7 @@ class ControlClient {
|
|
|
15726
15726
|
// package.json
|
|
15727
15727
|
var package_default = {
|
|
15728
15728
|
name: "@staix/agent-hub",
|
|
15729
|
-
version: "0.6.
|
|
15729
|
+
version: "0.6.3",
|
|
15730
15730
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15731
15731
|
license: "MIT",
|
|
15732
15732
|
type: "module",
|
|
@@ -15839,14 +15839,24 @@ function roles() {
|
|
|
15839
15839
|
}
|
|
15840
15840
|
var MAX_RECONNECT_DELAY_MS = 30000;
|
|
15841
15841
|
var TERMINAL_CLOSES = {
|
|
15842
|
-
4000: `another session attached to the hub as "${peerId}"; this one is detached (restart it to take the peer back)`,
|
|
15843
15842
|
4401: "the hub refused the control token",
|
|
15844
15843
|
4404: "the hub belongs to a different project or instance; restart this session from the intended project",
|
|
15845
15844
|
4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
|
|
15846
15845
|
4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
|
|
15847
15846
|
4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session"
|
|
15848
15847
|
};
|
|
15848
|
+
var HELD_CLOSE = 4000;
|
|
15849
|
+
var HELD = `another session is attached to the hub as "${peerId}"; this one is standing by and takes the peer back when that session leaves`;
|
|
15849
15850
|
var INBOX_CAP = 200;
|
|
15851
|
+
function peerHeld() {
|
|
15852
|
+
try {
|
|
15853
|
+
const status = JSON.parse(readFileSync3(join3(stateDir, "status.json"), "utf8"));
|
|
15854
|
+
const peer = status.peers?.[peerId];
|
|
15855
|
+
return !!peer && (peer.state !== "offline" || peer.claiming === true);
|
|
15856
|
+
} catch {
|
|
15857
|
+
return false;
|
|
15858
|
+
}
|
|
15859
|
+
}
|
|
15850
15860
|
var INSTRUCTIONS = [
|
|
15851
15861
|
"agent-hub connects you to other coding agents working in this project (for example codex, kimi, local) and to the hub console user.",
|
|
15852
15862
|
'Their messages arrive as <channel source="agent-hub" ...> tags; meta.source names the sender and meta.message_id identifies the message.',
|
|
@@ -15895,7 +15905,12 @@ ${sanitize(e.body)}`).join(`
|
|
|
15895
15905
|
}
|
|
15896
15906
|
}
|
|
15897
15907
|
async function connectLoop() {
|
|
15908
|
+
let standingBy = false;
|
|
15898
15909
|
for (let attempt = 0;; attempt++) {
|
|
15910
|
+
if (standingBy && peerHeld()) {
|
|
15911
|
+
await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
|
|
15912
|
+
continue;
|
|
15913
|
+
}
|
|
15899
15914
|
let code;
|
|
15900
15915
|
try {
|
|
15901
15916
|
const client = await ControlClient.connect(stateDir, {
|
|
@@ -15906,6 +15921,8 @@ async function connectLoop() {
|
|
|
15906
15921
|
client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]);
|
|
15907
15922
|
hub = client;
|
|
15908
15923
|
attempt = -1;
|
|
15924
|
+
standingBy = false;
|
|
15925
|
+
detached = undefined;
|
|
15909
15926
|
log(`connected to hub as "${peerId}"`);
|
|
15910
15927
|
code = await new Promise((r) => client.onClose = r);
|
|
15911
15928
|
hub = undefined;
|
|
@@ -15919,6 +15936,12 @@ async function connectLoop() {
|
|
|
15919
15936
|
detached = TERMINAL_CLOSES[code];
|
|
15920
15937
|
return log(`stopped reconnecting: ${detached}`);
|
|
15921
15938
|
}
|
|
15939
|
+
if (code === HELD_CLOSE) {
|
|
15940
|
+
if (!standingBy)
|
|
15941
|
+
log(`standing by: ${HELD}`);
|
|
15942
|
+
standingBy = true;
|
|
15943
|
+
detached = HELD;
|
|
15944
|
+
}
|
|
15922
15945
|
await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
|
|
15923
15946
|
}
|
|
15924
15947
|
}
|
package/src/adapters/acp.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
-
import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
3
|
+
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import { BasePeer } from "../hub/peers.ts";
|
|
5
5
|
import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
|
|
6
6
|
|
|
@@ -30,12 +30,15 @@ export interface AcpOptions {
|
|
|
30
30
|
preamble?: string;
|
|
31
31
|
/** The session's running token total from `usage_update` (inferred shape: totalTokens, else input + output, else `used`). Cumulative, not a delta. */
|
|
32
32
|
onTokens?: (sessionTotal: number) => void;
|
|
33
|
-
/** Resolve with an optionId, or undefined to cancel. Absent = every request is cancelled.
|
|
33
|
+
/** Resolve with an optionId, or undefined to cancel. Absent = every request is cancelled.
|
|
34
|
+
* A request whose payload could not be resolved is titled as such and carries no session-wide allow option. */
|
|
34
35
|
onPermission?: (req: PermissionRequest) => Promise<string | undefined>;
|
|
35
36
|
log?: (line: string) => void;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
const HANDSHAKE_MS = 30_000;
|
|
40
|
+
/** Tool calls whose arguments are remembered until their permission request arrives. */
|
|
41
|
+
const TOOL_INPUT_CAP = 64;
|
|
39
42
|
|
|
40
43
|
type Pending = { resolve: (v: any) => void; reject: (e: Error) => void };
|
|
41
44
|
|
|
@@ -46,6 +49,7 @@ export class AcpPeer extends BasePeer {
|
|
|
46
49
|
private nextId = 1;
|
|
47
50
|
private readonly pending = new Map<number, Pending>();
|
|
48
51
|
private chunks: string[] = [];
|
|
52
|
+
private readonly toolInputs = new Map<string, unknown>();
|
|
49
53
|
private primed = false;
|
|
50
54
|
private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
|
|
51
55
|
|
|
@@ -108,7 +112,7 @@ export class AcpPeer extends BasePeer {
|
|
|
108
112
|
this.primed = true;
|
|
109
113
|
if (turn !== this.turn) return; // superseded: these chunks belong to a later turn
|
|
110
114
|
const body = this.chunks.join("").trim();
|
|
111
|
-
if (body) this.onMessage?.(body, { inReplyTo: replyParent(envs) });
|
|
115
|
+
if (body) this.onMessage?.(body, { inReplyTo: replyParent(envs), to: replyAudience(envs) });
|
|
112
116
|
})
|
|
113
117
|
.catch((e: Error) => {
|
|
114
118
|
this.opts.log?.(`[${this.id}] prompt failed: ${e.message}`);
|
|
@@ -144,6 +148,13 @@ export class AcpPeer extends BasePeer {
|
|
|
144
148
|
if (msg.method === "session/update") {
|
|
145
149
|
const u = msg.params?.update;
|
|
146
150
|
if (u?.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") this.chunks.push(u.content.text);
|
|
151
|
+
// The permission request that follows may carry no `rawInput` (Kimi 2.0.1 does not), and then the
|
|
152
|
+
// console would be asked to approve a bare tool name. Keep what the call said it would run (issue #31).
|
|
153
|
+
else if ((u?.sessionUpdate === "tool_call" || u?.sessionUpdate === "tool_call_update") && typeof u.toolCallId === "string") {
|
|
154
|
+
if (u.rawInput !== undefined) this.toolInputs.set(u.toolCallId, u.rawInput);
|
|
155
|
+
if (u.status === "completed" || u.status === "failed") this.toolInputs.delete(u.toolCallId);
|
|
156
|
+
while (this.toolInputs.size > TOOL_INPUT_CAP) this.toolInputs.delete(this.toolInputs.keys().next().value as string);
|
|
157
|
+
}
|
|
147
158
|
else if (u?.sessionUpdate === "usage_update" && this.opts.onTokens) {
|
|
148
159
|
const f = { ...u, ...(typeof u.usage === "object" ? u.usage : {}) } as Record<string, unknown>;
|
|
149
160
|
const num = (k: string) => (typeof f[k] === "number" ? (f[k] as number) : 0);
|
|
@@ -163,11 +174,14 @@ export class AcpPeer extends BasePeer {
|
|
|
163
174
|
}
|
|
164
175
|
|
|
165
176
|
private async answerPermission(msg: any): Promise<void> {
|
|
166
|
-
const options: PermissionOption[] = msg.params?.options ?? [];
|
|
167
177
|
const call = msg.params?.toolCall ?? {};
|
|
168
|
-
// The approver has to see what runs, not only the tool's name ("Bash").
|
|
169
|
-
|
|
170
|
-
const
|
|
178
|
+
// The approver has to see what runs, not only the tool's name ("Bash"). The request itself carries it
|
|
179
|
+
// for some agents; for the rest it was on the `tool_call` update that announced the call.
|
|
180
|
+
const raw = call.rawInput ?? (typeof call.toolCallId === "string" ? this.toolInputs.get(call.toolCallId) : undefined);
|
|
181
|
+
const input = raw === undefined ? "" : `: ${(typeof raw === "string" ? raw : JSON.stringify(raw)).slice(0, 600)}`;
|
|
182
|
+
// An unknown payload is never dressed up as a description, and it must not buy a blanket grant.
|
|
183
|
+
const title: string = raw === undefined ? `${call.title ?? "tool call"} (payload not reported by the agent)` : `${call.title ?? "tool call"}${input}`;
|
|
184
|
+
const options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => raw !== undefined || o.kind !== "allow_always");
|
|
171
185
|
const picked = await this.opts.onPermission?.({ peer: this.id, title, options }).catch(() => undefined);
|
|
172
186
|
const valid = options.some((o) => o.optionId === picked);
|
|
173
187
|
this.send({
|
|
@@ -28,17 +28,36 @@ function roles(): Record<string, string[]> {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
31
|
-
/** Close codes a retry cannot fix:
|
|
31
|
+
/** Close codes a retry cannot fix: bad token, wrong project, reserved or taken id, wire version. */
|
|
32
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
33
|
4401: "the hub refused the control token",
|
|
35
34
|
4404: "the hub belongs to a different project or instance; restart this session from the intended project",
|
|
36
35
|
4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
|
|
37
36
|
4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
|
|
38
37
|
4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
|
|
39
38
|
};
|
|
39
|
+
/** Another session attached under this peer id. Unlike the other closes this one ends on its own, so the
|
|
40
|
+
* session stands by and takes the peer back once the hub reports it offline (issue #30). */
|
|
41
|
+
const HELD_CLOSE = 4000;
|
|
42
|
+
const HELD = `another session is attached to the hub as "${peerId}"; this one is standing by and takes the peer back when that session leaves`;
|
|
40
43
|
const INBOX_CAP = 200;
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Whether a live session holds this peer id, per the daemon's own status file. The hub gives the id to
|
|
47
|
+
* whoever said hello last, so reconnecting blind would evict the session that just took it and the two
|
|
48
|
+
* would trade the peer forever. No readable status means no live hub holds it, and a connect can only fail.
|
|
49
|
+
*/
|
|
50
|
+
function peerHeld(): boolean {
|
|
51
|
+
try {
|
|
52
|
+
const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8")) as { peers?: Record<string, { state?: string; claiming?: boolean }> };
|
|
53
|
+
const peer = status.peers?.[peerId];
|
|
54
|
+
// `claiming`: a hello that has not finished its preface. It reads as offline but a session is arriving.
|
|
55
|
+
return !!peer && (peer.state !== "offline" || peer.claiming === true);
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
const INSTRUCTIONS = [
|
|
43
62
|
"agent-hub connects you to other coding agents working in this project (for example codex, kimi, local) and to the hub console user.",
|
|
44
63
|
'Their messages arrive as <channel source="agent-hub" ...> tags; meta.source names the sender and meta.message_id identifies the message.',
|
|
@@ -89,7 +108,13 @@ async function push(envs: Envelope[]): Promise<void> {
|
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
async function connectLoop(): Promise<void> {
|
|
111
|
+
let standingBy = false;
|
|
92
112
|
for (let attempt = 0; ; attempt++) {
|
|
113
|
+
// Standing by after a take-over: wait for the slot rather than evicting whoever holds it now.
|
|
114
|
+
if (standingBy && peerHeld()) {
|
|
115
|
+
await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
93
118
|
let code: number | undefined;
|
|
94
119
|
try {
|
|
95
120
|
const client = await ControlClient.connect(stateDir, { role: toolsOnly ? "tools" : "peer", peer: peerId,
|
|
@@ -97,6 +122,8 @@ async function connectLoop(): Promise<void> {
|
|
|
97
122
|
client.onPush = (msg) => msg.t === "deliver" && void push(msg.envs ?? [msg.env]); // `env`: a daemon older than wire version 2
|
|
98
123
|
hub = client;
|
|
99
124
|
attempt = -1;
|
|
125
|
+
standingBy = false;
|
|
126
|
+
detached = undefined;
|
|
100
127
|
log(`connected to hub as "${peerId}"`);
|
|
101
128
|
code = await new Promise<number>((r) => (client.onClose = r));
|
|
102
129
|
hub = undefined;
|
|
@@ -105,11 +132,16 @@ async function connectLoop(): Promise<void> {
|
|
|
105
132
|
code = (e as { code?: number }).code;
|
|
106
133
|
if (attempt === 0) log((e as Error).message);
|
|
107
134
|
}
|
|
108
|
-
// Retrying these only
|
|
135
|
+
// Retrying these only hammers a hub that will refuse again.
|
|
109
136
|
if (code !== undefined && TERMINAL_CLOSES[code]) {
|
|
110
137
|
detached = TERMINAL_CLOSES[code];
|
|
111
138
|
return log(`stopped reconnecting: ${detached}`);
|
|
112
139
|
}
|
|
140
|
+
if (code === HELD_CLOSE) {
|
|
141
|
+
if (!standingBy) log(`standing by: ${HELD}`);
|
|
142
|
+
standingBy = true;
|
|
143
|
+
detached = HELD;
|
|
144
|
+
}
|
|
113
145
|
await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** Math.max(attempt, 0), MAX_RECONNECT_DELAY_MS)));
|
|
114
146
|
}
|
|
115
147
|
}
|
|
@@ -344,6 +344,8 @@ export class CodexPeer extends BasePeer {
|
|
|
344
344
|
const inReplyTo = this.injected;
|
|
345
345
|
this.injected = undefined;
|
|
346
346
|
this.deltas.clear();
|
|
347
|
+
// ponytail: the reply is addressed to the highest-hop sender only (newEnvelope's default). A digest that
|
|
348
|
+
// mixed senders answers the last of them; track the audience alongside `injected` if that starts to matter.
|
|
347
349
|
if (this.lastAnswer) this.onMessage?.(this.lastAnswer, inReplyTo ? { inReplyTo } : {});
|
|
348
350
|
this.lastAnswer = "";
|
|
349
351
|
this.setState("idle");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { renderDigest, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
|
|
2
|
+
import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
|
|
3
3
|
import { TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
|
|
4
4
|
import { BasePeer } from "../hub/peers.ts";
|
|
5
5
|
import { profile } from "../local/sandbox.ts";
|
|
@@ -52,6 +52,7 @@ const system = (cwd: string, preamble = "") =>
|
|
|
52
52
|
|
|
53
53
|
/** Hub-native agent loop: the hub owns every model call, so it can choose the model per call (L2) and the gateway (L3). */
|
|
54
54
|
export class LocalPeer extends BasePeer {
|
|
55
|
+
readonly hubNative = true;
|
|
55
56
|
private readonly history: ChatMessage[] = [];
|
|
56
57
|
private readonly sessionId = `agent-hub-local-${randomUUID()}`;
|
|
57
58
|
private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
|
|
@@ -96,7 +97,7 @@ export class LocalPeer extends BasePeer {
|
|
|
96
97
|
const policy = this.opts.turnPolicy?.(envs);
|
|
97
98
|
// A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
|
|
98
99
|
// The task id travels with it, so the board can keep the text (`ahub task show`) while tail and log show a stub.
|
|
99
|
-
const reply: EnvelopeOpts = { inReplyTo: replyParent(envs), ...(policy?.pii ? { to: [USER], private: true, priority: "important" as const, ...(policy.task ? { refs: { task: policy.task } } : {}) } : {}) };
|
|
100
|
+
const reply: EnvelopeOpts = { inReplyTo: replyParent(envs), to: replyAudience(envs), ...(policy?.pii ? { to: [USER], private: true, priority: "important" as const, ...(policy.task ? { refs: { task: policy.task } } : {}) } : {}) };
|
|
100
101
|
this.run(envs, turn, msgs, progress, policy, reply)
|
|
101
102
|
.then((answer) => {
|
|
102
103
|
if (turn !== this.turn) return;
|
|
@@ -148,7 +149,7 @@ export class LocalPeer extends BasePeer {
|
|
|
148
149
|
permit: this.opts.tools.permit,
|
|
149
150
|
sandboxProfile: this.sandboxProfile,
|
|
150
151
|
send: (text, to) => {
|
|
151
|
-
this.onMessage?.(text, policy?.pii ? reply : { inReplyTo: replyParent(envs),
|
|
152
|
+
this.onMessage?.(text, policy?.pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
|
|
152
153
|
return policy?.pii ? "sent to the console user only (PII task)" : "sent";
|
|
153
154
|
},
|
|
154
155
|
};
|
package/src/adapters/pi.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join, resolve, relative } from "node:path";
|
|
4
4
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
5
|
-
import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
5
|
+
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
6
6
|
import { BasePeer } from "../hub/peers.ts";
|
|
7
7
|
import { stopOwnedProcess } from "../hub/child-process.ts";
|
|
8
8
|
|
|
@@ -33,6 +33,7 @@ const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): s
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
export class PiPeer extends BasePeer {
|
|
36
|
+
readonly hubNative = true;
|
|
36
37
|
private proc?: ChildProcessWithoutNullStreams;
|
|
37
38
|
private server?: ReturnType<typeof Bun.serve>;
|
|
38
39
|
private buffer = "";
|
|
@@ -300,9 +301,12 @@ export class PiPeer extends BasePeer {
|
|
|
300
301
|
if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
|
|
301
302
|
const text = this.settledText.trim(); const error = this.settledError; const cancelled = this.settledCancelled;
|
|
302
303
|
this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
|
|
303
|
-
|
|
304
|
+
// Answer the peers this turn was for, not every peer on the bus (issue #29). activeEnvs still holds the
|
|
305
|
+
// whole delivery here, steered additions included.
|
|
306
|
+
const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
|
|
307
|
+
if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
|
|
304
308
|
else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
|
|
305
|
-
else if (text) this.onMessage?.(text,
|
|
309
|
+
else if (text) this.onMessage?.(text, reply);
|
|
306
310
|
this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
|
|
307
311
|
}
|
|
308
312
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-r
|
|
|
25
25
|
import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
26
26
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
27
27
|
|
|
28
|
+
import { backendLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
29
|
+
|
|
28
30
|
const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
|
|
29
31
|
|
|
30
32
|
ahub --project <path|id> <command> select a repository or worktree explicitly
|
|
@@ -570,10 +572,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
570
572
|
const { status } = await hub.request({ t: "status" });
|
|
571
573
|
hub.close();
|
|
572
574
|
console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
|
|
573
|
-
const peers = Object.entries(status.peers as Record<string,
|
|
574
|
-
for (const [id, p] of peers) console.log(
|
|
575
|
-
const models = (status as any).models?.backends as
|
|
576
|
-
if (models?.length) for (const backend of models) console.log(
|
|
575
|
+
const peers = Object.entries(status.peers as Record<string, PeerRow>);
|
|
576
|
+
for (const [id, p] of peers) console.log(peerLine(id, p));
|
|
577
|
+
const models = (status as any).models?.backends as BackendRow[] | undefined;
|
|
578
|
+
if (models?.length) for (const backend of models) console.log(backendLine(backend));
|
|
577
579
|
if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
|
|
578
580
|
const counts = Object.entries(status.tasks ?? {}).map(([s, n]) => `${n} ${s}`).join(", ");
|
|
579
581
|
if (counts) console.log(` tasks: ${counts} (ahub board)`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
2
|
+
|
|
3
|
+
export interface PeerRow { state?: string; queued?: number; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
|
+
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
|
|
5
|
+
|
|
6
|
+
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
|
7
|
+
export function backendLabel(backend: BackendRow): string {
|
|
8
|
+
const kind = backend.kind ?? "unknown";
|
|
9
|
+
const alias = backend.alias ?? "unknown";
|
|
10
|
+
return alias.startsWith(`${kind}/`) ? alias : `${kind}/${alias}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function peerLine(id: string, p: PeerRow): string {
|
|
14
|
+
return ` ${id.padEnd(8)} ${(p.state ?? "unknown").padEnd(8)} queued ${p.queued ?? 0}` +
|
|
15
|
+
(p.paused ? ` (${p.paused})` : "") +
|
|
16
|
+
(p.servedBy ? ` last call: ${p.servedBy}` : "") +
|
|
17
|
+
// Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
|
|
18
|
+
// meant reading status.json by hand.
|
|
19
|
+
(p.requestedModel ? ` model: ${p.requestedModel}` : "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function backendLine(backend: BackendRow): string {
|
|
23
|
+
return ` model ${backendLabel(backend)} ${backend.state ?? "unknown"} active ${backend.active ?? 0}` +
|
|
24
|
+
(backend.requestedModel ? ` requested ${backend.requestedModel}` : "") +
|
|
25
|
+
(backend.actualModel ? ` actual ${backend.actualModel}` : "") +
|
|
26
|
+
(backend.provider ? ` provider ${backend.provider}` : "");
|
|
27
|
+
}
|
package/src/hub/bus.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HUB, MAX_HOP, newEnvelope, parseMarker, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
1
|
+
import { DIGEST, HUB, MAX_HOP, newEnvelope, parseMarker, replyAudience, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
2
2
|
import type { PeerAdapter } from "./peers.ts";
|
|
3
3
|
|
|
4
4
|
export type BusEvent =
|
|
@@ -30,6 +30,27 @@ export interface BusSnapshot {
|
|
|
30
30
|
|
|
31
31
|
export const DEFAULT_BUS: BusOptions = { retryMs: 1000, batchMax: 3, batchMs: 15_000, queueCap: 200 };
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* What a peer may claim for its own message. A hub-native peer writes `[IMPORTANT]` on routine status reports
|
|
35
|
+
* (issue #29), and `important` interrupts every recipient at once, so it only keeps it when answering an
|
|
36
|
+
* `important` request that was addressed to it. `fyi` and `status` are never capped.
|
|
37
|
+
*/
|
|
38
|
+
function capPriority(peer: PeerAdapter, priority: Priority, parent: EnvelopeOpts["inReplyTo"], delivery: Envelope[] | undefined): Priority {
|
|
39
|
+
if (!peer.hubNative || priority !== "important") return priority;
|
|
40
|
+
if (!parent) return "status"; // nothing was asked: an unsolicited report is never urgent enough to interrupt
|
|
41
|
+
// The whole delivery, not just `replyParent`: an important request addressed to the peer can share a hop with a
|
|
42
|
+
// later status item, and then the parent is that status item.
|
|
43
|
+
const asked = delivery ?? [parent];
|
|
44
|
+
return asked.some((e) => e.priority === "important" && !!e.to?.includes(peer.id)) ? "important" : "status";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `digest` is not a peer. A reply to a condensed delivery is for the senders that condensation replaced. */
|
|
48
|
+
function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
|
|
49
|
+
if (!to.includes(DIGEST)) return to;
|
|
50
|
+
const senders = originals ? replyAudience(originals) : [];
|
|
51
|
+
return [...new Set(to.flatMap((id) => (id === DIGEST ? senders : [id])))];
|
|
52
|
+
}
|
|
53
|
+
|
|
33
54
|
const SEEN_CAP = 2048;
|
|
34
55
|
const MAX_ATTEMPTS = 3;
|
|
35
56
|
const DIGEST_MAX = 10;
|
|
@@ -66,7 +87,17 @@ export class Bus {
|
|
|
66
87
|
if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
|
|
67
88
|
peer.onMessage = (text, opts) => {
|
|
68
89
|
const { priority, body } = parseMarker(text);
|
|
69
|
-
if (body)
|
|
90
|
+
if (!body) return;
|
|
91
|
+
// What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
|
|
92
|
+
// and the priority ceiling are about what it stands for.
|
|
93
|
+
const last = this.lastDelivery.get(peer.id);
|
|
94
|
+
const answers = opts?.inReplyTo?.id !== undefined && last?.out.some((e) => e.id === opts.inReplyTo!.id);
|
|
95
|
+
const originals = answers ? last!.originals : undefined;
|
|
96
|
+
this.publish(newEnvelope(peer.id, body, {
|
|
97
|
+
...opts,
|
|
98
|
+
...(opts?.to?.length ? { to: resolveTo(opts.to, originals) } : {}),
|
|
99
|
+
priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals),
|
|
100
|
+
}));
|
|
70
101
|
};
|
|
71
102
|
peer.onFailed = (envs) => {
|
|
72
103
|
// The adapter got the condensed list; what has to come back is what that list replaced.
|
package/src/hub/daemon.ts
CHANGED
|
@@ -131,6 +131,11 @@ class WsPeer extends BasePeer {
|
|
|
131
131
|
claim(sock: Sock): void {
|
|
132
132
|
this.claimed = sock;
|
|
133
133
|
}
|
|
134
|
+
/** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
|
|
135
|
+
* for the id must not take it from a claimant that is still arriving. */
|
|
136
|
+
get claiming(): boolean {
|
|
137
|
+
return !!this.claimed && this.claimed !== this.sock && this.claimed.readyState === WebSocket.OPEN;
|
|
138
|
+
}
|
|
134
139
|
attach(sock: Sock): void {
|
|
135
140
|
if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
|
|
136
141
|
this.sock?.close(4000, "replaced");
|
|
@@ -493,7 +498,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
493
498
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
494
499
|
codexProxyPort: opts.codexProxyPort,
|
|
495
500
|
peers: Object.fromEntries(
|
|
496
|
-
[...bus.peers].map(([id, p]) => [id, { state: bus.stateOf(id), queued: bus.queued(id), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]),
|
|
501
|
+
[...bus.peers].map(([id, p]) => [id, { state: bus.stateOf(id), queued: bus.queued(id), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]),
|
|
497
502
|
),
|
|
498
503
|
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
499
504
|
...(modelRelay ? { models: modelRelay.status() } : {}),
|
|
@@ -979,6 +984,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
979
984
|
if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
980
985
|
const ws = peer;
|
|
981
986
|
ws.claim(sock);
|
|
987
|
+
writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
|
|
982
988
|
void ensurePreface(c.peer).finally(() => {
|
|
983
989
|
if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
|
|
984
990
|
});
|
package/src/hub/envelope.ts
CHANGED
|
@@ -25,13 +25,15 @@ export const MAX_HOP = 3;
|
|
|
25
25
|
export const USER: PeerId = "user";
|
|
26
26
|
/** The hub itself: sender of recall and workflow events. */
|
|
27
27
|
export const HUB: PeerId = "hub";
|
|
28
|
+
/** A hub-written condensation of several status messages. Not a peer: a reply to it is for the senders it replaced. */
|
|
29
|
+
export const DIGEST: PeerId = "digest";
|
|
28
30
|
|
|
29
31
|
export interface EnvelopeOpts {
|
|
30
32
|
to?: PeerId[];
|
|
31
33
|
kind?: Kind;
|
|
32
34
|
priority?: Priority;
|
|
33
|
-
/** The envelope this one answers: inherits its trace, hop + 1. */
|
|
34
|
-
inReplyTo?: Pick<Envelope, "trace" | "hop"
|
|
35
|
+
/** The envelope this one answers: inherits its trace, hop + 1, and - when `to` is absent - its sender as the addressee. */
|
|
36
|
+
inReplyTo?: Pick<Envelope, "trace" | "hop"> & Partial<Pick<Envelope, "id" | "from" | "to" | "priority">>;
|
|
35
37
|
refs?: Envelope["refs"];
|
|
36
38
|
private?: boolean;
|
|
37
39
|
}
|
|
@@ -42,7 +44,10 @@ export function newEnvelope(from: PeerId, body: string, opts: EnvelopeOpts = {})
|
|
|
42
44
|
trace: opts.inReplyTo?.trace ?? randomUUID(),
|
|
43
45
|
hop: opts.inReplyTo ? opts.inReplyTo.hop + 1 : 0,
|
|
44
46
|
from,
|
|
45
|
-
|
|
47
|
+
// An answer goes back to whoever asked. Without this a reply carries no `to` and the bus fans it out to
|
|
48
|
+
// every peer, so one directed question costs every other agent a turn (issue #29). `user` and `hub` are not
|
|
49
|
+
// peers, so a reply to them reaches the console and the log and nobody's turn.
|
|
50
|
+
...(opts.to?.length ? { to: opts.to } : opts.inReplyTo?.from ? { to: [opts.inReplyTo.from] } : {}),
|
|
46
51
|
kind: opts.kind ?? "chat",
|
|
47
52
|
priority: opts.priority ?? "status",
|
|
48
53
|
body,
|
|
@@ -80,6 +85,11 @@ export function renderDigest(envs: Envelope[], primed: boolean): string {
|
|
|
80
85
|
return primed ? items : `${STANDING_INSTRUCTION}\n\n${items}`;
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
/** Who a reply to a delivery is for: everyone whose message it answers, each once, in the order they arrived. */
|
|
89
|
+
export function replyAudience(envs: Envelope[]): PeerId[] {
|
|
90
|
+
return [...new Set(envs.filter((e) => !(e.from === HUB && e.kind === "presence")).map((e) => e.from))];
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
/** What a reply to a delivery answers: the highest-hop item, so a digest cannot be used to reset the hop cap. */
|
|
84
94
|
export function replyParent(envs: Envelope[]): Envelope {
|
|
85
95
|
// Never the hub's own context block (it was not published, so nothing can resolve it); ties go to the later item.
|
package/src/hub/inference.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { OmniRoute } from "../omniroute/client.ts";
|
|
2
2
|
import type { Sidecar } from "../switchyard/sidecar.ts";
|
|
3
3
|
import { CLASSES, type TaskClass } from "./board.ts";
|
|
4
|
-
import { newEnvelope, type Envelope } from "./envelope.ts";
|
|
4
|
+
import { DIGEST, newEnvelope, type Envelope } from "./envelope.ts";
|
|
5
5
|
|
|
6
6
|
export interface InferenceConfig {
|
|
7
7
|
enabled: boolean;
|
|
@@ -14,7 +14,7 @@ export interface InferenceConfig {
|
|
|
14
14
|
export const DEFAULT_INFERENCE: InferenceConfig = { enabled: true, digest_min_items: 5, digest_min_chars: 8000, triage: true };
|
|
15
15
|
|
|
16
16
|
/** Sender of a condensed digest. Not the hub's own id: replies to it must keep the hop count of what it replaced. */
|
|
17
|
-
export
|
|
17
|
+
export { DIGEST };
|
|
18
18
|
|
|
19
19
|
const TIMEOUT_MS = 8000;
|
|
20
20
|
const BACKOFF_MS = 5 * 60_000;
|
package/src/hub/peers.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
|
|
|
3
3
|
export interface PeerAdapter {
|
|
4
4
|
readonly id: PeerId;
|
|
5
5
|
readonly state: PeerState;
|
|
6
|
+
/** A peer the hub drives itself (Pi, the local worker). It cannot be trusted to rate its own urgency: the bus caps it. */
|
|
7
|
+
readonly hubNative?: boolean;
|
|
6
8
|
/** Inject now, as one prompt. Only called while `state === "idle"`. Rejecting puts the envelopes back at the queue head. */
|
|
7
9
|
deliver(envs: Envelope[]): Promise<void>;
|
|
8
10
|
/** Optional: feed envelopes into the turn that is running now. Only called while `state === "busy"`. */
|
package/src/ui/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<title>agent-hub | Local dashboard</title>
|
|
7
7
|
<style>
|
|
8
|
-
:root{color-scheme:light;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--green:#245f45;--red:#a2392f}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow
|
|
8
|
+
:root{color-scheme:light;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--panel:#ffffff;--panel-soft:#f4f6f2;--control:#ffffff;--control-border:#bbc4bb;--hover:#f0f4ef;--green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--dot:#98a29b;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;--focus:#245f45;--on-accent:#ffffff}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:var(--panel);border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);gap:12px}.section-head .filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.content{padding:16px 18px}.empty{color:var(--muted);padding:20px 18px}.row{padding:14px 18px;border-bottom:1px solid var(--line)}.row:last-child{border-bottom:0}.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.peername{display:flex;align-items:center;gap:8px;min-width:0}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:2px 7px;background:var(--panel-soft);color:var(--muted);border-radius:4px;white-space:nowrap}.badge.ok{background:var(--green-soft);color:var(--green)}.badge.warn{color:var(--amber);background:var(--amber-soft)}.badge.info{color:var(--blue);background:var(--blue-soft)}.badge.err{color:var(--red);background:var(--red-soft)}.dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--dot)}.dot.ok{background:var(--dot-ok)}.dot.warn{background:var(--dot-warn)}.dot.err{background:var(--dot-err)}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:999px;padding:4px 10px;font-size:12px;cursor:pointer;line-height:1.2}.chip:hover{background:var(--hover)}.chip[aria-pressed="true"]{background:var(--green);color:var(--on-accent);border-color:var(--green);font-weight:600}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--green);color:var(--on-accent);border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--focus);outline-offset:3px}input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:5px;padding:8px;background:var(--control);color:var(--ink)}input[type="checkbox"]{width:auto;margin-right:6px}textarea{resize:vertical;min-height:78px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px}form .field{margin-bottom:12px}.actions select{width:auto;max-width:220px}.section-head .filters select{width:auto;max-width:220px}.body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:7px}.stream{height:360px;overflow:auto}.event{padding:12px 18px;border-bottom:1px solid var(--line)}.event[hidden]{display:none}.event .body{font:13px/1.55 ui-monospace,monospace}.notice{grid-column:1/-1;margin:0;border:1px solid var(--line);border-radius:6px;padding:10px 14px;background:var(--panel);overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:var(--red)}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}progress.warn{accent-color:var(--amber)}progress.warn::-webkit-progress-value{background:var(--amber)}progress.critical{accent-color:var(--red)}progress.critical::-webkit-progress-value{background:var(--red)}progress::-moz-progress-bar{background:var(--green)}progress.warn::-moz-progress-bar{background:var(--amber)}progress.critical::-moz-progress-bar{background:var(--red)}.budget-window+.budget-window{margin-top:14px}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}details summary{cursor:pointer;padding:14px 18px;color:var(--green);font-weight:600}details form{padding:0 18px 18px}.task-list{max-height:460px;overflow:auto}footer{max-width:1440px;margin:auto;padding:0 32px 24px;color:var(--muted);font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}.stack{gap:18px}header,main{padding:18px}.stream{height:300px}footer{padding:0 18px 18px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
|
|
9
9
|
</style>
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
<main>
|
|
14
14
|
<p id="notice" class="notice" role="status" aria-live="polite"></p>
|
|
15
15
|
<div class="stack">
|
|
16
|
-
<section aria-labelledby="stream-title"><div class="section-head"><h2 id="stream-title">Live stream</h2><label><input id="follow" type="checkbox" checked> Follow latest</label></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div></section>
|
|
17
|
-
<section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><span id="task-count" class="meta"></span></div><div id="tasks" class="task-list"></div><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
|
|
16
|
+
<section aria-labelledby="stream-title"><div class="section-head"><h2 id="stream-title">Live stream</h2><div class="filters"><div id="stream-filters" class="chips" role="group" aria-label="Filter events by kind"></div><label><input id="follow" type="checkbox" checked> Follow latest</label></div></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div><p id="stream-empty" class="empty" hidden>No events match this filter.</p></section>
|
|
17
|
+
<section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="proposed">Proposed</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="tasks" class="task-list"></div><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
|
|
18
18
|
<section aria-labelledby="compose-title"><div class="section-head"><h2 id="compose-title">Console message</h2></div><form id="send" class="content"><div class="field"><label for="recipient">Recipient</label><select id="recipient" name="recipient"><option value="">All peers</option></select></div><div class="field"><label for="message">Message</label><textarea id="message" name="body" required maxlength="8000" placeholder="Send an instruction to the team..."></textarea></div><button class="primary" data-mutation="project" type="submit">Send message</button></form></section>
|
|
19
19
|
</div>
|
|
20
20
|
<div class="stack">
|
|
@@ -33,11 +33,26 @@ const $ = (id) => document.getElementById(id);
|
|
|
33
33
|
const el = (tag, text, cls) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; };
|
|
34
34
|
let stopped = false, cursor = 0, timer, projectsTimer, eventCount = 0;
|
|
35
35
|
let managerMode = false, projectList = [], selectedProjectId = '', selectedInstanceId = '', snapshotValid = false, viewGeneration = 0, projectRequest = 0;
|
|
36
|
+
let lastSnapshot = null;
|
|
36
37
|
const drafts = new Map();
|
|
37
38
|
const pendingButtons = new WeakSet();
|
|
38
39
|
let draftChoicesPending = false;
|
|
39
40
|
const signatures = new Map();
|
|
40
41
|
const time = (value) => value ? new Date(value).toLocaleString() : 'unknown';
|
|
42
|
+
const rel = (value) => {
|
|
43
|
+
if (!value) return 'unknown';
|
|
44
|
+
const delta = Math.round((value - Date.now()) / 1000), past = delta < 0, span = Math.abs(delta);
|
|
45
|
+
if (span < 15) return 'just now';
|
|
46
|
+
const unit = span < 60 ? [span, 's'] : span < 3600 ? [Math.floor(span / 60), 'm'] : span < 86400 ? [Math.floor(span / 3600), 'h'] : [Math.floor(span / 86400), 'd'];
|
|
47
|
+
const text = unit[0] + unit[1];
|
|
48
|
+
return past ? text + ' ago' : 'in ' + text;
|
|
49
|
+
};
|
|
50
|
+
const badge = (text, cls = '') => { const node = el('span', text, 'badge' + (cls ? ' ' + cls : '')); return node; };
|
|
51
|
+
const dot = (cls) => el('span', undefined, 'dot ' + cls);
|
|
52
|
+
const taskBadge = { proposed: '', in_progress: 'warn', in_review: 'info', approved: 'ok', changes_requested: 'err' };
|
|
53
|
+
const peerDot = { idle: 'ok', busy: 'warn', paused: 'warn', offline: '' };
|
|
54
|
+
const projectBadge = { running: 'ok', starting: 'warn', stopping: 'warn', stopped: '', unavailable: 'err', incompatible: 'err', missing: 'err' };
|
|
55
|
+
function refreshRelativeTimes(root) { for (const node of root.querySelectorAll('[data-ts]')) node.textContent = rel(Number(node.dataset.ts)); }
|
|
41
56
|
function notice(text, error = false) { $('notice').textContent = text; $('notice').classList.toggle('error', error); }
|
|
42
57
|
function expire() { stopped = true; clearTimeout(timer); clearTimeout(projectsTimer); $('connection').textContent = 'Session closed'; notice('Session missing or expired. Run ahub ui in your terminal to reopen the dashboard.', true); document.querySelectorAll('button').forEach((button) => { button.disabled = true; }); }
|
|
43
58
|
async function post(path, body) {
|
|
@@ -81,6 +96,8 @@ function resetView(id, instance) {
|
|
|
81
96
|
saveDraft();
|
|
82
97
|
selectedProjectId = id || ''; selectedInstanceId = instance || ''; snapshotValid = false; cursor = 0; eventCount = 0; viewGeneration++;
|
|
83
98
|
signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
|
|
99
|
+
if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
|
|
100
|
+
$('stream-empty').hidden = true; $('task-filter').value = '';
|
|
84
101
|
for (const [id, text] of [['peers','No project selected.'],['tasks','No project selected.'],['permissions','No project selected.'],['budget','No project selected.']]) { $(id).replaceChildren(); empty($(id), text); }
|
|
85
102
|
$('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
|
|
86
103
|
notice('');
|
|
@@ -98,8 +115,9 @@ function renderProjects() {
|
|
|
98
115
|
if (!projectList.length) return empty(target, 'No registered projects.');
|
|
99
116
|
for (const project of projectList) {
|
|
100
117
|
const id = typeof project.id === 'string' ? project.id : '', state = String(project.state || 'unavailable');
|
|
101
|
-
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
|
|
102
|
-
|
|
118
|
+
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
|
|
119
|
+
name.append(dot(projectBadge[state] || ''), el('span', projectName(project.root, id), 'label'));
|
|
120
|
+
head.append(name, badge(state, projectBadge[state])); row.append(head); row.append(el('p', String(project.root || id), 'meta'));
|
|
103
121
|
const counts = []; if (project.peers !== undefined) counts.push(String(count(project.peers)) + ' peers'); if (project.tasks !== undefined) counts.push(String(taskCount(project.tasks)) + ' tasks');
|
|
104
122
|
if (counts.length) row.append(el('p', counts.join(' / '), 'meta')); if (project.warning || project.error) row.append(el('p', project.warning || project.error, 'meta'));
|
|
105
123
|
const actions = el('div', undefined, 'actions');
|
|
@@ -134,8 +152,9 @@ function render(snapshot) {
|
|
|
134
152
|
update('peers', peers, (target) => {
|
|
135
153
|
if (!ids.length) return empty(target, 'No peers connected. Start an agent from your terminal.');
|
|
136
154
|
for (const [id, peer] of Object.entries(peers)) {
|
|
137
|
-
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
|
|
138
|
-
|
|
155
|
+
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
|
|
156
|
+
name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
|
|
157
|
+
head.append(name, badge(peer.state, peerDot[peer.state] || ''));
|
|
139
158
|
row.append(head, el('p', peer.queued + ' queued', 'meta'));
|
|
140
159
|
if (peer.paused) row.append(el('p', peer.paused, 'meta'));
|
|
141
160
|
if (peer.requestedModel) row.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
|
|
@@ -149,11 +168,14 @@ function render(snapshot) {
|
|
|
149
168
|
const peerSignature = JSON.stringify(ids);
|
|
150
169
|
if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), ids, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
|
|
151
170
|
$('task-count').textContent = snapshot.tasks.length + ' tasks';
|
|
152
|
-
|
|
171
|
+
const taskFilter = $('task-filter').value;
|
|
172
|
+
const visibleTasks = taskFilter ? snapshot.tasks.filter((task) => task.state === taskFilter) : snapshot.tasks;
|
|
173
|
+
update('tasks', [visibleTasks, ids, taskFilter, snapshot.tasks.length], (target) => {
|
|
153
174
|
if (!snapshot.tasks.length) return empty(target, 'No public tasks yet. Propose a task below to get started.');
|
|
154
|
-
|
|
175
|
+
if (!visibleTasks.length) return empty(target, 'No tasks in this state.');
|
|
176
|
+
for (const task of visibleTasks) {
|
|
155
177
|
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
|
|
156
|
-
head.append(el('span', '#' + task.id + ' ' + task.title, 'label'),
|
|
178
|
+
head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge(task.state.replaceAll('_', ' '), taskBadge[task.state] || ''));
|
|
157
179
|
row.append(head, el('p', task.class + ' / ' + (task.owner || 'unassigned') + (task.reviewer ? ' / review: ' + task.reviewer : ''), 'meta'));
|
|
158
180
|
if (task.detail) row.append(el('p', task.detail, 'body'));
|
|
159
181
|
if (task.state !== 'approved' && ids.length) {
|
|
@@ -179,9 +201,17 @@ function render(snapshot) {
|
|
|
179
201
|
if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
|
|
180
202
|
for (const [peer, budget] of Object.entries(snapshot.budget)) {
|
|
181
203
|
const row = el('div', undefined, 'row'); row.append(el('div', peer, 'label'));
|
|
182
|
-
if (budget.paused)
|
|
204
|
+
if (budget.paused) { const line = el('p', 'Paused: ' + budget.paused.reason + ' / resets ', 'meta'), stamp = el('span'); stamp.dataset.ts = budget.paused.resetsAt; stamp.title = time(budget.paused.resetsAt); stamp.textContent = rel(budget.paused.resetsAt); line.append(stamp); row.append(line); }
|
|
183
205
|
if (!budget.windows.length) row.append(el('p', 'No usage windows reported.', 'meta'));
|
|
184
|
-
for (const window of budget.windows) {
|
|
206
|
+
for (const window of budget.windows) {
|
|
207
|
+
const item = el('div', undefined, 'budget-window'), percent = Math.round(window.used * 100);
|
|
208
|
+
item.append(el('p', window.id + ': ' + percent + '% used' + (window.stale ? ' (stale)' : ''), 'meta'));
|
|
209
|
+
const bar = el('progress'); bar.max = 1; bar.value = window.used; bar.className = percent >= 90 ? 'critical' : percent >= 70 ? 'warn' : '';
|
|
210
|
+
bar.setAttribute('aria-label', peer + ' ' + window.id + ' usage'); item.append(bar);
|
|
211
|
+
const meta = el('p', window.source + (window.resetsAt ? ' / resets ' : ''), 'meta');
|
|
212
|
+
if (window.resetsAt) { const stamp = el('span'); stamp.dataset.ts = window.resetsAt; stamp.title = time(window.resetsAt); stamp.textContent = rel(window.resetsAt); meta.append(stamp); }
|
|
213
|
+
item.append(meta); row.append(item);
|
|
214
|
+
}
|
|
185
215
|
target.append(row);
|
|
186
216
|
}
|
|
187
217
|
});
|
|
@@ -190,13 +220,18 @@ function render(snapshot) {
|
|
|
190
220
|
for (const entry of snapshot.events || []) {
|
|
191
221
|
if (entry.seq <= cursor) continue;
|
|
192
222
|
if (!eventCount) stream.replaceChildren();
|
|
193
|
-
const event = entry.event, node = el('div', undefined, 'event');
|
|
194
|
-
|
|
223
|
+
const event = entry.event, node = el('div', undefined, 'event'), kind = event.env ? event.env.kind : 'state';
|
|
224
|
+
node.dataset.kind = kind; applyStreamFilter(node);
|
|
225
|
+
if (event.env) { const env = event.env, meta = el('div', '', 'meta'), stamp = el('span'); stamp.dataset.ts = env.ts; stamp.title = time(env.ts); stamp.textContent = rel(env.ts);
|
|
226
|
+
meta.append(stamp, el('span', ' / ' + env.from + ' → ' + (env.to ? env.to.join(', ') : 'all') + ' / ' + env.kind + (event.t !== 'envelope' ? ' / ' + event.t : '') + (event.dropped ? ' / ' + event.dropped : '')));
|
|
227
|
+
node.append(meta, el('div', env.body, 'body')); }
|
|
195
228
|
else node.append(el('div', event.peer + ' / ' + event.state, 'meta'));
|
|
196
229
|
stream.append(node); eventCount++; while (stream.children.length > 200) stream.firstElementChild.remove();
|
|
197
230
|
}
|
|
198
231
|
cursor = Math.max(cursor, snapshot.cursor || 0);
|
|
199
232
|
if ($('follow').checked) stream.scrollTop = stream.scrollHeight;
|
|
233
|
+
refreshRelativeTimes(document);
|
|
234
|
+
refreshStreamEmpty();
|
|
200
235
|
syncMutationControls();
|
|
201
236
|
}
|
|
202
237
|
async function poll() {
|
|
@@ -207,15 +242,35 @@ async function poll() {
|
|
|
207
242
|
const input = {after:cursor}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
|
|
208
243
|
const result = await post('/snapshot', input);
|
|
209
244
|
if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
|
|
210
|
-
snapshotValid = true; render(result); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString();
|
|
245
|
+
snapshotValid = true; lastSnapshot = result; render(result); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString();
|
|
211
246
|
}
|
|
212
247
|
}
|
|
213
248
|
catch (error) { if (!stopped) $('connection').textContent = 'Disconnected / retrying: ' + error.message; }
|
|
214
249
|
if (!stopped) timer = setTimeout(poll, 1000);
|
|
215
250
|
}
|
|
251
|
+
const STREAM_KINDS = ['chat', 'task', 'review', 'status', 'budget', 'presence', 'state'];
|
|
252
|
+
let streamFilter = '';
|
|
253
|
+
function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
|
|
254
|
+
function refreshStreamEmpty() { const has = $('stream').querySelector('.event:not([hidden])') !== null, any = $('stream').querySelector('.event') !== null; $('stream-empty').hidden = has || !any; }
|
|
255
|
+
function renderStreamChips() {
|
|
256
|
+
const group = $('stream-filters');
|
|
257
|
+
if (group.children.length) return;
|
|
258
|
+
for (const kind of STREAM_KINDS) {
|
|
259
|
+
const chip = el('button', kind);
|
|
260
|
+
chip.type = 'button'; chip.className = 'chip'; chip.setAttribute('aria-pressed', 'false');
|
|
261
|
+
chip.addEventListener('click', () => {
|
|
262
|
+
streamFilter = streamFilter === kind ? '' : kind;
|
|
263
|
+
for (const child of group.children) child.setAttribute('aria-pressed', String(child.textContent === streamFilter));
|
|
264
|
+
for (const node of $('stream').querySelectorAll('.event')) applyStreamFilter(node);
|
|
265
|
+
refreshStreamEmpty();
|
|
266
|
+
});
|
|
267
|
+
group.append(chip);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
$('task-filter').addEventListener('change', () => { if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); });
|
|
216
271
|
$('send').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, body = $('message').value.trim(); if (!body) return; const to = $('recipient').value, payload = {action:'send', body}; if (to) payload.to = [to]; if (await action(payload, event.submitter) && generation === viewGeneration) $('message').value = ''; });
|
|
217
272
|
$('propose').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, title = $('title').value.trim(); if (!title) return; const payload = {action:'propose', title, class:$('class').value}; if ($('detail').value.trim()) payload.detail = $('detail').value.trim(); if ($('owner').value) payload.owner = $('owner').value; if (await action(payload, event.submitter) && generation === viewGeneration) { $('title').value = ''; $('detail').value = ''; } });
|
|
218
|
-
(async () => { try { if (ticket) await post('/session', {ticket}); ticket = ''; await refreshProjects(); await poll(); } catch (error) { ticket = ''; if (!stopped) { stopped = true; notice('Could not start session: ' + error.message + ' Run ahub ui to reopen.', true); $('connection').textContent = 'Session unavailable'; } } })();
|
|
273
|
+
(async () => { try { if (ticket) await post('/session', {ticket}); ticket = ''; renderStreamChips(); await refreshProjects(); await poll(); } catch (error) { ticket = ''; if (!stopped) { stopped = true; notice('Could not start session: ' + error.message + ' Run ahub ui to reopen.', true); $('connection').textContent = 'Session unavailable'; } } })();
|
|
219
274
|
</script>
|
|
220
275
|
</body>
|
|
221
276
|
</html>
|