@staix/agent-hub 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/bus.md +1 -1
  4. package/docs/agent-notes/daemon.md +2 -2
  5. package/docs/agent-notes/tasks.md +1 -1
  6. package/docs/agent-notes/tests.md +1 -1
  7. package/docs/dogfood.md +3 -1
  8. package/docs/operations.md +45 -0
  9. package/docs/profiles.md +1 -1
  10. package/docs/quickstart.md +5 -1
  11. package/docs/smoke.md +6 -0
  12. package/docs/specs/2026-09-19-agent-hub-design.md +31 -0
  13. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  14. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  15. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  16. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  17. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  18. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  19. package/docs/verified.json +12 -12
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +1 -1
  23. package/src/cli/help.ts +1 -0
  24. package/src/cli/identity.ts +3 -1
  25. package/src/cli/learn-output.ts +38 -0
  26. package/src/cli/main.ts +20 -2
  27. package/src/cli/recovery-waits.ts +26 -0
  28. package/src/cli/smoke-pty-bridge.ts +67 -18
  29. package/src/cli/smoke-pty.ts +10 -2
  30. package/src/cli/upgrade-runtime.ts +16 -14
  31. package/src/cli/upgrade.ts +2 -1
  32. package/src/hub/bus.ts +18 -10
  33. package/src/hub/config-apply.ts +2 -1
  34. package/src/hub/daemon.ts +61 -14
  35. package/src/hub/delivery-journal.ts +78 -57
  36. package/src/hub/learning.ts +33 -1
  37. package/src/hub/operational-settings.ts +79 -1
  38. package/src/hub/settings.ts +21 -1
  39. package/src/hub/tasks.ts +78 -1
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
5
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
6
6
  "paths": [
7
7
  "package.json",
8
8
  "src/",
@@ -11,14 +11,14 @@
11
11
  ]
12
12
  },
