@staix/agent-hub 0.12.19 → 0.12.21

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 (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
@@ -0,0 +1,211 @@
1
+ import { createHash } from "node:crypto";
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { hubHome } from "./project.ts";
5
+ import { processLiveness } from "../pi/process-signature.ts";
6
+
7
+ /**
8
+ * #251: benchmark suites, runs and their comparison. A run's records hold ids, outcomes, exit codes, counts, tokens
9
+ * and times, never the suite's text, a command's output or anything an agent wrote.
10
+ */
11
+ export const BENCH_SCHEMA = "agent-hub.bench/v1";
12
+
13
+ export interface SuiteTask { id: string; title: string; detail?: string; class?: string; owner?: string; ref: string; setup?: string; verify: string; timeout_s: number }
14
+ export interface Suite { name: string; tasks: SuiteTask[] }
15
+
16
+ const TASK_FIELDS = new Set(["id", "title", "detail", "class", "owner", "ref", "setup", "verify", "timeout_s"]);
17
+ const CLASSES = new Set(["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"]);
18
+ /** A suite file, checked field by field; every refusal names the task and the field. */
19
+ export function parseSuite(text: string): Suite {
20
+ let raw: unknown;
21
+ try { raw = JSON.parse(text); } catch { throw new Error("suite: not valid JSON"); }
22
+ const suite = raw as Record<string, unknown>;
23
+ if (!suite || typeof suite !== "object" || Array.isArray(suite)) throw new Error("suite: expected an object with name and tasks");
24
+ for (const key of Object.keys(suite)) if (key !== "name" && key !== "tasks") throw new Error(`suite: unknown field ${key}`);
25
+ if (typeof suite.name !== "string" || !suite.name.trim()) throw new Error("suite: name is required");
26
+ if (!Array.isArray(suite.tasks) || !suite.tasks.length) throw new Error("suite: tasks must be a non-empty list");
27
+ const ids = new Set<string>();
28
+ const tasks = suite.tasks.map((item, i): SuiteTask => {
29
+ const t = item as Record<string, unknown>;
30
+ const where = `suite task ${typeof t?.id === "string" ? t.id : `#${i + 1}`}`;
31
+ if (!t || typeof t !== "object" || Array.isArray(t)) throw new Error(`${where}: expected an object`);
32
+ for (const key of Object.keys(t)) if (!TASK_FIELDS.has(key)) throw new Error(`${where}: unknown field ${key}`);
33
+ for (const key of ["id", "title", "ref", "verify"]) if (typeof t[key] !== "string" || !(t[key] as string).trim()) throw new Error(`${where}: ${key} is required`);
34
+ for (const key of ["detail", "class", "owner", "setup"]) if (t[key] !== undefined && typeof t[key] !== "string") throw new Error(`${where}: ${key} must be text`);
35
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(t.id as string)) throw new Error(`${where}: id may hold letters, digits, dot, dash and underscore`);
36
+ if (ids.has(t.id as string)) throw new Error(`${where}: duplicate id`);
37
+ ids.add(t.id as string);
38
+ if (t.class !== undefined && !CLASSES.has(t.class as string)) throw new Error(`${where}: class must be one of ${[...CLASSES].join(", ")}`);
39
+ if (typeof t.timeout_s !== "number" || !Number.isFinite(t.timeout_s) || t.timeout_s < 10 || t.timeout_s > 86_400) throw new Error(`${where}: timeout_s must be a number of seconds from 10 to 86400`);
40
+ return t as unknown as SuiteTask;
41
+ });
42
+ return { name: suite.name, tasks };
43
+ }
44
+ export const suiteHash = (text: string): string => createHash("sha256").update(text).digest("hex");
45
+
46
+ export interface Fingerprint { peers: { id: string; model?: string; permissionMode?: string }[]; routingHash: string | null; hubVersion: string }
47
+ export interface RunHeader {
48
+ schema: typeof BENCH_SCHEMA; kind: "run"; run: string; suite: string; suiteHash: string; arm: string; fingerprint: Fingerprint;
49
+ version: string; startedAt: string; tasks: number; repeats: number; runner: { pid: number; signature: string | null };
50
+ /** Task ids in run order, so a running run can name the attempt in progress. */
51
+ order: string[];
52
+ }
53
+ export type Outcome = "pass" | "fail" | "timeout" | "error";
54
+ /** Closed list: a reason never quotes a command or its output. */
55
+ export type AttemptError = "setup failed" | "setup timeout" | "propose refused" | "hub stopped" | "verify timeout" | "reset failed" | "interrupted";
56
+ export interface AttemptMetrics {
57
+ tokens: number; wallMs: number | null; activeMs: number; reviewRounds: number; changesRequested: number; checkFailed: number;
58
+ firstPass: boolean; turns: number; filesChanged: number; models: string[];
59
+ }
60
+ export interface Attempt {
61
+ schema: typeof BENCH_SCHEMA; kind: "attempt"; run: string; task: string; repeat: number; outcome: Outcome; error?: AttemptError;
62
+ verifyExit: number | null; hubTask: number | null; startedAt: string; endedAt: string; ms: number; metrics: AttemptMetrics | null;
63
+ }
64
+ export interface RunEnd { schema: typeof BENCH_SCHEMA; kind: "end"; run: string; endedAt: string; stopped?: "timeout" | "interrupted" | "error" }
65
+ export type BenchRecord = RunHeader | Attempt | RunEnd;
66
+ export interface BenchRun { header: RunHeader; attempts: Attempt[]; end?: RunEnd; state: "running" | "finished" | "stopped" | "interrupted" | "unknown" }
67
+
68
+ export const benchDir = (home = hubHome()): string => join(home, "bench");
69
+ export const runFile = (run: string, home = hubHome()): string => join(benchDir(home), `${run}.jsonl`);
70
+ export function appendBench(record: BenchRecord, home = hubHome()): void {
71
+ mkdirSync(benchDir(home), { recursive: true, mode: 0o700 });
72
+ chmodSync(benchDir(home), 0o700); // mkdir's mode applies only when it creates the directory
73
+ const file = runFile(record.run, home);
74
+ appendFileSync(file, JSON.stringify(record) + "\n", { mode: 0o600 });
75
+ chmodSync(file, 0o600);
76
+ }
77
+
78
+ /** Every run on this machine, newest first. A run with no end record is running while its runner lives. */
79
+ export function readRuns(home = hubHome()): BenchRun[] {
80
+ const dir = benchDir(home);
81
+ if (!existsSync(dir)) return [];
82
+ const runs: BenchRun[] = [];
83
+ for (const name of readdirSync(dir).filter((n) => n.endsWith(".jsonl"))) try {
84
+ let header: RunHeader | undefined, end: RunEnd | undefined;
85
+ const attempts: Attempt[] = [];
86
+ for (const line of readFileSync(join(dir, name), "utf8").split("\n")) {
87
+ if (!line.trim()) continue;
88
+ let r: BenchRecord;
89
+ try { r = JSON.parse(line); } catch { continue; }
90
+ if (r?.schema !== BENCH_SCHEMA) continue;
91
+ if (r.kind === "run") header = r; else if (r.kind === "attempt") attempts.push(r); else if (r.kind === "end") end = r;
92
+ }
93
+ if (!header || typeof header.startedAt !== "string" || !header.runner) continue;
94
+ const owner = end ? undefined : processLiveness(header.runner.pid, header.runner.signature);
95
+ const state = end ? (end.stopped ? "stopped" : "finished") : owner === "live" ? "running" : owner === "gone" ? "interrupted" : "unknown";
96
+ runs.push({ header, attempts, ...(end ? { end } : {}), state });
97
+ } catch { /* a file that cannot be read or has no valid header is not a run; the others still are */ }
98
+ return runs.sort((a, b) => b.header.startedAt.localeCompare(a.header.startedAt));
99
+ }
100
+
101
+ const median = (values: number[]): number | null => {
102
+ if (!values.length) return null;
103
+ const s = [...values].sort((a, b) => a - b), m = Math.floor(s.length / 2);
104
+ return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
105
+ };
106
+ export interface Measures {
107
+ attempts: number; pass: number; fail: number; timeout: number; error: number;
108
+ /** What the measures are computed over: attempts that did not end in an error; attempts that have #247 measures. */
109
+ scored: number; measured: number;
110
+ passRate: number | null; firstPassRate: number | null; tokensMedian: number | null; wallMsMedian: number | null; reworkMean: number | null;
111
+ }
112
+ export function measures(attempts: Attempt[]): Measures {
113
+ const count = (o: Outcome) => attempts.filter((a) => a.outcome === o).length;
114
+ const scored = attempts.filter((a) => a.outcome !== "error");
115
+ const withMetrics = attempts.flatMap((a) => a.metrics ? [a.metrics] : []);
116
+ return { attempts: attempts.length, pass: count("pass"), fail: count("fail"), timeout: count("timeout"), error: count("error"), scored: scored.length, measured: withMetrics.length,
117
+ passRate: scored.length ? count("pass") / scored.length : null,
118
+ firstPassRate: withMetrics.length ? withMetrics.filter((m) => m.firstPass).length / withMetrics.length : null,
119
+ tokensMedian: median(withMetrics.map((m) => m.tokens)), wallMsMedian: median(scored.map((a) => a.ms)),
120
+ reworkMean: withMetrics.length ? withMetrics.reduce((n, m) => n + m.changesRequested, 0) / withMetrics.length : null };
121
+ }
122
+ export interface RunSummary extends Measures { run: string; suite: string; arm: string; state: BenchRun["state"]; startedAt: string; done: number; total: number; current?: string }
123
+ export function runSummary(r: BenchRun): RunSummary {
124
+ const total = r.header.tasks * r.header.repeats;
125
+ const current = r.state === "running" && r.attempts.length < total ? r.header.order?.[r.attempts.length % r.header.order.length] : undefined;
126
+ return { run: r.header.run, suite: r.header.suite, arm: r.header.arm, state: r.state, startedAt: r.header.startedAt, done: r.attempts.length, total, ...(current ? { current } : {}), ...measures(r.attempts) };
127
+ }
128
+ /** Per task and overall for one run (or several runs of one arm). */
129
+ export function benchReport(runs: BenchRun[]): { overall: Measures; tasks: Record<string, Measures> } {
130
+ const attempts = runs.flatMap((r) => r.attempts);
131
+ // A Map: a task id may be `constructor` or `__proto__`, which a plain object answers with an inherited value.
132
+ const tasks = new Map<string, Attempt[]>();
133
+ for (const a of attempts) { const id = String(a.task); tasks.set(id, [...(tasks.get(id) ?? []), a]); } // a store line is not trusted to hold a string
134
+ return { overall: measures(attempts), tasks: Object.fromEntries([...tasks].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, measures(v)])) };
135
+ }
136
+
137
+ /** A seeded generator, so a comparison prints the same interval every time for the same records. */
138
+ function mulberry32(seed: number): () => number {
139
+ let a = seed >>> 0;
140
+ return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
141
+ }
142
+ export const MIN_ATTEMPTS = 5;
143
+ type MeasureName = "passRate" | "firstPassRate" | "tokensMedian" | "wallMsMedian";
144
+ export interface Comparison {
145
+ arms: { arm: string; runs: number; measures: Measures }[];
146
+ /** Each other arm against the first: the difference and its 95% bootstrap interval; inconclusive below MIN_ATTEMPTS. */
147
+ differences: { arm: string; measure: MeasureName; diff: number | null; low: number | null; high: number | null; inconclusive: boolean }[];
148
+ }
149
+ export function benchCompare(groups: { arm: string; runs: BenchRun[] }[], resamples = 1000, seed = 251): Comparison {
150
+ const arms = groups.map((g) => ({ arm: g.arm, runs: g.runs.length, attempts: g.runs.flatMap((r) => r.attempts) }));
151
+ const base = arms[0];
152
+ const differences: Comparison["differences"] = [];
153
+ const names: MeasureName[] = ["passRate", "firstPassRate", "tokensMedian", "wallMsMedian"];
154
+ if (base) for (const other of arms.slice(1)) {
155
+ const random = mulberry32(seed);
156
+ const pick = (xs: Attempt[]) => Array.from({ length: xs.length }, () => xs[Math.floor(random() * xs.length)]!);
157
+ const samples: Record<MeasureName, number[]> = { passRate: [], firstPassRate: [], tokensMedian: [], wallMsMedian: [] };
158
+ if (base.attempts.length && other.attempts.length) for (let i = 0; i < resamples; i++) {
159
+ const a = measures(pick(base.attempts)), b = measures(pick(other.attempts));
160
+ for (const n of names) if (a[n] !== null && b[n] !== null) samples[n].push(b[n]! - a[n]!);
161
+ }
162
+ const ma = measures(base.attempts), mb = measures(other.attempts);
163
+ // What each measure is computed over: pass rate and wall time over attempts that did not end in an error, the
164
+ // #247 measures over attempts that have them. Below MIN_ATTEMPTS of those in either arm a difference is inconclusive.
165
+ const counted = (xs: Attempt[], n: MeasureName) => n === "passRate" || n === "wallMsMedian" ? xs.filter((a) => a.outcome !== "error").length : xs.filter((a) => a.metrics).length;
166
+ for (const n of names) {
167
+ const s = samples[n].sort((x, y) => x - y);
168
+ const diff = ma[n] !== null && mb[n] !== null ? mb[n]! - ma[n]! : null;
169
+ const rank = (q: number) => s[Math.min(s.length - 1, Math.max(0, Math.ceil(q * s.length) - 1))]!; // nearest rank
170
+ differences.push({ arm: other.arm, measure: n, diff, low: s.length ? rank(0.025) : null, high: s.length ? rank(0.975) : null,
171
+ inconclusive: counted(base.attempts, n) < MIN_ATTEMPTS || counted(other.attempts, n) < MIN_ATTEMPTS || diff === null });
172
+ }
173
+ }
174
+ return { arms: arms.map((a) => ({ arm: a.arm, runs: a.runs, measures: measures(a.attempts) })), differences };
175
+ }
176
+
177
+ /** The runs an arm label stands for: every run of it that is over, a stopped one included (its attempts carry their own outcomes). */
178
+ export const armRuns = (runs: BenchRun[], arm: string): BenchRun[] => runs.filter((r) => r.header.arm === arm && r.state !== "running" && r.state !== "unknown");
179
+ /** Groups to compare must run the same suite (name and file hash), or their numbers measure different work. */
180
+ export function sameSuite(groups: { arm: string; runs: BenchRun[] }[]): string | null {
181
+ const keys = new Set(groups.flatMap((g) => g.runs.map((r) => `${r.header.suite} (${String(r.header.suiteHash ?? "").slice(0, 12)})`)));
182
+ return keys.size > 1 ? `these runs come from different suites or suite versions: ${[...keys].join(", ")}` : null;
183
+ }
184
+
185
+ const pct = (v: number | null) => v === null ? "-" : `${Math.round(v * 100)}%`;
186
+ const num = (v: number | null) => v === null ? "-" : String(Math.round(v));
187
+ const dur = (ms: number | null) => ms === null ? "-" : ms < 120_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`;
188
+ const row = (name: string, m: Measures) => `${name.padEnd(28)} ${String(m.attempts).padStart(8)} ${pct(m.passRate).padStart(5)} ${`${m.pass}/${m.fail}/${m.timeout}/${m.error}`.padStart(11)} ${pct(m.firstPassRate).padStart(10)} ${(m.reworkMean === null ? "-" : m.reworkMean.toFixed(2)).padStart(6)} ${num(m.tokensMedian).padStart(10)} ${dur(m.wallMsMedian).padStart(8)}`;
189
+ const HEAD = `${"".padEnd(28)} ${"attempts".padStart(8)} ${"pass".padStart(5)} ${"p/f/t/e".padStart(11)} ${"first pass".padStart(10)} ${"rework".padStart(6)} ${"tokens p50".padStart(10)} ${"wall p50".padStart(8)}`;
190
+ export function formatRuns(runs: BenchRun[]): string[] {
191
+ if (!runs.length) return ["no benchmark runs on this machine (ahub bench run <suite.json> --arm <label>)"];
192
+ return runs.map(runSummary).map((s) => `${s.run} ${s.state.padEnd(11)} ${s.suite} [${s.arm}] ${s.done}/${s.total} pass ${pct(s.passRate)} tokens p50 ${num(s.tokensMedian)} wall p50 ${dur(s.wallMsMedian)} ${s.startedAt}`);
193
+ }
194
+ export function formatReport(r: ReturnType<typeof benchReport>): string[] {
195
+ return [HEAD, row("all tasks", r.overall), ...Object.entries(r.tasks).map(([k, m]) => row(k, m))];
196
+ }
197
+ export function formatCompare(c: Comparison): string[] {
198
+ const lines = [HEAD, ...c.arms.map((a) => row(`${a.arm} (${a.runs} run${a.runs === 1 ? "" : "s"})`, a.measures))];
199
+ const fmt = (n: MeasureName, v: number | null) => v === null ? "-" : n.endsWith("Rate") ? `${v >= 0 ? "+" : ""}${Math.round(v * 100)}pt` : n === "wallMsMedian" ? `${v >= 0 ? "+" : "-"}${dur(Math.abs(v))}` : `${v >= 0 ? "+" : ""}${Math.round(v)}`;
200
+ for (const d of c.differences) lines.push(`${d.arm} vs ${c.arms[0]!.arm}: ${d.measure} ${fmt(d.measure, d.diff)} (95% ${fmt(d.measure, d.low)} to ${fmt(d.measure, d.high)})${d.inconclusive ? `, inconclusive: fewer than ${MIN_ATTEMPTS} attempts counted for it in an arm` : ""}`);
201
+ return lines;
202
+ }
203
+ export const CSV_COLUMNS = ["run", "suite", "arm", "task", "repeat", "outcome", "error", "verifyExit", "hubTask", "startedAt", "endedAt", "ms", "tokens", "wallMs", "activeMs",
204
+ "reviewRounds", "changesRequested", "checkFailed", "firstPass", "turns", "filesChanged", "models"] as const;
205
+ export function benchCsv(runs: BenchRun[]): string {
206
+ const cell = (v: unknown) => { const s = v === null || v === undefined ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
207
+ const rows = runs.flatMap((r) => r.attempts.map((a) => [r.header.run, r.header.suite, r.header.arm, a.task, a.repeat, a.outcome, a.error, a.verifyExit, a.hubTask, a.startedAt, a.endedAt, a.ms,
208
+ a.metrics?.tokens, a.metrics?.wallMs, a.metrics?.activeMs, a.metrics?.reviewRounds, a.metrics?.changesRequested, a.metrics?.checkFailed, a.metrics?.firstPass, a.metrics?.turns,
209
+ a.metrics?.filesChanged, a.metrics?.models.join(" ")].map(cell).join(",")));
210
+ return [CSV_COLUMNS.join(","), ...rows].join("\n") + "\n";
211
+ }
package/src/hub/board.ts CHANGED
@@ -27,12 +27,16 @@ export interface HistoryEntry {
27
27
  note?: string;
28
28
  /** The owner an event that set it left the task with (#67); absent in rows written before 0.11. */
29
29
  owner?: PeerId | null;
30
+ /** On a decline or an escalation: the owner it was declined for or taken from (#207); later reroutes leave it out. */
31
+ from?: PeerId;
30
32
  /** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
31
33
  profile?: string;
32
34
  /** Structured cause recorded at the operation source, never inferred from note text. */
33
35
  reason?: TaskMoveReason;
34
36
  /** Persisted task-idle ladder; never counted as fresh task activity (#186). */
35
37
  sweep?: SweepRecord;
38
+ /** The PII screen did not clear this entry's note (#198): only the console reads it. */
39
+ withheld?: true;
36
40
  }
37
41
  export interface Task {
38
42
  id: number;
@@ -47,6 +51,8 @@ export interface Task {
47
51
  plan?: TaskPlan;
48
52
  /** Tasks that must be approved before this one is offered (issue #34); fixed when it is proposed. */
49
53
  deps?: number[];
54
+ /** Who routing offers the task first once nothing is left to wait for (issue #207); null when nobody was named. */
55
+ reserved?: PeerId | null;
50
56
  signals: string[];
51
57
  /** consecutive changes_requested verdicts */
52
58
  rejections: number;
@@ -95,14 +101,16 @@ export class Board {
95
101
  for (const [col, empty] of [["plan", "{}"], ["deps", "[]"]] as const) {
96
102
  if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT NOT NULL DEFAULT '${empty}'`);
