@staix/agent-hub 0.7.6 → 0.7.8
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 +20 -1
- package/README.md +5 -4
- package/docs/operations.md +63 -16
- package/docs/quickstart.md +1 -1
- package/docs/security.md +2 -1
- package/docs/smoke.md +166 -12
- package/docs/specs/2026-09-19-agent-hub-design.md +92 -3
- package/docs/specs/2026-09-20-durable-delivery-design.md +1 -1
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -1
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2 -2
- package/src/adapters/acp.ts +47 -7
- package/src/adapters/codex-appserver.ts +19 -4
- package/src/cli/main.ts +2 -0
- package/src/hub/bus.ts +9 -1
- package/src/hub/checks.ts +91 -0
- package/src/hub/daemon.ts +128 -9
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/tasks.ts +122 -9
- package/templates/config.json +4 -1
|
@@ -113,6 +113,12 @@ stopped with its matching CLI before a protocol-8 manager is launched.
|
|
|
113
113
|
|
|
114
114
|
## Current 0.7.0 correction and recovery contract
|
|
115
115
|
|
|
116
|
+
Amended 2026-09-30: 0.7.0 shipped protocol 10 and the queue commands; protocol 10
|
|
117
|
+
went into production with 0.7.0 and was verified there with 0.7.1, where
|
|
118
|
+
`ahub queue list` read back live receipts ([smoke checklist](../smoke.md)). `ahub queue show` and `ahub queue resolve` are
|
|
119
|
+
covered by tests, not by a live run. The paragraphs below keep the pre-release
|
|
120
|
+
wording.
|
|
121
|
+
|
|
116
122
|
The earlier sections record the protocol-8 implementation history. The current
|
|
117
123
|
release is 0.6.4 with protocol 9; the next target is 0.7.0 with protocol 10.
|
|
118
124
|
This correction supersedes the earlier protocol-8 source/target statement for
|
|
@@ -146,6 +152,6 @@ The documented verification sequence is: run a dry-run, verify package and
|
|
|
146
152
|
session identity, manually confirm Claude recovery, read back version, session,
|
|
147
153
|
queue, task, and budget state, then release the held queues. Do not claim a
|
|
148
154
|
successful 0.7.0 cutover from tests, synthetic approvals, or package
|
|
149
|
-
installation alone. Issue [#
|
|
155
|
+
installation alone. Issue [#1](https://github.com/STAIxBWLB/agent-hub/issues/1)
|
|
150
156
|
remains open for off-campus Access credentials and a natural near-limit budget
|
|
151
157
|
pause.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.8",
|
|
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",
|
|
@@ -15727,7 +15727,7 @@ class ControlClient {
|
|
|
15727
15727
|
// package.json
|
|
15728
15728
|
var package_default = {
|
|
15729
15729
|
name: "@staix/agent-hub",
|
|
15730
|
-
version: "0.7.
|
|
15730
|
+
version: "0.7.8",
|
|
15731
15731
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15732
15732
|
license: "MIT",
|
|
15733
15733
|
type: "module",
|
|
@@ -15806,7 +15806,7 @@ var TASK_TOOLS = [
|
|
|
15806
15806
|
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
|
|
15807
15807
|
tool("hub_task_accept", "Take a task that was assigned to you.", { id }, ["id"]),
|
|
15808
15808
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
|
|
15809
|
-
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
15809
|
+
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
15810
15810
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] } }),
|
|
15811
15811
|
tool("hub_review", "Give your verdict on a task you were asked to review. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str }, ["id", "verdict"]),
|
|
15812
15812
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
|
package/src/adapters/acp.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface PermissionRequest {
|
|
|
13
13
|
peer: PeerId;
|
|
14
14
|
title: string;
|
|
15
15
|
options: PermissionOption[];
|
|
16
|
+
/** The tool's name alone, when the adapter knows it apart from the title (which can quote the payload). */
|
|
17
|
+
tool?: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface AcpOptions {
|
|
@@ -33,6 +35,8 @@ export interface AcpOptions {
|
|
|
33
35
|
/** Resolve with an optionId, or undefined to cancel. Absent = every request is cancelled.
|
|
34
36
|
* A request whose payload could not be resolved is titled as such and carries no session-wide allow option. */
|
|
35
37
|
onPermission?: (req: PermissionRequest) => Promise<string | undefined>;
|
|
38
|
+
/** Tool titles approved once without asking: the hub's own tools, as Codex gets them (issue #72). Exact names only. */
|
|
39
|
+
autoApprove?: (toolTitle: string) => boolean;
|
|
36
40
|
log?: (line: string) => void;
|
|
37
41
|
}
|
|
38
42
|
|
|
@@ -42,6 +46,16 @@ const TOOL_INPUT_CAP = 64;
|
|
|
42
46
|
|
|
43
47
|
type Pending = { resolve: (v: any) => void; reject: (e: Error) => void };
|
|
44
48
|
|
|
49
|
+
/** Streamed argument text as a payload: a complete JSON object, or nothing (a partial stream is not what will run). */
|
|
50
|
+
function jsonObject(text: string | undefined): object | undefined {
|
|
51
|
+
try {
|
|
52
|
+
const v = text === undefined ? undefined : JSON.parse(text);
|
|
53
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/** ACP client (JSON-RPC 2.0, newline-delimited, over the child's stdio). One session, one prompt in flight. */
|
|
46
60
|
export class AcpPeer extends BasePeer {
|
|
47
61
|
private proc: ChildProcessWithoutNullStreams | undefined;
|
|
@@ -50,6 +64,7 @@ export class AcpPeer extends BasePeer {
|
|
|
50
64
|
private readonly pending = new Map<number, Pending>();
|
|
51
65
|
private chunks: string[] = [];
|
|
52
66
|
private readonly toolInputs = new Map<string, unknown>();
|
|
67
|
+
private readonly toolText = new Map<string, string>(); // streamed argument text, per call, until it finishes
|
|
53
68
|
private primed = false;
|
|
54
69
|
private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
|
|
55
70
|
private activeDeliveryId: string | undefined;
|
|
@@ -183,10 +198,15 @@ export class AcpPeer extends BasePeer {
|
|
|
183
198
|
if (u?.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") this.chunks.push(u.content.text);
|
|
184
199
|
// The permission request that follows may carry no `rawInput` (Kimi 2.0.1 does not), and then the
|
|
185
200
|
// console would be asked to approve a bare tool name. Keep what the call said it would run (issue #31).
|
|
201
|
+
// Kimi 2.1.1 sends no rawInput before the answer either: the argument JSON streams as content text (issue #72).
|
|
186
202
|
else if ((u?.sessionUpdate === "tool_call" || u?.sessionUpdate === "tool_call_update") && typeof u.toolCallId === "string") {
|
|
203
|
+
// A new call starts clean, so a reused id can never show the arguments of the call before it.
|
|
204
|
+
if (u.sessionUpdate === "tool_call") (this.toolInputs.delete(u.toolCallId), this.toolText.delete(u.toolCallId));
|
|
187
205
|
if (u.rawInput !== undefined) this.toolInputs.set(u.toolCallId, u.rawInput);
|
|
188
|
-
|
|
189
|
-
|
|
206
|
+
const text = Array.isArray(u.content) ? u.content.map((c: any) => (c?.type === "content" && c.content?.type === "text" ? String(c.content.text) : "")).join("") : "";
|
|
207
|
+
if (text) this.toolText.set(u.toolCallId, text);
|
|
208
|
+
if (u.status === "completed" || u.status === "failed") (this.toolInputs.delete(u.toolCallId), this.toolText.delete(u.toolCallId));
|
|
209
|
+
for (const map of [this.toolInputs, this.toolText]) while (map.size > TOOL_INPUT_CAP) map.delete(map.keys().next().value as string);
|
|
190
210
|
}
|
|
191
211
|
else if (u?.sessionUpdate === "usage_update" && this.opts.onTokens) {
|
|
192
212
|
const f = { ...u, ...(typeof u.usage === "object" ? u.usage : {}) } as Record<string, unknown>;
|
|
@@ -208,14 +228,34 @@ export class AcpPeer extends BasePeer {
|
|
|
208
228
|
|
|
209
229
|
private async answerPermission(msg: any): Promise<void> {
|
|
210
230
|
const call = msg.params?.toolCall ?? {};
|
|
231
|
+
const once = (msg.params?.options ?? []).find((o: PermissionOption) => o.kind === "allow_once");
|
|
232
|
+
if (once && typeof call.title === "string" && this.opts.autoApprove?.(call.title)) {
|
|
233
|
+
this.opts.log?.(`permission auto-approved for ${this.id}: ${call.title}`); // the name only: arguments may quote a PII turn
|
|
234
|
+
return this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "selected", optionId: once.optionId } } });
|
|
235
|
+
}
|
|
211
236
|
// The approver has to see what runs, not only the tool's name ("Bash"). The request itself carries it
|
|
212
|
-
// for some agents; for the rest it was on the `tool_call` update that announced the call
|
|
213
|
-
|
|
214
|
-
const
|
|
237
|
+
// for some agents; for the rest it was on the `tool_call` update that announced the call, as rawInput or,
|
|
238
|
+
// failing that, as streamed argument text that only counts once it is a complete JSON object.
|
|
239
|
+
const id = typeof call.toolCallId === "string" ? call.toolCallId : undefined;
|
|
240
|
+
const raw = call.rawInput ?? (id === undefined ? undefined : this.toolInputs.get(id) ?? jsonObject(this.toolText.get(id)));
|
|
241
|
+
const full = raw === undefined ? "" : typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
242
|
+
// A cut payload hides its tail, and a tail can change what runs: it is marked and buys no session-wide grant.
|
|
243
|
+
const cut = full.length > 600;
|
|
244
|
+
const input = raw === undefined ? "" : `: ${cut ? `${full.slice(0, 600)} [cut, ${full.length} chars]` : full}`;
|
|
215
245
|
// An unknown payload is never dressed up as a description, and it must not buy a blanket grant.
|
|
216
246
|
const title: string = raw === undefined ? `${call.title ?? "tool call"} (payload not reported by the agent)` : `${call.title ?? "tool call"}${input}`;
|
|
217
|
-
const options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => raw !== undefined || o.kind !== "allow_always");
|
|
218
|
-
|
|
247
|
+
const options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => (raw !== undefined && !cut) || o.kind !== "allow_always");
|
|
248
|
+
// Only a bare tool name travels on its own (a desktop notice shows it); anything prose-like stays in the title.
|
|
249
|
+
const tool = typeof call.title === "string" && /^[\w.:@-]{1,80}$/.test(call.title) ? call.title : undefined;
|
|
250
|
+
// Waiting for a person is not the agent going silent: keep the watchdog from cancelling the turn meanwhile.
|
|
251
|
+
const turn = this.turn;
|
|
252
|
+
const alive = setInterval(() => this.state === "busy" && turn === this.turn && this.touch(), Math.max(10, Math.min(30_000, Math.floor(this.watchdogMs / 3))));
|
|
253
|
+
let picked: string | undefined;
|
|
254
|
+
try {
|
|
255
|
+
picked = await this.opts.onPermission?.({ peer: this.id, title, options, ...(tool ? { tool } : {}) }).catch(() => undefined);
|
|
256
|
+
} finally {
|
|
257
|
+
clearInterval(alive); // also when there is no handler at all
|
|
258
|
+
}
|
|
219
259
|
const valid = options.some((o) => o.optionId === picked);
|
|
220
260
|
this.send({
|
|
221
261
|
jsonrpc: "2.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
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
|
|
|
@@ -54,6 +54,7 @@ export class CodexPeer extends BasePeer {
|
|
|
54
54
|
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer" }>();
|
|
55
55
|
private usageTimer: ReturnType<typeof setInterval> | undefined;
|
|
56
56
|
private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
|
|
57
|
+
private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
|
|
57
58
|
private lastAnswer = "";
|
|
58
59
|
private readonly deltas = new Map<string, string[]>();
|
|
59
60
|
private primed = false;
|
|
@@ -146,6 +147,7 @@ export class CodexPeer extends BasePeer {
|
|
|
146
147
|
resolve: (result) => {
|
|
147
148
|
this.primed = true;
|
|
148
149
|
this.injected = replyParent(envs);
|
|
150
|
+
this.answering = [...envs];
|
|
149
151
|
const nativeTurn = typeof result?.turn?.id === "string" ? result.turn.id : undefined;
|
|
150
152
|
if (deliveryId) {
|
|
151
153
|
if (nativeTurn) this.addTurnDelivery(nativeTurn, deliveryId);
|
|
@@ -188,6 +190,7 @@ export class CodexPeer extends BasePeer {
|
|
|
188
190
|
this.primed = true;
|
|
189
191
|
// The turn now answers these too; the highest hop wins so a steer cannot reset the hop cap.
|
|
190
192
|
this.injected = replyParent(this.injected ? [this.injected, ...envs] : envs);
|
|
193
|
+
this.answering.push(...envs);
|
|
191
194
|
if (deliveryId) {
|
|
192
195
|
this.addTurnDelivery(expectedTurnId, deliveryId);
|
|
193
196
|
this.delivery({ id: deliveryId, state: "accepted" });
|
|
@@ -216,6 +219,7 @@ export class CodexPeer extends BasePeer {
|
|
|
216
219
|
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
|
|
217
220
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
218
221
|
this.injected = undefined;
|
|
222
|
+
this.answering = [];
|
|
219
223
|
this.lastAnswer = "";
|
|
220
224
|
this.abandonSteers("turn went silent");
|
|
221
225
|
super.onWatchdog();
|
|
@@ -297,9 +301,17 @@ export class CodexPeer extends BasePeer {
|
|
|
297
301
|
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
|
|
298
302
|
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
|
|
299
303
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
304
|
+
this.forgetTurn();
|
|
300
305
|
this.setState("offline");
|
|
301
306
|
}
|
|
302
307
|
|
|
308
|
+
/** Nothing of an interrupted turn may address a later answer: a TUI that comes back starts its own. */
|
|
309
|
+
private forgetTurn(): void {
|
|
310
|
+
this.injected = undefined;
|
|
311
|
+
this.answering = [];
|
|
312
|
+
this.lastAnswer = "";
|
|
313
|
+
}
|
|
314
|
+
|
|
303
315
|
private fromTui(link: Link, raw: string): void {
|
|
304
316
|
const msg = parse(raw);
|
|
305
317
|
if (msg?.id !== undefined && TRACKED.has(msg.method)) link.tracked.set(msg.id, msg.method);
|
|
@@ -329,6 +341,7 @@ export class CodexPeer extends BasePeer {
|
|
|
329
341
|
this.link = link;
|
|
330
342
|
this.threadId = threadId;
|
|
331
343
|
this.activeTurns.clear();
|
|
344
|
+
this.forgetTurn();
|
|
332
345
|
this.opts.log?.(`[${this.id}] thread ${threadId}`);
|
|
333
346
|
this.setState("idle");
|
|
334
347
|
this.readUsage();
|
|
@@ -385,11 +398,13 @@ export class CodexPeer extends BasePeer {
|
|
|
385
398
|
this.turnDeliveries.delete(nativeTurn);
|
|
386
399
|
}
|
|
387
400
|
const inReplyTo = this.injected;
|
|
401
|
+
const to = replyAudience(this.answering);
|
|
388
402
|
this.injected = undefined;
|
|
403
|
+
this.answering = [];
|
|
389
404
|
this.deltas.clear();
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
if (this.lastAnswer) this.onMessage?.(this.lastAnswer, inReplyTo ? { inReplyTo } : {});
|
|
405
|
+
// Addressed like every other adapter's answer (issue #3): each sender it answers, including one steered in;
|
|
406
|
+
// the bus maps a condensed delivery's `digest` back to the senders it replaced, a steer beside it or not.
|
|
407
|
+
if (this.lastAnswer) this.onMessage?.(this.lastAnswer, inReplyTo ? { inReplyTo, to } : {});
|
|
393
408
|
this.lastAnswer = "";
|
|
394
409
|
this.setState("idle");
|
|
395
410
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -404,6 +404,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
404
404
|
else {
|
|
405
405
|
console.log(`ahub setup changes your Claude Code plugin configuration, one step at a time, re-checking after each. First step:\n ${step.argv.join(" ")}\n ${step.why}`);
|
|
406
406
|
if (!args.includes("--yes")) {
|
|
407
|
+
// Nobody can answer without a terminal: waiting would hang a script forever (issue #73).
|
|
408
|
+
if (!process.stdin.isTTY) fail("not a terminal: nothing was changed; rerun with --yes to apply");
|
|
407
409
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
408
410
|
const answer = (await rl.question("Proceed (this and the steps that follow from it)? [y/N] ")).trim().toLowerCase();
|
|
409
411
|
rl.close();
|
package/src/hub/bus.ts
CHANGED
|
@@ -130,9 +130,12 @@ export class Bus {
|
|
|
130
130
|
const last = this.lastDelivery.get(peer.id);
|
|
131
131
|
const answers = opts?.inReplyTo?.id !== undefined && last?.out.some((e) => e.id === opts.inReplyTo!.id);
|
|
132
132
|
const originals = answers ? last!.originals : undefined;
|
|
133
|
+
// `digest` in the audience stands for the condensed delivery even when a steer, not the digest, is the parent.
|
|
134
|
+
const condensed = !!last?.out.some((e) => e.from === DIGEST);
|
|
135
|
+
const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
|
|
133
136
|
this.publish(newEnvelope(peer.id, body, {
|
|
134
137
|
...opts,
|
|
135
|
-
...(opts?.to?.length ? { to: resolveTo(opts.to,
|
|
138
|
+
...(opts?.to?.length ? { to: resolveTo(opts.to, audienceOriginals) } : {}),
|
|
136
139
|
priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals),
|
|
137
140
|
}));
|
|
138
141
|
};
|
|
@@ -387,6 +390,11 @@ export class Bus {
|
|
|
387
390
|
return () => this.taps.delete(fn);
|
|
388
391
|
}
|
|
389
392
|
|
|
393
|
+
/** Paused by the console or the budget relay, whatever the adapter's own state. */
|
|
394
|
+
isPaused(id: PeerId): boolean {
|
|
395
|
+
return this.paused.has(id);
|
|
396
|
+
}
|
|
397
|
+
|
|
390
398
|
/** The adapter's state, or `paused` while the peer is held by `pause()`. */
|
|
391
399
|
stateOf(id: PeerId): PeerState {
|
|
392
400
|
const state = this.peers.get(id)?.state ?? "offline";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { childEnv } from "./child-process.ts";
|
|
3
|
+
|
|
4
|
+
export interface CheckResult {
|
|
5
|
+
code: number | null;
|
|
6
|
+
timedOut: boolean;
|
|
7
|
+
/** Killed because the hub stopped: not a verdict on the work. */
|
|
8
|
+
interrupted?: boolean;
|
|
9
|
+
/** The last lines of stdout and stderr together, capped. */
|
|
10
|
+
tail: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const TAIL_LINES = 40;
|
|
14
|
+
const TAIL_CHARS = 4000;
|
|
15
|
+
const KEEP_CHARS = 64 * 1024;
|
|
16
|
+
/** How long output may still arrive after the command exited; a descendant that left the group can hold the pipes. */
|
|
17
|
+
const DRAIN_MS = 500;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Why the completion checks of issue #7 must not run, or undefined when git confirms nobody committed the config.
|
|
21
|
+
* A command a cloned repository ships would otherwise run on the owner's machine the first time an agent marks a task
|
|
22
|
+
* done. Unknown is not untracked: no repository, no git, or a git error all refuse. The directory itself counts too (a
|
|
23
|
+
* committed symlink or submodule at `.agenthub`), and so does any spelling of the path, since macOS opens either.
|
|
24
|
+
*/
|
|
25
|
+
export function checksRefusal(cwd: string): string | undefined {
|
|
26
|
+
const r = spawnSync("git", ["-C", cwd, "ls-files", "-s", "-z", "--", ":(icase).agenthub"], { encoding: "utf8", env: childEnv(process.env) });
|
|
27
|
+
if (r.status !== 0) return "git could not confirm that .agenthub/config.json is untracked";
|
|
28
|
+
const tracked = r.stdout.split("\0").some((row) => {
|
|
29
|
+
const path = row.slice(row.indexOf("\t") + 1).toLowerCase();
|
|
30
|
+
return path === ".agenthub" || path === ".agenthub/config.json";
|
|
31
|
+
});
|
|
32
|
+
return tracked ? ".agenthub/config.json is committed to git" : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Runs one check command in the project root, in its own process group so a timeout stops what it started. */
|
|
36
|
+
export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>): Promise<CheckResult> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let out = "";
|
|
39
|
+
let timedOut = false;
|
|
40
|
+
let interrupted = false;
|
|
41
|
+
let settled = false;
|
|
42
|
+
let exited = false;
|
|
43
|
+
const child = spawn("sh", ["-c", command], { cwd, env: childEnv(process.env), stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
44
|
+
const killGroup = () => {
|
|
45
|
+
try {
|
|
46
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
47
|
+
} catch {
|
|
48
|
+
// already gone
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const stop = () => {
|
|
52
|
+
if (exited) return; // finished before the hub stopped: its own result stands
|
|
53
|
+
interrupted = true;
|
|
54
|
+
killGroup();
|
|
55
|
+
};
|
|
56
|
+
running?.add(stop);
|
|
57
|
+
const keep = (chunk: string) => {
|
|
58
|
+
out += chunk;
|
|
59
|
+
if (out.length > KEEP_CHARS) out = out.slice(-KEEP_CHARS);
|
|
60
|
+
};
|
|
61
|
+
for (const s of [child.stdout, child.stderr]) {
|
|
62
|
+
s?.setEncoding("utf8"); // decodes across chunk edges, so Korean output is not cut into U+FFFD
|
|
63
|
+
s?.on("data", keep);
|
|
64
|
+
}
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
timedOut = true;
|
|
67
|
+
killGroup();
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
const done = (code: number | null) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
settled = true;
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
running?.delete(stop);
|
|
74
|
+
child.stdout?.destroy();
|
|
75
|
+
child.stderr?.destroy();
|
|
76
|
+
const tail = out.replace(/\s+$/, "").split("\n").slice(-TAIL_LINES).join("\n").slice(-TAIL_CHARS);
|
|
77
|
+
resolve({ code: timedOut || interrupted ? null : code, timedOut, ...(interrupted ? { interrupted } : {}), tail });
|
|
78
|
+
};
|
|
79
|
+
child.on("error", (e) => {
|
|
80
|
+
out += `\n${e.message}`;
|
|
81
|
+
done(null);
|
|
82
|
+
});
|
|
83
|
+
child.on("exit", (code) => {
|
|
84
|
+
exited = true;
|
|
85
|
+
clearTimeout(timer); // it finished in time; only its output may still be on the way
|
|
86
|
+
killGroup(); // a background job it left behind (sh has no job control, so it is still in the group)
|
|
87
|
+
setTimeout(() => done(code), DRAIN_MS);
|
|
88
|
+
});
|
|
89
|
+
child.on("close", (code) => done(code));
|
|
90
|
+
});
|
|
91
|
+
}
|
package/src/hub/daemon.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { childEnv } from "./child-process.ts";
|
|
7
|
+
import { checksRefusal, runCheck } from "./checks.ts";
|
|
5
8
|
import type { ServerWebSocket } from "bun";
|
|
6
9
|
import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
7
10
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -16,7 +19,7 @@ import { Capture, skipTools } from "../memory/capture.ts";
|
|
|
16
19
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
17
20
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
18
21
|
import { Briefs } from "../memory/brief.ts";
|
|
19
|
-
import { Board, type TaskClass } from "./board.ts";
|
|
22
|
+
import { Board, CLASSES, type TaskClass } from "./board.ts";
|
|
20
23
|
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
21
24
|
import { HUB } from "./envelope.ts";
|
|
22
25
|
import { trimToTokens } from "../memory/recall.ts";
|
|
@@ -53,6 +56,12 @@ export interface HubConfig {
|
|
|
53
56
|
pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
|
|
54
57
|
mlx: Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
|
|
55
58
|
local: { deny: string[]; bash_network: boolean; max_steps: number; read_allow: string[] };
|
|
59
|
+
/** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
|
|
60
|
+
approvals: { timeout_s: number; notify: boolean };
|
|
61
|
+
/** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
|
|
62
|
+
tasks: { release_after_min: number };
|
|
63
|
+
/** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
|
|
64
|
+
checks: { timeout_s: number; [cls: string]: string | number };
|
|
56
65
|
}
|
|
57
66
|
export const DEFAULT_CONFIG: HubConfig = {
|
|
58
67
|
watchdog_ms: DEFAULT_WATCHDOG_MS,
|
|
@@ -69,6 +78,10 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
69
78
|
pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto", dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 },
|
|
70
79
|
mlx: { provider: "ollama", model: "agenthub-fast-mlx:4b-8k", sourceModel: "qwen3.5:4b-mlx", contextWindow: 8192, maxInputTokens: 6000, maxTokens: 2048, maxConcurrency: 1 },
|
|
71
80
|
local: { deny: [], bash_network: false, max_steps: 30, read_allow: [] },
|
|
81
|
+
// Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
|
|
82
|
+
approvals: { timeout_s: 120, notify: false },
|
|
83
|
+
tasks: { release_after_min: 30 },
|
|
84
|
+
checks: { timeout_s: 600 },
|
|
72
85
|
};
|
|
73
86
|
|
|
74
87
|
export { stateDirFor };
|
|
@@ -98,6 +111,12 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
98
111
|
omniroute: { ...DEFAULT_CONFIG.omniroute, ...file.omniroute },
|
|
99
112
|
local: { ...DEFAULT_CONFIG.local, ...file.local },
|
|
100
113
|
pi: { ...DEFAULT_CONFIG.pi, ...file.pi },
|
|
114
|
+
approvals: {
|
|
115
|
+
timeout_s: file.approvals?.timeout_s ?? DEFAULT_CONFIG.approvals.timeout_s,
|
|
116
|
+
notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
|
|
117
|
+
},
|
|
118
|
+
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks },
|
|
119
|
+
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
101
120
|
mlx,
|
|
102
121
|
};
|
|
103
122
|
} catch (error) {
|
|
@@ -121,6 +140,8 @@ export interface DaemonOptions {
|
|
|
121
140
|
/** Auto-approve ACP permission requests with the agent's allow_once option. */
|
|
122
141
|
unattended?: boolean;
|
|
123
142
|
permissionTimeoutMs?: number;
|
|
143
|
+
/** Tests replace the desktop notifier; the default posts a macOS notification when approvals.notify is on. */
|
|
144
|
+
notifier?: (title: string, body: string) => void;
|
|
124
145
|
/** Called once when shutdown begins, however it was triggered; the lifecycle uses it to arm a hard deadline. */
|
|
125
146
|
onShutdownStart?: () => void;
|
|
126
147
|
/** How often the daemon checks that its project root and state dir still exist. Tests shrink this. */
|
|
@@ -301,6 +322,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
301
322
|
};
|
|
302
323
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
303
324
|
startupCleanup.push(() => board.close());
|
|
325
|
+
// Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
|
|
326
|
+
const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => (CLASSES as readonly string[]).includes(k) && typeof v === "string" && v.trim())) as Record<string, string>;
|
|
327
|
+
const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && !(k in checkCommands));
|
|
328
|
+
if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
|
|
329
|
+
const checksRefused = Object.keys(checkCommands).length ? checksRefusal(opts.cwd) : undefined;
|
|
330
|
+
if (checksRefused) log(`checks ignored: ${checksRefused}, and only a file nobody committed may choose commands the hub runs`);
|
|
331
|
+
const checksAllowed = Object.keys(checkCommands).length > 0 && !checksRefused;
|
|
332
|
+
const checkTimeoutS = typeof config.checks.timeout_s === "number" && config.checks.timeout_s >= 1 ? Math.min(config.checks.timeout_s, 3600) : 600;
|
|
333
|
+
const runningChecks = new Set<() => void>();
|
|
334
|
+
let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
|
|
335
|
+
const interrupted = { code: null, timedOut: false, interrupted: true, tail: "" };
|
|
304
336
|
const tasks = new Tasks({
|
|
305
337
|
board,
|
|
306
338
|
bus,
|
|
@@ -309,6 +341,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
309
341
|
project: chain.at(-1)!,
|
|
310
342
|
...(config.memory.enabled ? { memory, briefs: new Briefs(memory, chain.at(-1)!, config.memory.brief_items) } : {}),
|
|
311
343
|
notify,
|
|
344
|
+
...(checksAllowed ? { check: (cls: string) => checkCommands[cls], runCheck: async (command: string) => (checksClosed ? interrupted : runCheck(command, opts.cwd, checkTimeoutS * 1000, runningChecks)) } : {}),
|
|
312
345
|
// What a peer learned reaches the peers working now, on their next delivery, instead of at their next session (#68).
|
|
313
346
|
// Fail-open: the note is already saved, so a bus that cannot persist costs the sharing, not the tool call.
|
|
314
347
|
share: (by, line) => {
|
|
@@ -318,6 +351,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
318
351
|
log(`note from ${by} not shared: ${(e as Error).message}`);
|
|
319
352
|
}
|
|
320
353
|
},
|
|
354
|
+
tell: (peer, line) => {
|
|
355
|
+
try {
|
|
356
|
+
bus.note(peer, line);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
log(`note for ${peer} not kept: ${(e as Error).message}`);
|
|
359
|
+
}
|
|
360
|
+
},
|
|
321
361
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
322
362
|
});
|
|
323
363
|
// ---- budget relay -------------------------------------------------------------------------------------------
|
|
@@ -409,6 +449,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
409
449
|
return { ...e, env: { ...env, ...(task ? { refs: { task } } : {}), body: `[private${task ? `: task #${task}, see ahub task show ${task}` : ""}]` } };
|
|
410
450
|
};
|
|
411
451
|
const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
|
|
452
|
+
// What the hub's MCP server offers a native peer. Codex approves them in config, Kimi's requests for exactly these
|
|
453
|
+
// names are answered by the hub: no file or process is touched, and every call passes the hub's own checks.
|
|
454
|
+
const HUB_TOOLS = ["hub_send", ...TASK_TOOLS.map((t) => t.name)];
|
|
455
|
+
const HUB_TOOL_TITLES = new Set(HUB_TOOLS.map((name) => `mcp__agent-hub__${name}`));
|
|
412
456
|
const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
|
|
413
457
|
|
|
414
458
|
/** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
|
|
@@ -431,8 +475,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
431
475
|
return line(tasks.accept(by, a.id));
|
|
432
476
|
case "hub_task_decline":
|
|
433
477
|
return line(await tasks.decline(by, a.id, a.reason));
|
|
434
|
-
case "hub_task_done":
|
|
435
|
-
|
|
478
|
+
case "hub_task_done": {
|
|
479
|
+
const t = await tasks.done(by, a.id, a.summary, a.refs);
|
|
480
|
+
return tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
|
|
481
|
+
}
|
|
436
482
|
case "hub_review":
|
|
437
483
|
return line(await tasks.review(by, a.id, a.verdict, a.note));
|
|
438
484
|
case "hub_remember":
|
|
@@ -477,8 +523,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
477
523
|
const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
|
|
478
524
|
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
|
|
479
525
|
};
|
|
526
|
+
let releasing = false; // gone-owner release (#6): one run at a time, and a recovery commit waits for it
|
|
480
527
|
const recoveryReady = () => {
|
|
481
|
-
if (!recoveryActive() || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
528
|
+
if (!recoveryActive() || releasing || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
482
529
|
if (!recoveryPeerSnapshot) return true;
|
|
483
530
|
const current = recoveryPeers();
|
|
484
531
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -573,17 +620,70 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
573
620
|
// message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
|
|
574
621
|
bus.onQueues = () => { if (!stopping) writeStatus(); };
|
|
575
622
|
|
|
623
|
+
// When each peer went offline; a peer never seen attached counts from hub start (issue #6).
|
|
624
|
+
const offlineSince = new Map<PeerId, number>();
|
|
625
|
+
const hubStartedAt = Date.now();
|
|
626
|
+
const releaseGoneOwners = async () => {
|
|
627
|
+
const limit = config.tasks.release_after_min;
|
|
628
|
+
if (!(limit > 0) || releasing || stopping || recoveryActive()) return;
|
|
629
|
+
releasing = true;
|
|
630
|
+
try {
|
|
631
|
+
await releaseOwners(limit);
|
|
632
|
+
} finally {
|
|
633
|
+
releasing = false;
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
const releaseOwners = async (limit: number) => {
|
|
637
|
+
const owners = new Set(board.list().filter((t) => t.owner && ["proposed", "in_progress", "changes_requested"].includes(t.state)).map((t) => t.owner!));
|
|
638
|
+
for (const owner of owners) {
|
|
639
|
+
if (stopping || recoveryActive()) return;
|
|
640
|
+
if (owner === USER || owner === "hub" || bus.isPaused(owner) || bus.stateOf(owner) !== "offline") continue;
|
|
641
|
+
const since = offlineSince.get(owner) ?? hubStartedAt;
|
|
642
|
+
if (Date.now() - since < limit * 60_000) continue;
|
|
643
|
+
const moved = await tasks.releaseFromGone(owner, Math.round((Date.now() - since) / 60_000)).catch((e) => (log(`releasing tasks of ${owner} failed: ${(e as Error).message}`), []));
|
|
644
|
+
for (const m of moved) notify(`task ${m.title} moved from ${owner} (offline) to ${m.to ?? "nobody"}`);
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
const releaseTimer = setInterval(() => void releaseGoneOwners(), 60_000);
|
|
648
|
+
releaseTimer.unref?.();
|
|
649
|
+
intervals.push(releaseTimer);
|
|
650
|
+
|
|
576
651
|
bus.tap((e) => {
|
|
577
652
|
e = redact(e);
|
|
578
653
|
uiEvents.push({ seq: ++uiSequence, event: e });
|
|
579
654
|
if (uiEvents.length > 200) uiEvents.shift();
|
|
580
|
-
if (e.t === "state")
|
|
655
|
+
if (e.t === "state") {
|
|
656
|
+
log(`state ${e.peer} -> ${e.state}`);
|
|
657
|
+
if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
|
|
658
|
+
else offlineSince.delete(e.peer);
|
|
659
|
+
}
|
|
581
660
|
else if (e.t === "undeliverable") log(`UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}`);
|
|
582
661
|
else if (e.t === "overflow") log(`OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
|
|
583
662
|
else log(`msg ${e.env.from} -> ${e.env.to?.join(",") ?? "*"} ${e.env.priority} hop=${e.env.hop}${e.dropped ? ` NOT DELIVERED(${e.dropped})` : ""}: ${e.env.body.slice(0, 200)}`);
|
|
584
663
|
if (!stopping) writeStatus();
|
|
585
664
|
});
|
|
586
665
|
|
|
666
|
+
const approvalTimeoutS = (() => {
|
|
667
|
+
const s = config.approvals.timeout_s;
|
|
668
|
+
if (typeof s === "number" && s >= 30 && s <= 3600) return s;
|
|
669
|
+
log(`approvals.timeout_s ${JSON.stringify(s)} is outside 30-3600 seconds; using 120`);
|
|
670
|
+
return 120;
|
|
671
|
+
})();
|
|
672
|
+
let notifierBroken = false;
|
|
673
|
+
// Peer and tool name only: a title can quote what a PII turn is about to write (issue #5).
|
|
674
|
+
const desktop = opts.notifier ?? ((title: string, body: string) => {
|
|
675
|
+
const quote = (t: string) => `"${t.replace(/[\\"]/g, "\\$&")}"`;
|
|
676
|
+
const child = spawn("osascript", ["-e", `display notification ${quote(body)} with title ${quote(title)}`], { stdio: "ignore", env: childEnv(process.env) });
|
|
677
|
+
const kill = setTimeout(() => child.kill(), 5000);
|
|
678
|
+
kill.unref?.();
|
|
679
|
+
const failed = (why: string) => {
|
|
680
|
+
if (!notifierBroken) log(`desktop notification failed (${why}); later ones are not reported`);
|
|
681
|
+
notifierBroken = true;
|
|
682
|
+
};
|
|
683
|
+
child.on("error", (e: Error) => (clearTimeout(kill), failed(e.message)));
|
|
684
|
+
child.on("exit", (code: number | null, signal: string | null) => (clearTimeout(kill), code || signal ? failed(signal ? `osascript stopped by ${signal}` : `osascript exit ${code}`) : undefined));
|
|
685
|
+
});
|
|
686
|
+
|
|
587
687
|
async function onPermission(req: PermissionRequest): Promise<string | undefined> {
|
|
588
688
|
if (stopping) return undefined;
|
|
589
689
|
if (opts.unattended) return req.options.find((o) => o.kind === "allow_once")?.optionId;
|
|
@@ -593,11 +693,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
593
693
|
const title = req.title.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
594
694
|
// The title can quote what a PII turn is about to write. It is for the person approving, on the console; the log
|
|
595
695
|
// (which `ahub ask` reads as evidence) only records that a request was made.
|
|
596
|
-
|
|
597
|
-
|
|
696
|
+
const timeoutMs = opts.permissionTimeoutMs ?? approvalTimeoutS * 1000;
|
|
697
|
+
log(`permission ${id} requested by ${req.peer} (${title.length} chars, shown on the console; cancelled after ${timeoutMs / 1000}s)`);
|
|
698
|
+
// The tool name is for the desktop notice only: the console push keeps its shape.
|
|
699
|
+
const { tool: _tool, ...shown } = req;
|
|
700
|
+
const push = JSON.stringify({ t: "permission", id, ...shown, title });
|
|
598
701
|
for (const c of consoles) if (c.data.tail) c.send(push);
|
|
702
|
+
if (config.approvals.notify) {
|
|
703
|
+
try {
|
|
704
|
+
desktop("agent-hub", `${req.peer} asks for approval${req.tool ? `: ${req.tool}` : ""} (ahub tail)`);
|
|
705
|
+
} catch {
|
|
706
|
+
// never let a notifier break the request itself
|
|
707
|
+
}
|
|
708
|
+
}
|
|
599
709
|
return new Promise((resolve) => {
|
|
600
|
-
const timer = setTimeout(() =>
|
|
710
|
+
const timer = setTimeout(() => {
|
|
711
|
+
notify(`permission ${id} from ${req.peer} was not answered within ${Math.round(timeoutMs / 1000)}s and was cancelled`);
|
|
712
|
+
done(undefined);
|
|
713
|
+
}, timeoutMs);
|
|
601
714
|
const done = (optionId: string | undefined) => {
|
|
602
715
|
clearTimeout(timer);
|
|
603
716
|
permissions.delete(id);
|
|
@@ -691,6 +804,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
691
804
|
cwd: opts.cwd,
|
|
692
805
|
watchdogMs: config.watchdog_ms,
|
|
693
806
|
onPermission,
|
|
807
|
+
// The hub's own tools pass without a console prompt, as Codex's do (approval_mode below; issue #72). This
|
|
808
|
+
// relies on the agent putting the tool name in `title`, as Kimi does; an ACP agent that titles calls with
|
|
809
|
+
// model-written text must not be configured as kimi_cmd. A stopping hub approves nothing.
|
|
810
|
+
autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
|
|
694
811
|
log,
|
|
695
812
|
onTokens: onKimiTokens,
|
|
696
813
|
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
|
|
@@ -713,7 +830,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
713
830
|
["command", '"bun"'],
|
|
714
831
|
["args", JSON.stringify(["run", SERVER_JS])],
|
|
715
832
|
["env", `{${Object.entries(toolEnv("codex")).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ")}}`],
|
|
716
|
-
...
|
|
833
|
+
...HUB_TOOLS.map((name) => [`tools.${name}.approval_mode`, '"approve"']),
|
|
717
834
|
].flatMap(([k, v]) => ["-c", `mcp_servers.agent-hub.${k}=${v}`]),
|
|
718
835
|
preamble: roleContract("codex", config.roles),
|
|
719
836
|
onUsage: (rateLimits, hard) => budget.report("codex", codexWindows(rateLimits), hard),
|
|
@@ -1270,6 +1387,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1270
1387
|
}
|
|
1271
1388
|
async function stopOnce(): Promise<void> {
|
|
1272
1389
|
stopping = true;
|
|
1390
|
+
checksClosed = true;
|
|
1391
|
+
for (const kill of runningChecks) kill();
|
|
1273
1392
|
log("hub stopping");
|
|
1274
1393
|
writeStatus();
|
|
1275
1394
|
dashboard?.stop();
|