@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.
@@ -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 [#12](https://github.com/STAIxBWLB/agent-hub/issues/12)
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": "@staix/agent-hub",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.7.6",
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.6",
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"]),
@@ -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
- if (u.status === "completed" || u.status === "failed") this.toolInputs.delete(u.toolCallId);
189
- while (this.toolInputs.size > TOOL_INPUT_CAP) this.toolInputs.delete(this.toolInputs.keys().next().value as string);
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
- const raw = call.rawInput ?? (typeof call.toolCallId === "string" ? this.toolInputs.get(call.toolCallId) : undefined);
214
- const input = raw === undefined ? "" : `: ${(typeof raw === "string" ? raw : JSON.stringify(raw)).slice(0, 600)}`;
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
- const picked = await this.opts.onPermission?.({ peer: this.id, title, options }).catch(() => undefined);
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
- // ponytail: the reply is addressed to the highest-hop sender only (newEnvelope's default). A digest that
391
- // mixed senders answers the last of them; track the audience alongside `injected` if that starts to matter.
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, originals) } : {}),
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
- return line(await tasks.done(by, a.id, a.summary, a.refs));
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") log(`state ${e.peer} -> ${e.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
- log(`permission ${id} requested by ${req.peer} (${title.length} chars, shown on the console)`);
597
- const push = JSON.stringify({ t: "permission", id, ...req, title });
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(() => done(undefined), opts.permissionTimeoutMs ?? 120_000);
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
- ...["hub_send", ...TASK_TOOLS.map((t) => t.name)].map((name) => [`tools.${name}.approval_mode`, '"approve"']),
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();