97
103
  }
104
+ // And from before issue #207: existing rows reserve nobody.
105
+ if (!have.has("reserved")) this.db.run("ALTER TABLE tasks ADD COLUMN reserved TEXT");
98
106
  }
99
107
 
100
- propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; signals?: string[] }): Task {
108
+ propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[] }): Task {
101
109
  const now = Date.now();
102
110
  const history: HistoryEntry[] = [{ at: now, by, event: "proposed" }];
103
111
  const { lastInsertRowid } = this.db
104
- .query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?)")
105
- .run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
112
+ .query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?, ?)")
113
+ .run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
106
114
  const task = this.get(Number(lastInsertRowid))!;
107
115
  this.changed(task, history[0]!);
108
116
  return task;
@@ -126,17 +134,17 @@ export class Board {
126
134
 
127
135
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
128
136
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
129
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason"> = {}): Task {
137
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}): Task {
130
138
  const task = this.get(id);
131
139
  if (!task) throw new Error(`no task #${id}`);
132
140
  if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
133
141
  throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
134
142
  }
135
143
  const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
136
- const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}) }];
144
+ const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}) }];
137
145
  this.db
138
- .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
139
- .run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
146
+ .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, updated = ? WHERE id = ?")
147
+ .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), Date.now(), id);
140
148
  const updated = this.get(id)!;