13
13
  "docs/security.md": {
14
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
14
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
21
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
22
22
  "paths": [
23
23
  "package.json",
24
24
  "src/",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "docs/quickstart.md": {
38
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
38
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
39
39
  "paths": [
40
40
  "package.json",
41
41
  "src/",
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "docs/agent-notes/adapters.md": {
46
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
46
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -93,7 +93,7 @@
93
93
  ]
94
94
  },
95
95
  "docs/agent-notes/budget.md": {
96
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
96
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
97
97
  "paths": [
98
98
  "src/hub/budget.ts",
99
99
  "src/cli/statusline-tee.ts",
@@ -117,7 +117,7 @@
117
117
  ]
118
118
  },
119
119
  "docs/agent-notes/bus.md": {
120
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
120
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
121
121
  "paths": [
122
122
  "src/hub/bus.ts",
123
123
  "src/hub/envelope.ts",
@@ -132,7 +132,7 @@
132
132
  ]
133
133
  },
134
134
  "docs/agent-notes/daemon.md": {
135
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
135
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
136
136
  "paths": [
137
137
  "src/hub/daemon.ts",
138
138
  "src/hub/control-client.ts",
@@ -225,7 +225,7 @@
225
225
  ]
226
226
  },
227
227
  "docs/agent-notes/tasks.md": {
228
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
228
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
229
229
  "paths": [
230
230
  "src/hub/tasks.ts",
231
231
  "src/hub/board.ts",
@@ -264,7 +264,7 @@
264
264
  ]
265
265
  },
266
266
  "docs/agent-notes/tests.md": {
267
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
267
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
268
268
  "paths": [
269
269
  "test/",
270
270
  "scripts/check.sh",
@@ -292,7 +292,7 @@
292
292
  ]
293
293
  },
294
294
  "docs/agent-notes/README.md": {
295
- "verifiedAgainst": "43867c65caaadc8d7bdc44e034ecf1b933c8df77",
295
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
296
296
  "paths": [
297
297
  "src/cli/console-state.ts",
298
298
  "src/cli/attention.ts",
@@ -301,7 +301,7 @@
301
301
  ]
302
302
  },
303
303
  "src/cli/help.ts": {
304
- "verifiedAgainst": "aeeaeb048860316dd4e08d84a658519ed6c0ad24",
304
+ "verifiedAgainst": "cb942e06d89c6cd9fbe81a3f6d36f3f1d1c14c14",
305
305
  "paths": [
306
306
  "src/cli/main.ts",
307
307
  "src/cli/launch.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
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.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -22536,7 +22536,7 @@ init_security_baseline();
22536
22536
  // package.json
22537
22537
  var package_default = {
22538
22538
  name: "@staix/agent-hub",
22539
- version: "0.21.0",
22539
+ version: "0.22.0",
22540
22540
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
22541
22541
  license: "MIT",
22542
22542
  type: "module",
package/src/cli/help.ts CHANGED
@@ -102,6 +102,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
102
102
  ["ahub source add <path|url> | list [--json] [--color=auto|always|never]", "list is person only, add the person's or the configured conductor's: register an input as a content-hashed source (src:<16 hex of the SHA-256>) under the hub state dir sources/<id>/ (meta.json, raw, text); a URL is fetched once by this command, https only, at most 5 redirects; up to 16 MiB, never truncated; a path under pii.paths or text matching signals.pii_patterns is private, and private is never downgraded"],
103
103
  ["ahub schedule list | run <id> | pause <id> | resume <id> [--json] [--color=auto|always|never]", "list is person only; run, pause and resume are the person's or the configured conductor's: the machine-local schedules (`schedules` in .agenthub/config.local.json); list shows each one's next run, run proposes one now through the same hold, open-task and PII path as the timer and restarts its period, pause stops the timer proposing it without counting a skip, resume starts a fresh period so a pause is never replayed as a run"],
104
104
  ["ahub intake \"<text>\" [--source <id>...] | approve <id> | discard <id> [--json] [--color=auto|always|never]", "person only, except approve (person or configured conductor): turn a plain request into a draft backlog card that nothing offers or pulls until you approve it; the configured planner is asked to split it with hub_backlog_split, or the hub model proposes the split (a PII request only on an on-prem model, never to a planner); each --source must be a registered src: id; approve clears the draft on it and its parts, discard cancels them through task cancel"],
105
+ ["ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]", "person only, never a conductor or an agent shell: review a recurring-unmet-criterion candidate the hub filed (#514a); accept writes its one review checklist line, added to your current checklist, to .agenthub/config.local.json through the settings writer (this machine only, never a tracked file, ahub settings undo puts it back, applies after a restart) and closes the card; reject only closes it"],
105
106
  ["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "what each task delivered and a reviewer approved, by id, hash and size (a path only where the PII screen allows); --check measures the files now and marks one changed since review; an agent sees only the tasks hub_task_show shows it"],
106
107
  ["ahub task show <id> [--json] [--full] [--color=auto|always|never] | escalate <id>", "full task with history (the person sees PII text, an agent the public view) / hand it to the next peer in escalate_to (person or configured conductor)"],
107
108
  ["ahub task role <id> <role|none>", "the person, or an agent on a task it owns: change bounded catalog guidance on an open task; routing and authority are unchanged"],
@@ -186,9 +186,11 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
186
186
  if (command === "schedule") return ["list", "run", "pause", "resume"].includes(args.find(arg => arg !== "--json") ?? "") ? `schedule ${args.find(arg => arg !== "--json")}` : "schedule";
187
187
  // #334: intake names its two subcommands in the audit and a refusal, never the request text (it can carry a secret).
188
188
  if (command === "intake") return ["approve", "discard"].includes(args.find(arg => arg !== "--json") ?? "") ? `intake ${args.find(arg => arg !== "--json")}` : "intake";
189
+ // #514b: the audit and a refusal name the subcommand only, never the card id or any text.
190
+ if (command === "learn") return ["list", "accept", "reject"].includes(args.find(arg => arg !== "--json") ?? "") ? `learn ${args.find(arg => arg !== "--json")}` : "learn";
189
191
  if (command === "source") return ["add", "list"].includes(args.find(arg => arg !== "--json") ?? "") ? `source ${args.find(arg => arg !== "--json")}` : "source";
190
192
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
191
- if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake"].includes(command)) return "unknown";
193
+ if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn"].includes(command)) return "unknown";
192
194
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
193
195
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
194
196
  if (sub && ["propose", "show", "assign", "unassign", "escalate", "cancel", "claim", "label", "list", "add", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "discard", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take", "trust"].includes(sub)) return `${command} ${sub}`;
@@ -0,0 +1,38 @@
1
+ // Human output for `ahub learn` (#514b). Pure: parsed hub-tool JSON in, spans out, no I/O. `--json` is the caller's branch and prints the same text.
2
+ // Ids, counts, the key parts and the one line the hub itself generates are all it shows: the card's own text is never echoed (a card is data to the parser, not to this printer).
3
+ import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
4
+ import type { Span } from "./console-state.ts";
5
+
6
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
7
+ const dash = (value: unknown) => value === undefined || value === null || value === "" ? "-" : String(value);
8
+
9
+ /** `ahub learn list [--all]`: one row per candidate, and what each command would do. */
10
+ export function renderLearnList(result: unknown, columns?: number, all = false): Span[][] {
11
+ const rows = list(result);
12
+ const open = rows.filter(row => row.status === "open").length;
13
+ const out: Span[][] = [...heading(all ? `Review checklist candidates: ${open} open, ${rows.length - open} decided` : `Review checklist candidates: ${open} open`, columns)];
14
+ if (!rows.length) {
15
+ out.push(blank, [s(all ? "no candidate has been filed" : "no open candidate; pass --all to include decided ones", "muted")]);
16
+ return out;
17
+ }
18
+ out.push(...grid(["ID", "STATUS", "PROFILE/CLASS", "CRITERION", "TASKS", "LINE TO ADD"], rows.map((row: any) => [
19
+ s(`#${row.id}`, "taskRef"), s(dash(row.status), row.status === "open" ? "attention" : "muted"), s(`${dash(row.profile)}/${dash(row.class)}`), s(dash(row.criterion)), s(String(list(row.tasks).length)), s(dash(row.line)),
20
+ ]), columns));
21
+ if (open) out.push(blank, [s("accept writes the line to .agenthub/config.local.json (ahub settings undo puts it back); reject closes the card. Nothing else changes.", "muted")]);
22
+ return out;
23
+ }
24
+
25
+ /** `ahub learn accept|reject <id>`: what was decided, and for accept exactly what was written and where. */
26
+ export function renderLearnChange(op: "accept" | "reject", result: unknown, columns?: number): Span[][] {
27
+ const r = (result ?? {}) as any;
28
+ const card = op === "accept" ? r.card : r;
29
+ const out: Span[][] = [...heading(op === "accept" ? `Candidate #${dash(card?.id)} accepted` : `Candidate #${dash(card?.id)} rejected and closed`, columns)];
30
+ if (op === "reject") return [...out, ...pair("written", "nothing", columns, 8, "muted")];
31
+ const a = r.accepted ?? {};
32
+ if (a.alreadyAccepted) return [...heading(`Candidate #${dash(card?.id)} was already accepted`, columns), ...pair("profile", `${dash(a.profile)}/${dash(a.class)}`, columns, 8), ...pair("criterion", a.criterion, columns, 8), ...pair("written", "nothing: it was accepted before (ahub settings undo still puts back that write if it is the latest)", columns, 8, "muted")];
33
+ out.push(...pair("profile", `${dash(a.profile)}/${dash(a.class)}`, columns, 8), ...pair("criterion", a.criterion, columns, 8), ...pair("line", a.line, columns, 8),
34
+ ...pair("file", `${dash(a.file)} (this machine only; the shared config is untouched)`, columns, 8), ...pair("now", a.alreadyPresent ? `the checklist already had this line: nothing was written` : `${dash(a.lines)} lines in the checklist`, columns, 8),
35
+ ...pair("applies", dash(a.applies), columns, 8, "attention"));
36
+ if (a.undo) out.push(blank, [s(`put it back with ${a.undo}`, "muted")]);
37
+ return out;
38
+ }
package/src/cli/main.ts CHANGED
@@ -26,6 +26,7 @@ import { fetchSourceUrl, MAX_SOURCE_BYTES, publicSourceView, screenRegistration,
26
26
  import { renderSourceAdd, renderSourceList } from "./source-output.ts";
27
27
  import { renderScheduleChange, renderScheduleList, renderScheduleRun } from "./schedule-output.ts";
28
28
  import { renderIntake, renderIntakeChange } from "./intake-output.ts";
29
+ import { renderLearnChange, renderLearnList } from "./learn-output.ts";
29
30
  import { terminalText } from "./console-state.ts";
30
31
  import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
31
32
  import { petView, readPet, renderPet } from "./pet.ts";
@@ -38,7 +39,7 @@ import { verifiedJcodeCommand } from "../adapters/jcode.ts";
38
39
  import { configureJcodeMcp, jcodeExtraMcpServers } from "./jcode-mcp.ts";
39
40
  import { qwenSessionId } from "../hub/crash.ts";
40
41
  import { isPermissionMode } from "../hub/permission-mode.ts";
41
- import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
42
+ import { parseSettingText, settingDef, settingText, undoableDef, type SettingRow } from "../hub/settings.ts";
42
43
  import { startModeOf } from "../hub/start-mode.ts";
43
44
  import { currentRouting, OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
44
45
  import { spawn, spawnSync } from "node:child_process";
@@ -1450,6 +1451,23 @@ const commands: Record<string, () => Promise<void> | void> = {
1450
1451
  return showToolText(data => renderScheduleChange(sub as "pause" | "resume", data, present.columns, Date.now()), await taskOp(sub === "pause" ? "schedule_pause" : "schedule_resume", { id }), present);
1451
1452
  },
1452
1453
 
1454
+ learn: async () => {
1455
+ // #514b: the person's verdict on a recurring-criterion candidate (#514a). Person only, end to end: `learn` is a `console` command for an agent shell (classifyPeerCommand has no entry for it, so the
1456
+ // identity layer refuses it before this runs), and the daemon refuses any caller but the person again. `accept` writes ONE key (profile_overrides.review_checklist) to the machine-local
1457
+ // config through the settings writer; nothing in a card's text is written.
1458
+ const split = splitPresentation(args);
1459
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1460
+ const usage = "usage: ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]";
1461
+ const [sub, ...rest] = split.operands;
1462
+ if (sub === "list" && rest.every(arg => arg === "--all")) {
1463
+ const all = rest.includes("--all");
1464
+ return showToolText(data => renderLearnList(data, present.columns, all), await taskOp("learn_list", { ...(all ? { all: true } : {}) }), present);
1465
+ }
1466
+ const [id, ...extra] = rest;
1467
+ if ((sub !== "accept" && sub !== "reject") || !id || extra.length || !/^[1-9]\d*$/.test(id)) return fail(usage);
1468
+ return showToolText(data => renderLearnChange(sub, data, present.columns), await taskOp(sub === "accept" ? "learn_accept" : "learn_reject", { id: Number(id) }), present);
1469
+ },
1470
+
1453
1471
  intake: async () => {
1454
1472
  // #334 (#511a): natural-language intake. Person only, except approve (#933: the configured conductor too, admitted and audited by the daemon).
1455
1473
  // The request is a draft backlog card that nothing offers until `approve`; the hub, not this process, splits it.
@@ -1952,7 +1970,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1952
1970
  return console.log(paint([{ text: result.text }], false));
1953
1971
  }
1954
1972
  if (sub === "undo") {
1955
- const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo, settingScope.instances, settingScope.catalog);
1973
+ const view = await request({ t: "settings_get" }), undoDef = undoableDef(view.undo, settingScope.instances, settingScope.catalog);
1956
1974
  // A roster undo of a declared peer needs its confirmation prompt; without readable declarations, refuse rather than skip it.
1957
1975
  if (typeof undoDef === "string" && settingScopeError) fail(`pending undo ${String(view.undo)}: ${undoDef}; the project config could not be read: ${settingScopeError}`);
1958
1976
  const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
@@ -0,0 +1,26 @@
1
+ import { configRefusal } from "../hub/config-trust.ts";
2
+ import { readInstanceConfigLayers } from "../hub/instance-config.ts";
3
+ import { recoveryWaits, type RecoveryOperationalConfig, type RecoveryWaits } from "../hub/operational-settings.ts";
4
+
5
+ /**
6
+ * #489 H4. The upgrade coordinator is a CLI process, not the daemon, so it reads the five recovery waits itself, from the project it is acting on, at the START of each wait (a wait already running keeps
7
+ * its deadline, and the receipt's `waits` records it). Both layers count, the machine-local file last, as `loadConfig` merges them; a layer git tracks as local policy is refused like every other local
8
+ * policy (`configRefusal`) and a file that cannot be read or parsed is the default. A recovery never fails on its configuration: bounds were checked when the value was written, and `recoveryWaits` falls
9
+ * back to the default for a value outside them.
10
+ */
11
+ const LEAVES = new Set(["reconnect_wait_s", "unmanaged_reconnect_wait_s", "control_timeout_s", "shutdown_wait_s", "terminal_idle_wait_s"]);
12
+ export function readRecoveryWaits(projectRoot: string | undefined, read: (cwd: string) => ReturnType<typeof readInstanceConfigLayers> = readInstanceConfigLayers): RecoveryWaits {
13
+ if (!projectRoot) return recoveryWaits(() => undefined);
14
+ let merged: RecoveryOperationalConfig["recovery"];
15
+ try {
16
+ for (const layer of read(projectRoot)) {
17
+ const recovery = layer.raw.recovery;
18
+ if (!recovery || typeof recovery !== "object" || Array.isArray(recovery) || !Object.keys(recovery).some(key => LEAVES.has(key))) continue;
19
+ // The shared file is team policy and always read; the machine-local file only when git does not track it. Git is asked only when that file actually sets one of the five
20
+ // (a recovery polls and sends many control requests, and a file that sets none must cost no subprocess).
21
+ if (layer.name === "config.local.json" && configRefusal(projectRoot, layer.name)) continue;
22
+ merged = { ...merged, ...(recovery as Record<string, unknown>) };
23
+ }
24
+ } catch { return recoveryWaits(() => undefined); }
25
+ return recoveryWaits(() => ({ recovery: merged }));
26
+ }
@@ -1,33 +1,82 @@
1
1
  // macOS script(1) requires a terminal on its own stdin, including when its child gets a PTY.
2
2
  // Keep a real PTY in this owned bridge instead; the outer Node child remains tracked by stopOwnedProcess.
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ /**
8
+ * #560: Bun up to 1.4.3 can hang for good inside `Bun.spawn({ terminal })` on macOS when the child writes and starts exiting before the call returns (oven-sh/bun#45300, fixed in 1.4.4): the
9
+ * event loop thread blocks in wait4 while the exiting session leader waits for that same thread to drain its pty. Measured here with a bare Bun script and no agent-hub code: 26 of 96 runs hung
10
+ * at 12 in parallel on 1.4.3 and 0 of 180 on 1.4.4. The child therefore starts behind a gate: a shell that waits for a file the bridge writes right after `Bun.spawn` has returned, then `exec`s the
11
+ * real command (same pid, same session, same terminal). The wait has no bound of its own: a bridge that dies before it opens the gate closes the pty, which hangs the shell up (the trap below), and a bridge
12
+ * that is alive but never opens it is a hung bridge, which `runPty` stops with its group.
13
+ * ponytail: ceiling and upgrade path. The gate exists only because of oven-sh/bun#45300 on Bun up to 1.4.3 (this repo pins 1.4.2 in `.bun-version`); the upgrade path is to delete GATE_SCRIPT, `gate` and `gateDir` once the pinned Bun is 1.4.4 or newer, which is its own task because it
14
+ * touches CI and every OS leg. The parent-death stop below stays whatever the Bun version: an orphaned bridge is this repository's bug, not Bun's. The gate costs about one shell start (a few ms).
15
+ */
16
+ // The shell removes its own gate (file, then directory) once released and when it is hung up or terminated (a bridge killed with SIGKILL runs no cleanup, and the pty closing
17
+ // sends the shell SIGHUP), so no path leaves a directory behind. `exec` resets the traps for the real command.
18
+ const GATE_SCRIPT = 'd=${0%/*}; trap "rm -f \\"$0\\"; rmdir \\"$d\\" 2>/dev/null; exit 125" HUP TERM INT; while [ ! -e "$0" ]; do sleep 0.01; done; rm -f "$0"; rmdir "$d" 2>/dev/null; exec "$@"';
19
+ /** A bridge whose caller died is stopped instead of running on: its child holds a terminal nobody reads and, under launchd, nothing else would ever stop it. */
20
+ const PARENT_POLL_MS = 250;
21
+ const PARENT_TERM_GRACE_MS = 1_000;
22
+ const PARENT_HARD_EXIT_MS = 3_000;
23
+
24
+ // The caller is whoever started this bridge, read before anything that can take time: a caller that dies while `Bun.spawn` runs (the window #45300 widens) has already reparented this process by the
25
+ // time the call returns, and a parent read after it would be launchd and never change.
26
+ const parent = process.ppid;
27
+ const callerGone = (): boolean => process.ppid !== parent;
3
28
  const argv = process.argv.slice(2);
4
29
  if (!argv.length) {
5
30
  process.exitCode = 125;
6
31
  } else {
7
32
  let streamClosed!: () => void;
8
33
  const eof = new Promise<void>(resolve => { streamClosed = resolve; });
9
- const child = Bun.spawn(argv, {
10
- cwd: process.cwd(), env: process.env,
11
- terminal: {
12
- cols: 120, rows: 40, name: "xterm-256color",
13
- data: (_terminal, data) => { process.stdout.write(data); },
14
- exit: () => streamClosed(),
15
- },
16
- });
17
- const stop = () => { try { child.kill("SIGTERM"); } catch { /* exit readback decides */ } };
18
- process.on("SIGINT", stop); process.on("SIGTERM", stop);
34
+ const gateDir = process.platform === "darwin" ? mkdtempSync(join(tmpdir(), "ahub-pty-gate-")) : undefined;
35
+ const gate = gateDir ? join(gateDir, "open") : undefined;
36
+ let watch: ReturnType<typeof setInterval> | undefined;
19
37
  try {
20
- const code = await child.exited;
21
- // Drain the PTY before closing it; a descendant holding a slave cannot make the bridge wait forever.
22
- let timer: ReturnType<typeof setTimeout> | undefined;
23
- await Promise.race([eof, new Promise<void>(resolve => { timer = setTimeout(resolve, 250); })]);
24
- if (timer) clearTimeout(timer);
25
- process.exitCode = code;
38
+ const child = Bun.spawn(gate ? ["/bin/sh", "-c", GATE_SCRIPT, gate, ...argv] : argv, {
39
+ cwd: process.cwd(), env: process.env,
40
+ terminal: {
41
+ cols: 120, rows: 40, name: "xterm-256color",
42
+ data: (_terminal, data) => { process.stdout.write(data); },
43
+ exit: () => streamClosed(),
44
+ },
45
+ });
46
+ const stop = () => { try { child.kill("SIGTERM"); } catch { /* exit readback decides */ } };
47
+ process.on("SIGINT", stop); process.on("SIGTERM", stop);
48
+ /** The caller is gone: stop the child now, kill it after a grace period, and exit even if its exit is never reported. */
49
+ const abandon = () => {
50
+ clearInterval(watch);
51
+ stop();
52
+ setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } }, PARENT_TERM_GRACE_MS).unref();
53
+ setTimeout(() => process.exit(129), PARENT_HARD_EXIT_MS).unref();
54
+ };
55
+ // A caller that died while Bun.spawn ran never gets its command run: the gate stays shut and the gate shell takes the SIGTERM (its trap removes the gate and exits).
56
+ // Abandoning once is enough: the interval below exists only while the caller is still there, and clears itself the first time it is not.
57
+ if (callerGone()) abandon();
58
+ else {
59
+ if (gate) writeFileSync(gate, "");
60
+ watch = setInterval(() => { if (callerGone()) abandon(); }, PARENT_POLL_MS);
61
+ }
62
+ try {
63
+ const code = await child.exited;
64
+ // Drain the PTY before closing it; a descendant holding a slave cannot make the bridge wait forever.
65
+ let timer: ReturnType<typeof setTimeout> | undefined;
66
+ await Promise.race([eof, new Promise<void>(resolve => { timer = setTimeout(resolve, 250); })]);
67
+ if (timer) clearTimeout(timer);
68
+ process.exitCode = code;
69
+ } catch {
70
+ process.exitCode = 125;
71
+ } finally {
72
+ child.terminal?.close();
73
+ process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop);
74
+ }
26
75
  } catch {
27
76
  process.exitCode = 125;
28
77
  } finally {
29
- child.terminal?.close();
30
- process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop);
78
+ if (watch) clearInterval(watch);
79
+ if (gateDir) rmSync(gateDir, { recursive: true, force: true });
31
80
  }
32
81
  }
33
82
  export {};
@@ -54,14 +54,22 @@ function waitForExit(proc: ChildProcess): Promise<{ code: number | null }> {
54
54
  }
55
55
 
56
56
  /** Run an argv vector inside a fixed-size PTY, always stopping its owned process group before returning. */
57
- export async function runPty(argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
57
+ export function runPty(argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
58
+ return runPtyWith(commandFor, argv, options);
59
+ }
60
+
61
+ /**
62
+ * `runPty` with the launcher named by the caller. Only code can reach it (a test names a bridge that ignores SIGTERM or never exits); no config key, request or environment variable does, so the
63
+ * bridge a hub runs is always `commandFor`'s.
64
+ */
65
+ export async function runPtyWith(launcher: (argv: readonly string[]) => string[], argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
58
66
  validate(argv, options);
59
67
  const started = Date.now();
60
68
  if (options.signal?.aborted) return { outcome: "aborted", exitCode: null, outputBytes: 0, elapsedMs: Date.now() - started, cleaned: true };
61
69
 
62
70
  let proc: ChildProcess;
63
71
  try {
64
- const command = commandFor(argv);
72
+ const command = launcher(argv);
65
73
  proc = spawn(command[0]!, command.slice(1), {
66
74
  cwd: options.cwd,
67
75
  env: { ...options.env, TERM: "xterm-256color" },
@@ -3,6 +3,7 @@ import { resolveAcceptedBinding } from "./recovery-guidance.ts";
3
3
  import { claudeStateFile } from "../hub/claude-state.ts";
4
4
  import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
5
5
  import { readInstanceConfig, readInstanceConfigLayers } from "../hub/instance-config.ts";
6
+ import { readRecoveryWaits } from "./recovery-waits.ts";
6
7
  import { readAdapterModuleDeclarations } from "../adapters/external-modules.ts";
7
8
  import { configRefusal } from "../hub/config-trust.ts";
8
9
  import { validExternalInstanceBinding } from "../hub/external-instance.ts";
@@ -26,10 +27,9 @@ import { decodeRestoreReceipt, restoreTransition, type RestoreReceipt, type Rest
26
27
  import { RecoveryDeferredHubError } from "../hub/deferred-hub.ts";
27
28
  import { RecoveryPreservationError, RecoveryPresentationError, type RecoveryNativeCause } from "./upgrade-presentation-data.ts";
28
29
 
29
- /** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
30
- const UNMANAGED_RECONNECT_WAIT_MS = 90_000;
31
- /** Managed TUI startup can include resume and MCP handshakes; match createTerminal's ten-minute readiness ceiling. */
32
- const RECONNECT_WAIT_MS = 600_000;
30
+ // #489 H4: the reconnect, unmanaged reconnect, control request, terminal idle and shutdown waits are settings now (`recovery.*`, src/hub/operational-settings.ts), read by `readRecoveryWaits` at the start of
31
+ // each wait. Their defaults are the constants that stood here: an unmanaged Claude's plugin retries with a backoff of at most 30 s and three of those bound its reconnect wait (#206, 90 s); managed TUI startup
32
+ // can include resume and MCP handshakes, so its wait matches createTerminal's ten-minute readiness ceiling (600 s, which is also the setting's floor).
33
33
  class AttachmentLauncherGone extends Error {
34
34
  constructor(readonly transition: RestoreTransition) { super("chosen attachment launcher exited"); }
35
35
  }
@@ -80,12 +80,12 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
80
80
  return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
81
81
  }
82
82
 
83
- const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
84
83
 
85
84
  async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL, conductorPeer?: string): Promise<any> {
86
- const client = await ControlClient.connect(project.stateDir, { ...(conductorPeer ? { role: "tools" as const, peer: conductorPeer } : { role: "console" as const }), projectRoot: project.root, projectId: project.id }, RECOVERY_CONTROL_TIMEOUT_MS, protocol);
85
+ const controlTimeoutMs = readRecoveryWaits(project.root).controlTimeoutMs;
86
+ const client = await ControlClient.connect(project.stateDir, { ...(conductorPeer ? { role: "tools" as const, peer: conductorPeer } : { role: "console" as const }), projectRoot: project.root, projectId: project.id }, controlTimeoutMs, protocol);
87
87
  try {
88
- const reply = await client.request(message, RECOVERY_CONTROL_TIMEOUT_MS);
88
+ const reply = await client.request(message, controlTimeoutMs);
89
89
  if (reply.ok === false) {
90
90
  // An older hub has only its exact SQLite message; preserve compatibility without trusting arbitrary text.
91
91
  if (isDatabaseBusyReply(reply)) throw new RecoveryDatabaseBusy();
@@ -215,7 +215,7 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
215
215
  // pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
216
216
  // caller reads that as not inspected, never as stopped.
217
217
  const claim = registeredProjects().find((p) => p.id === project.id);
218
- const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, RECOVERY_CONTROL_TIMEOUT_MS, conductorPeer);
218
+ const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, readRecoveryWaits(project.root).controlTimeoutMs, conductorPeer);
219
219
  if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
220
220
  const control = readControl(project.stateDir);
221
221
  const sourceProtocol = control?.protocol;
@@ -469,7 +469,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
469
469
  if (found.manualRequired || !current || (exact && (current.handle !== saved.handle || current.incarnationId !== saved.incarnationId || current.worktreeId !== saved.worktreeId || current.projectRoot !== saved.projectRoot))) {
470
470
  throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: saved terminal identity changed or is not verified; manual-required`, { handle: saved.handle });
471
471
  }
472
- const idle = await waitForIdle(exact ? saved : current, 120_000, options);
472
+ const idle = await waitForIdle(exact ? saved : current, readRecoveryWaits(planned.project.root).terminalIdleWaitMs, options);
473
473
  if (!idle.satisfied) throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: terminal is not idle; manual-required`, { handle: saved.handle });
474
474
  const observed = await inspectRecovery(planned.project);
475
475
  if (observed.instanceId !== progress.instanceId) throw nativeFailure(planned, saved.peer, "target_unavailable", `${saved.peer}: daemon instance changed during terminal revalidation`, { handle: saved.handle, targetState: observed.state });
@@ -567,7 +567,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
567
567
  if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
568
568
  progress.terminals[key] = true; save(); return;
569
569
  }
570
- const idle = await waitForIdle(retired, 120_000, terminalOptions(run));
570
+ const idle = await waitForIdle(retired, readRecoveryWaits(retired.projectRoot).terminalIdleWaitMs, terminalOptions(run));
571
571
  if (!idle.satisfied) throw new Error(`${peer}: terminal ${retired.handle} of the stopped target's session is not verified idle; finish or cancel its turn, or close that terminal by hand, first`);
572
572
  progress.terminals[key] = "pending"; save();
573
573
  await recoveryCurrent();
@@ -583,7 +583,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
583
583
  };
584
584
  const binding = waiting.binding;
585
585
  const matchesTerminal = (terminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId">) => terminal.handle === binding.handle && terminal.incarnationId === binding.incarnationId && terminal.worktreeId === binding.worktreeId;
586
- const startedAt = now(), deadline = startedAt + RECONNECT_WAIT_MS;
586
+ const reconnectWaitMs = readRecoveryWaits(planned.project.root).reconnectWaitMs; // read once, at the start: a wait already running keeps the deadline the receipt records
587
+ const startedAt = now(), deadline = startedAt + reconnectWaitMs;
587
588
  const waitStep = op.step;
588
589
  if (waitStep) {
589
590
  (op.waits ??= {})[waitStep] = { waitStartedAt: new Date(startedAt).toISOString(), deadlineAt: new Date(deadline).toISOString() };
@@ -656,7 +657,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
656
657
  hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
657
658
  lastHubConnectAttempt: "unknown" };
658
659
  save();
659
- throw nativeFailure(planned, binding.peer, "attachment_timeout", `${binding.peer}: attachment not verified within ${RECONNECT_WAIT_MS / 1000} s; terminal ${binding.handle} retained; resume after it attaches (native-start diagnostic recorded; last hub connect attempt unknown)`, { handle: binding.handle, timeoutMs: RECONNECT_WAIT_MS });
660
+ throw nativeFailure(planned, binding.peer, "attachment_timeout", `${binding.peer}: attachment not verified within ${reconnectWaitMs / 1000} s; terminal ${binding.handle} retained; resume after it attaches (native-start diagnostic recorded; last hub connect attempt unknown)`, { handle: binding.handle, timeoutMs: reconnectWaitMs });
660
661
  } catch (error) { waitFailed = true; throw error; }
661
662
  finally {
662
663
  const finish = () => {
@@ -1030,9 +1031,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
1030
1031
  }
1031
1032
  if (group !== "native" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
1032
1033
  // #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
1033
- const deadline = now() + UNMANAGED_RECONNECT_WAIT_MS;
1034
+ const unmanagedWaitMs = readRecoveryWaits(planned.project.root).unmanagedReconnectWaitMs;
1035
+ const deadline = now() + unmanagedWaitMs;
1034
1036
  while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
1035
- if (now() >= deadline) throw nativeFailure(planned, id, "attachment_timeout", `${id}: the unmanaged session did not reconnect within ${UNMANAGED_RECONNECT_WAIT_MS / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`, { timeoutMs: UNMANAGED_RECONNECT_WAIT_MS });
1037
+ if (now() >= deadline) throw nativeFailure(planned, id, "attachment_timeout", `${id}: the unmanaged session did not reconnect within ${unmanagedWaitMs / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`, { timeoutMs: unmanagedWaitMs });
1036
1038
  await sleep(1000);
1037
1039
  }
1038
1040
  }
@@ -12,6 +12,7 @@ import { existsSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
14
14
  import { hubHome } from "../hub/project.ts";
15
+ import { readRecoveryWaits } from "./recovery-waits.ts";
15
16
  import { acquireRecoveryLock, claimRunner, coordinatorCurrent, operationIdPattern, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
16
17
  import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type RecoveryNativePeer } from "./recovery-guidance.ts";
17
18
  import type { TerminalBinding } from "./terminal-recovery.ts";
@@ -802,7 +803,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
802
803
  throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
803
804
  }
804
805
  if (awaitShutdown) {
805
- const deadline = driver.now() + 30_000;
806
+ const deadline = driver.now() + readRecoveryWaits(project.root).shutdownWaitMs; // #489 H4: recovery.shutdown_wait_s, 30 s unset
806
807
  // An already-sent commit can be between its final reply and the source closing its control server.
807
808
  if (resumedShutdown) {
808
809
  if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);