141
149
  this.changed(updated, history.at(-1)!);
142
150
  return updated;
package/src/hub/bus.ts CHANGED
@@ -295,7 +295,14 @@ export class Bus {
295
295
  if (current.revision === 0) {
296
296
  this.journal.importSnapshot(snapshot.journal ?? { schemaVersion: 1, revision: 1, instanceId: this.journal.instanceId, bus: snapshot, deliveries: [], manualPaused: snapshot.manualPaused ?? [] }, _operationId);
297
297
  }
298
+ const committedQueues = snapshot.queues;
298
299
  snapshot = this.journal.snapshot().bus;
300
+ // Offline peers need not reattach. Their empty queue keys still belong to the
301
+ // committed integrity view, even when no delivery ever persisted those keys (#234).
302
+ // The journal remains authoritative for work: never resurrect a non-empty queue.
303
+ for (const [id, queue] of Object.entries(committedQueues)) {
304
+ if (queue.length === 0 && !Object.hasOwn(snapshot.queues, id)) snapshot.queues[id] = [];
305
+ }
299
306
  }
300
307
  this.loadSnapshot(snapshot);
301
308
  this.persist();
@@ -348,7 +355,10 @@ export class Bus {
348
355
  if (record.revision !== revision) throw new Error("stale delivery revision");
349
356
  if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
350
357
  if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
351
- const before = this.snapshotWithoutJournal();
358
+ const queue = this.queues.get(record.peer) ?? [];
359
+ const hadQueue = this.queues.has(record.peer);
360
+ const before = [...queue];
361
+ const preface = this.prefaces.get(record.peer);
352
362
  try {
353
363
  this.journal.transaction(() => {
354
364
  if (!this.journal!.get(id)) this.journal!.createDelivery({ ...record });
@@ -356,10 +366,9 @@ export class Bus {
356
366
  this.journal!.resolve(id, current.revision, action, reason, revision);
357
367
  if (record.state === "queued") {
358
368
  const ids = new Set(record.originals.map((env) => env.id));
359
- this.queues.set(record.peer, (this.queues.get(record.peer) ?? []).filter((env) => !ids.has(env.id)));
369
+ queue.splice(0, queue.length, ...queue.filter((env) => !ids.has(env.id)));
360
370
  }
361
371
  if (action === "retry") {
362
- const queue = this.queues.get(record.peer) ?? [];
363
372
  for (const env of record.originals) {
364
373
  if (env.from === HUB && env.kind === "presence") this.restorePreface(record.peer, env);
365
374
  else if (!queue.some((old) => old.id === env.id)) queue.push(env);
@@ -368,7 +377,13 @@ export class Bus {
368
377
  }
369
378
  this.persist();
370
379
  });
371
- } catch (error) { this.loadSnapshot(before); throw error; }
380
+ } catch (error) {
381
+ queue.splice(0, queue.length, ...before);
382
+ if (!hadQueue) this.queues.delete(record.peer);
383
+ if (preface) this.prefaces.set(record.peer, preface);
384
+ else this.prefaces.delete(record.peer);
385
+ throw error;
386
+ }
372
387
  this.refreshRecoveryHold(record.peer);
373
388
  this.onQueues?.();
374
389
  void this.drain(record.peer);
@@ -715,13 +730,69 @@ export class Bus {
715
730
  finally { this.steering--; this.onQueues?.(); }
716
731
  }
717
732
 
733
+ /** Holds on a peer's queue, for a drain and a pull alike: recovery, an uncertain delivery, a pause (console, budget, conductor). */
734
+ private held(id: PeerId): boolean {
735
+ return this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id);
736
+ }
737
+
738
+ /** What a peer was just handed stays resolvable for `reply_to`: moved to the newest end, the stored envelope kept. */
739
+ private remember(envs: Envelope[]): void {
740
+ for (const e of envs) { const kept = this.seen.get(e.id) ?? e; this.seen.delete(e.id); this.seen.set(e.id, kept); }
741
+ while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
742
+ }
743
+
744
+ /** A push may go out now: nothing holds the queue, the peer is idle, and its session shows pushes (issue #205). */
745
+ private deliverable(id: PeerId): boolean {
746
+ return !this.held(id) && this.stateOf(id) === "idle" && !this.peers.get(id)?.pullOnly;
747
+ }
748
+
749
+ /**
750
+ * What one push delivery would take (the preface, then `take`'s batch), handed to a pull-only peer and recorded as
751
+ * completed: the tool result that returns it is the readback, so nothing waits for settlement and nothing is ever
752
+ * `accepted` (issue #205). It is checkpointed like a push, so queued rows of the same envelopes (an operator retry)
753
+ * are grouped into it, and registered for `reply_to` like a push. Undefined while held. A failed journal write puts
754
+ * back only this peer's queue and preface (every pause stays) and stops the bus like any other journal failure.
755
+ * ponytail: recorded completed at hand-out, before anything shows the model saw it: a reply lost on the way, a tool
756
+ * call cancelled (Esc) or a plugin that dies loses the batch, and `ahub queue resolve --action retry` refuses a
757
+ * terminal row. The completed row keeps the envelopes (`ahub queue show <id>`) to resend by hand; take/confirm in two
758
+ * steps if loss is ever observed.
759
+ */
760
+ pull(id: PeerId): Envelope[] | undefined {
761
+ if (this.storageError) throw new Error("delivery journal unavailable");
762
+ if (this.held(id)) return undefined;
763
+ const queue = this.queues.get(id) ?? [];
764
+ this.dropIrrelevant(id, queue);
765
+ const preface = this.prefaces.get(id);
766
+ if (!preface && !queue.length) return [];
767
+ const queued = [...queue];
768
+ const batch = [...(preface ? [preface] : []), ...this.take(id, queue)];
769
+ this.prefaces.delete(id);
770
+ // Before the checkpoint, as in a drain, so the persisted snapshot carries it; on a failure the bus stops anyway.
771
+ this.remember(batch);
772
+ try {
773
+ const deliveryId = crypto.randomUUID();
774
+ this.journal?.transaction(() => {
775
+ this.durableHandoff(id, deliveryId, batch, batch); // its bus snapshot is the queue after this pull
776
+ this.journal!.transition(deliveryId, "completed", "read through hub_inbox");
777
+ });
778
+ } catch (error) {
779
+ queue.splice(0, queue.length, ...queued);
780
+ if (preface) this.prefaces.set(id, preface);
781
+ this.storageError = "delivery journal unavailable";
782
+ throw error;
783
+ }
784
+ this.failureStreak.delete(id); this.lastFailure.delete(id); // read, as a completed push receipt clears them
785
+ this.onQueues?.();
786
+ return batch;
787
+ }
788
+
718
789
  private async drain(id: PeerId): Promise<void> {
719
790
  if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
720
791
  this.draining.add(id);
721
792
  try {
722
793
  const peer = this.peers.get(id)!;
723
794
  const queue = this.queues.get(id)!;
724
- while (!this.storageError && !this.recoveryHeld && !this.recoveryHeldPeers.has(id) && queue.length && this.stateOf(id) === "idle") {
795
+ while (!this.storageError && queue.length && this.deliverable(id)) {
725
796
  if (this.dropIrrelevant(id, queue) && !queue.length) break;
726
797
  const delay = this.wait(queue);
727
798
  if (delay > 0) { this.arm(id, delay); break; }
@@ -736,7 +807,8 @@ export class Bus {
736
807
  this.condensing += mayCondense ? 1 : 0;
737
808
  const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
738
809
  this.condensing -= mayCondense ? 1 : 0;
739
- if (this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.stateOf(id) !== "idle") {
810
+ // A session that cannot show pushes may have taken the peer while this was condensed (issue #205).
811
+ if (!this.deliverable(id)) {
740
812
  if (!this.journal) { if (preface) this.restorePreface(id, preface); queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id))); }
741
813
  break;
742
814
  }
@@ -751,8 +823,7 @@ export class Bus {
751
823
  for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
752
824
  this.prefaces.delete(id);
753
825
  }
754
- for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
755
- while (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
826
+ this.remember(out);
756
827
  this.lastDelivery.set(id, { out, originals: delivery });
757
828
  this.preparing.delete(id); // from here on the transport/journal owns settlement
758
829
  const deliveryId = crypto.randomUUID();
@@ -17,6 +17,7 @@ export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.e
17
17
  delete env.CLAUDECODE;
18
18
  delete env.CLAUDE_CODE_SESSION_ID;
19
19
  delete env.CODEX_THREAD_ID;
20
+ delete env.AGENTHUB_CHANNEL;
20
21
  delete env.AGENTHUB_PEER_ID;
21
22
  env.AGENTHUB_PEER_ID = peer;
22
23
  return env;
@@ -1,6 +1,9 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Task } from "./board.ts";
3
3
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
4
+ import { HUB, USER } from "./envelope.ts";
5
+ import { OWNERSHIP_EVENTS } from "./tasks.ts";
6
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
4
7
  import type { HubEvent } from "./events.ts";
5
8
  import type { SupervisionFeed } from "./supervision.ts";
6
9
  import type { Budget } from "./budget.ts";
@@ -22,8 +25,8 @@ export function publicPeerBudget(status: ReturnType<Budget["status"]>, nameable:
22
25
  }
23
26
 
24
27
  const PEER = /^[a-z][a-z0-9-]{0,31}$/;
25
- const peerId = (value: unknown): string => {
26
- if (typeof value !== "string" || /\s/.test(value) || !PEER.test(value) || ["user", "hub", "digest"].includes(value)) throw new Error("peer must be a valid agent peer id");
28
+ const peerId = (value: unknown, preEffect = false): string => {
29
+ if (typeof value !== "string" || /\s/.test(value) || !PEER.test(value) || ["user", "hub", "digest"].includes(value)) throw new (preEffect ? PreEffectToolRefusal : Error)("peer must be a valid agent peer id");
27
30
  return value;
28
31
  };
29
32
 
@@ -43,12 +46,16 @@ export function conductorPeer(roles: unknown): string | null {
43
46
  }
44
47
 
45
48
  /** The role check always comes first; default-allow capabilities cannot grant a role. */
46
- export function requireConductor(peer: string, roles: unknown, capabilities: Record<string, unknown> = {}, assign = false): void {
47
- if (conductorPeer(roles) !== peer) throw new Error("this operation requires the explicit conductor role");
48
- if (assign && Object.hasOwn(capabilities, peer)) {
49
- const caps = capabilities[peer];
50
- if (!Array.isArray(caps) || !caps.includes("assign")) throw new Error(`${peer} requires assign capability for this operation`);
51
- }
49
+ export function requireConductor(peer: string, roles: unknown, capabilities: Record<string, unknown> = {}, assign = false, preEffect = false): void {
50
+ if (conductorPeer(roles) !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("this operation requires the explicit conductor role");
51
+ if (assign) requireAssign(peer, capabilities, preEffect);
52
+ }
53
+
54
+ /** A peer with an explicit capabilities list needs `assign` in it to move work. */
55
+ function requireAssign(peer: string, capabilities: Record<string, unknown>, preEffect = false): void {
56
+ if (!Object.hasOwn(capabilities, peer)) return;
57
+ const caps = capabilities[peer];
58
+ if (!Array.isArray(caps) || !caps.includes("assign")) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} requires assign capability for this operation`);
52
59
  }
53
60
 
54
61
  export interface ConductorHold { peer: string; actor: string; since: number }
@@ -95,11 +102,11 @@ export class ConductorHolds {
95
102
 
96
103
  export type ConductorStart = { peer: "local" | "kimi" | "pi"; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
97
104
  /** Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text. */
98
- export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string): ConductorStart {
99
- if (mode !== undefined && mode !== "headless" && mode !== "tui") throw new Error("mode must be headless or tui");
105
+ export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect = false): ConductorStart {
106
+ if (mode !== undefined && mode !== "headless" && mode !== "tui") throw new (preEffect ? PreEffectToolRefusal : Error)("mode must be headless or tui");
100
107
  if (peer === "claude" || peer === "codex" || (peer === "pi" && mode === "tui")) return { peer, mode: "tui", command: preview(peer) };
101
108
  if ((peer === "local" || peer === "kimi" || peer === "pi") && mode !== "tui") return { peer, mode: "headless" };
102
- throw new Error("only local, kimi and headless pi may be started by the conductor");
109
+ throw new (preEffect ? PreEffectToolRefusal : Error)("only local, kimi and headless pi may be started by the conductor");
103
110
  }
104
111
 
105
112
  const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
@@ -154,23 +161,49 @@ export interface ConductorHooks {
154
161
  audit(event: ConductEvent): void;
155
162
  }
156
163
 
164
+ /** A task id as `Tasks.need()` takes it: a whole number or a digit string. */
165
+ const taskId = (v: unknown): number | undefined => {
166
+ const n = typeof v === "number" ? v : typeof v === "string" && /^\s*\d+\s*$/.test(v) ? Number(v) : NaN;
167
+ return Number.isSafeInteger(n) && n > 0 ? n : undefined;
168
+ };
169
+
157
170
  export class Conductor {
158
171
  constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
159
172
  async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
160
- if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new Error("unknown conductor tool");
161
- requireConductor(actor, this.hooks.roles(), this.hooks.capabilities(), tool === "hub_task_assign" || tool === "hub_task_escalate");
173
+ if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new PreEffectToolRefusal("unknown conductor tool");
174
+ if ((tool === "hub_task_show" || tool === "hub_task_assign") && conductorPeer(this.hooks.roles()) !== actor) {
175
+ const id = taskId(args.id);
176
+ const task = id === undefined ? undefined : this.hooks.task(id);
177
+ // A task's current owner and reviewer read its public view without the role (#208); nobody else does.
178
+ if (task && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
179
+ // Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
180
+ // worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
181
+ // reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
182
+ // moves are not decisions).
183
+ const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved"));
184
+ const accepted = task?.history.some((h) => h.event === "accepted");
185
+ if (task && tool === "hub_task_assign" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
186
+ const peer = peerId(args.peer, true);
187
+ if (peer !== actor) requireAssign(actor, this.hooks.capabilities(), true);
188
+ await this.hooks.assign(actor, task.id, peer);
189
+ const updated = this.hooks.task(task.id);
190
+ return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
191
+ }
192
+ }
193
+ requireConductor(actor, this.hooks.roles(), this.hooks.capabilities(), tool === "hub_task_assign" || tool === "hub_task_escalate", true);
162
194
  const action = tool.slice(4);
163
195
  const emit = (extra: Pick<ConductEvent, "task" | "peer"> = {}) => {
164
196
  try { this.hooks.audit({ kind: "conduct", actor, action, ...extra }); } catch { /* never throw after a task or hold write */ }
165
197
  };
166
198
  if (tool === "hub_status") { const result = publicConductorStatus(this.hooks.status()); emit(); return result; }
167
199
  if (tool.startsWith("hub_task_")) {
168
- if (typeof args.id !== "number" || !Number.isSafeInteger(args.id) || args.id <= 0) throw new Error("id must be a positive integer");
169
- const task = this.hooks.task(args.id);
170
- if (!task) throw new Error(`no task #${args.id}`);
200
+ const id = taskId(args.id);
201
+ if (id === undefined) throw new PreEffectToolRefusal("id must be a positive integer");
202
+ const task = this.hooks.task(id);
203
+ if (!task) throw new PreEffectToolRefusal(`no task #${args.id}`);
171
204
  if (tool === "hub_task_show") { const result = publicConductorTask(task, this.hooks.publicView); emit({ task: task.id }); return result; }
172
205
  if (tool === "hub_task_assign") {
173
- const peer = peerId(args.peer);
206
+ const peer = peerId(args.peer, true);
174
207
  await this.hooks.assign(actor, task.id, peer); emit({ task: task.id, peer });
175
208
  } else { await this.hooks.escalate(actor, task.id); emit({ task: task.id }); }
176
209
  // Task callbacks may return raw objects; always re-read and apply the public view.
@@ -178,15 +211,15 @@ export class Conductor {
178
211
  return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
179
212
  }
180
213
  if (tool === "hub_peer_start") {
181
- const start = conductorStart(args.peer, args.mode, this.hooks.preview);
214
+ const start = conductorStart(args.peer, args.mode, this.hooks.preview, true);
182
215
  if (start.mode === "headless") await this.hooks.start(start.peer);
183
216
  emit({ peer: start.peer }); return start;
184
217
  }
185
- const peer = peerId(args.peer);
186
- if (!this.hooks.known(peer)) throw new Error(`unknown peer: ${peer}`);
218
+ const peer = peerId(args.peer, true);
219
+ if (!this.hooks.known(peer)) throw new PreEffectToolRefusal(`unknown peer: ${peer}`);
187
220
  if (tool === "hub_peer_hold") { this.holds.hold(peer, actor); this.hooks.pause(peer); }
188
221
  else {
189
- if (this.holds.get(peer)?.actor !== actor) throw new Error("you may release only a conductor hold you placed");
222
+ if (this.holds.get(peer)?.actor !== actor) throw new PreEffectToolRefusal("you may release only a conductor hold you placed");
190
223
  await this.hooks.validateRelease?.(peer);
191
224
  requireConductor(actor, this.hooks.roles(), this.hooks.capabilities());
192
225
  this.holds.release(peer, actor); this.hooks.release(peer);
@@ -10,6 +10,7 @@ import { childEnv } from "./child-process.ts";
10
10
  */
11
11
  export const MACHINE_LOCAL = [
12
12
  "kimi_cmd",
13
+ "permission_modes",
13
14
  "codex_bin",
14
15
  "pi.cmd",
15
16
  "checks",
@@ -6,15 +6,17 @@ export function stateDirFor(cwd: string): string {
6
6
  return projectContext(cwd).stateDir;
7
7
  }
8
8
 
9
- /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). The plugin is installed apart from the daemon, so they can drift. */
10
- export const PROTOCOL = 15;
9
+ /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
10
+ export const PROTOCOL = 16;
11
11
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
12
- export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, PROTOCOL] as const;
12
+ export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
13
13
 
14
14
  export interface Hello {
15
15
  /** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
16
16
  role: "peer" | "console" | "tools";
17
17
  peer?: string;
18
+ /** `peer` only: false when the session cannot show channel pushes; its queue then waits for `inbox` (issue #205). */
19
+ channel?: boolean;
18
20
  projectId?: string;
19
21
  instanceId?: string;
20
22
  projectRoot?: string;
@@ -28,6 +30,8 @@ export interface ControlDescriptor {
28
30
  cwd?: string;
29
31
  protocol?: number;
30
32
  pid?: number;
33
+ /** #226: the daemon's processSignature, written into status.json only (not the status reply); absent before 0.12.21. */
34
+ pidSignature?: string;
31
35
  }
32
36
 
33
37
  /** Where the daemon of this state dir listens. Re-read before every connect: port and token change per run. */
@@ -37,7 +41,8 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
37
41
  const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
38
42
  if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token) return undefined;
39
43
  return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
40
- instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid };
44
+ instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid,
45
+ ...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
41
46
  } catch {
42
47
  return undefined;
43
48
  }