@staix/agent-hub 0.12.20 → 0.12.22

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 (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -9
  3. package/docs/agent-notes/adapters.md +13 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +7 -3
  6. package/docs/agent-notes/daemon.md +12 -2
  7. package/docs/agent-notes/local-worker.md +5 -1
  8. package/docs/agent-notes/tasks.md +4 -2
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +15 -5
  12. package/docs/operations.md +510 -54
  13. package/docs/quickstart.md +16 -11
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +49 -11
  16. package/docs/smoke.md +138 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
  19. package/docs/verified.json +34 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +3 -2
  23. package/src/adapters/acp.ts +77 -10
  24. package/src/adapters/codex-appserver.ts +72 -2
  25. package/src/adapters/local-worker.ts +46 -11
  26. package/src/adapters/pi.ts +215 -22
  27. package/src/cli/bench.ts +287 -0
  28. package/src/cli/console-state.ts +226 -28
  29. package/src/cli/console.ts +16 -4
  30. package/src/cli/facts-hook.ts +50 -8
  31. package/src/cli/help.ts +16 -5
  32. package/src/cli/identity.ts +10 -4
  33. package/src/cli/launch.ts +272 -18
  34. package/src/cli/main.ts +290 -30
  35. package/src/cli/preview.ts +13 -3
  36. package/src/cli/recovery-package.ts +17 -0
  37. package/src/cli/status-lines.ts +2 -1
  38. package/src/cli/terminal-recovery.ts +83 -31
  39. package/src/cli/upgrade-interactive.ts +297 -0
  40. package/src/cli/upgrade-runtime.ts +123 -20
  41. package/src/cli/upgrade.ts +108 -19
  42. package/src/hub/bench.ts +211 -0
  43. package/src/hub/bus.ts +24 -7
  44. package/src/hub/conductor.ts +22 -21
  45. package/src/hub/config-trust.ts +1 -0
  46. package/src/hub/control-client.ts +4 -1
  47. package/src/hub/daemon.ts +477 -86
  48. package/src/hub/delivery-journal.ts +6 -6
  49. package/src/hub/events.ts +4 -2
  50. package/src/hub/lifecycle.ts +8 -13
  51. package/src/hub/manager.ts +16 -14
  52. package/src/hub/peers.ts +2 -2
  53. package/src/hub/permission-mode.ts +44 -0
  54. package/src/hub/recovery-store.ts +59 -9
  55. package/src/hub/registry.ts +36 -22
  56. package/src/hub/research.ts +255 -0
  57. package/src/hub/tasks.ts +99 -55
  58. package/src/hub/tool-refusal.ts +7 -0
  59. package/src/hub/ui.ts +4 -1
  60. package/src/local/deny.ts +48 -1
  61. package/src/local/sandbox.ts +6 -3
  62. package/src/local/tools.ts +34 -11
  63. package/src/pi/extension.ts +43 -20
  64. package/src/pi/process-signature.ts +23 -0
  65. package/src/pi/tool-receipts.ts +24 -2
  66. package/src/ui/index.html +98 -17
  67. package/src/ui/task-progress.ts +33 -0
@@ -0,0 +1,255 @@
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 type { StampedEvent } from "./events.ts";
5
+ import { hubHome } from "./project.ts";
6
+
7
+ /**
8
+ * #247: opt-in research records of how tasks went, built only from `events.jsonl` (ids, states, counts, tokens), so the
9
+ * live writer and `ahub research backfill` produce the same records. Never a title, detail, plan, note, path, message
10
+ * body or check output: the events carry none of those.
11
+ */
12
+ export const RESEARCH_SCHEMA = "agent-hub.research/v1";
13
+ export const LABELS = ["ok", "regressed", "reverted", "incomplete", "wrong", "abandoned"] as const;
14
+ export type Label = (typeof LABELS)[number];
15
+ /** A person's later verdict that turns an approved task into a failure. */
16
+ const FAILED: ReadonlySet<string> = new Set(["regressed", "reverted", "incomplete", "wrong", "abandoned"]);
17
+
18
+ export interface PeerUsage { input: number | null; output: number | null; cacheRead: number | null; total: number | null }
19
+ export interface TaskRecord {
20
+ schema: typeof RESEARCH_SCHEMA; kind: "task"; project: string; task: number;
21
+ class: string | null; pii: boolean; outcome: "approved";
22
+ /** The proposal time also tells a task from an earlier one with the same id after `ahub reset --all`. */
23
+ createdAt: string | null; startedAt: string | null; approvedAt: string;
24
+ /** From the first `in_progress` to the approval; null when the start is not in the events. */
25
+ wallMs: number | null;
26
+ /** Sum of the attributed turns' durations, the turns still open at the approval included. */
27
+ activeMs: number;
28
+ /** Owners in the order they held the task. */
29
+ owners: string[]; reviewer: string | null;
30
+ /** Times the task went to review, was sent back, passed or failed its check, was reported done. */
31
+ reviewRounds: number; changesRequested: number; checkPassed: number; checkFailed: number; dones: number;
32
+ /** Owner changes after the first owner, and the same counted by the board's move reason. */
33
+ reassignments: number; reassignedBy: Record<string, number>;
34
+ /** Approved on its first review with no failed check. */
35
+ firstPass: boolean;
36
+ stuck: number; overlaps: number; conflicts: number;
37
+ tests: { pass: number; fail: number };
38
+ /** Attributed tokens per peer and per #200 attribution rule; unattributed tokens are in no record. */
39
+ tokens: { total: number; byPeer: Record<string, number>; byAttribution: Record<string, number> };
40
+ usage: Record<string, PeerUsage>;
41
+ /** Provider-reported total tokens per served (else requested) model. */
42
+ usageByModel: Record<string, number>;
43
+ turns: number; filesChanged: number;
44
+ /** Model routes and served models seen for the task's attributed requests. */
45
+ models: string[];
46
+ writer: { version: string; source: "live" | "backfill" };
47
+ }
48
+ /** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. */
49
+ export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string }
50
+ export type ResearchRecord = TaskRecord | LabelRecord;
51
+
52
+ /** The store name for a project: a hash of its id, never its path. */
53
+ export const projectKey = (projectId: string): string => createHash("sha256").update(projectId).digest("hex").slice(0, 16);
54
+ export const researchDir = (home = hubHome()): string => join(home, "research");
55
+ export const researchFile = (projectId: string, home = hubHome()): string => join(researchDir(home), `${projectKey(projectId)}.jsonl`);
56
+ /** One task across every record about it: a task id is reused after `ahub reset --all`, its proposal time is not. */
57
+ export const recordKey = (r: { project: string; task: number; createdAt: string | null; approvedAt?: string }): string => `${r.project}#${r.task}@${r.createdAt ?? r.approvedAt ?? ""}`;
58
+
59
+ type Acc = Omit<TaskRecord, "schema" | "kind" | "project" | "approvedAt" | "wallMs" | "firstPass" | "outcome" | "writer" | "models"> & {
60
+ models: Set<string>; approvedAt: string | null; lastState: string | null;
61
+ };
62
+ /** Keyed by peer ids, model names and reasons, any of which may be `constructor` or `__proto__`: no prototype to inherit from. */
63
+ const keyed = <T>(): Record<string, T> => Object.create(null) as Record<string, T>;
64
+ const blank = (task: number): Acc => ({ task, class: null, pii: false, createdAt: null, startedAt: null, activeMs: 0, owners: [], reviewer: null,
65
+ reviewRounds: 0, changesRequested: 0, checkPassed: 0, checkFailed: 0, dones: 0, reassignments: 0, reassignedBy: keyed(), stuck: 0, overlaps: 0, conflicts: 0,
66
+ tests: { pass: 0, fail: 0 }, tokens: { total: 0, byPeer: keyed(), byAttribution: keyed() }, usage: keyed(), usageByModel: keyed(), turns: 0, filesChanged: 0, models: new Set(),
67
+ approvedAt: null, lastState: null });
68
+ const add = (current: number | null, value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? (current ?? 0) + value : current;
69
+ const bump = (counts: Record<string, number>, key: string, n = 1) => { counts[key] = (counts[key] ?? 0) + n; };
70
+
71
+ /**
72
+ * One record per approved task in `events`. Approval is final on the board, so everything attributed to the task
73
+ * counts, also after the approval: the turn that approved it ends later, and its usage arrives after that. The live
74
+ * writer therefore waits for those turns (daemon `recordResearch`); backfill reads to the end of the file.
75
+ */
76
+ export function taskRecords(events: StampedEvent[], projectId: string, writer: TaskRecord["writer"]): TaskRecord[] {
77
+ const tasks = new Map<number, Acc>(), earlier: Acc[] = [];
78
+ const acc = (id: number) => { let a = tasks.get(id); if (!a) tasks.set(id, a = blank(id)); return a; };
79
+ const seenUsage = new Set<string>();
80
+ for (const e of events) {
81
+ if (e.type === "task") {
82
+ // Ids restart after `ahub reset --all` archives the events, but a file that kept both starts the task anew.
83
+ if (e.event === "proposed" && tasks.get(e.id)?.createdAt) { earlier.push(tasks.get(e.id)!); tasks.delete(e.id); }
84
+ const a = acc(e.id);
85
+ a.class = e.class; if (e.pii) a.pii = true;
86
+ if (e.owner && e.owner !== a.owners.at(-1)) {
87
+ if (a.owners.length) { a.reassignments++; bump(a.reassignedBy, e.reason ?? "none"); }
88
+ a.owners.push(e.owner);
89
+ }
90
+ if (e.reviewer) a.reviewer = e.reviewer;
91
+ if (e.event === "proposed") a.createdAt ??= e.at;
92
+ if (e.state === "in_progress") a.startedAt ??= e.at;
93
+ if (e.event === "done") a.dones++;
94
+ if (e.event === "check passed") a.checkPassed++;
95
+ if (e.event === "check failed") a.checkFailed++;
96
+ if (e.state === "in_review" && a.lastState !== "in_review") a.reviewRounds++;
97
+ if (e.state === "changes_requested" && a.lastState !== "changes_requested") a.changesRequested++;
98
+ if (e.state === "approved") a.approvedAt ??= e.at;
99
+ a.lastState = e.state;
100
+ continue;
101
+ }
102
+ const task = "task" in e && typeof e.task === "number" ? e.task : undefined;
103
+ if (task === undefined) continue;
104
+ const a = acc(task);
105
+ if ("pii" in e && e.pii === true) a.pii = true;
106
+ if (e.type === "tokens") { a.tokens.total += e.n; bump(a.tokens.byPeer, e.peer, e.n); bump(a.tokens.byAttribution, e.attribution ?? "unknown", e.n); }
107
+ else if (e.type === "turn_end") { a.turns++; a.activeMs += e.ms; a.filesChanged += e.files ?? 0; }
108
+ else if (e.type === "usage") {
109
+ const key = `${e.peer}\0${e.source}\0${e.id}`;
110
+ if (seenUsage.has(key)) continue;
111
+ seenUsage.add(key);
112
+ const u = a.usage[e.peer] ??= { input: null, output: null, cacheRead: null, total: null };
113
+ u.input = add(u.input, e.inputTokens); u.output = add(u.output, e.outputTokens); u.cacheRead = add(u.cacheRead, e.cacheReadTokens); u.total = add(u.total, e.totalTokens);
114
+ const model = e.servedModel ?? e.requestedModel;
115
+ if (model) a.models.add(String(model));
116
+ if (typeof e.totalTokens === "number" && Number.isFinite(e.totalTokens)) bump(a.usageByModel, String(model ?? "unknown"), e.totalTokens);
117
+ }
118
+ else if (e.type === "route") a.models.add(e.route);
119
+ else if (e.type === "route_outcome") { if (e.next?.tests === "pass") a.tests.pass++; if (e.next?.tests === "fail") a.tests.fail++; }
120
+ else if (e.type === "stuck") a.stuck++;
121
+ else if (e.type === "overlap") a.overlaps++;
122
+ else if (e.type === "conflict") a.conflicts++;
123
+ }
124
+ const out: TaskRecord[] = [];
125
+ for (const a of [...earlier, ...tasks.values()]) {
126
+ if (!a.approvedAt) continue;
127
+ const { approvedAt, lastState: _state, models, ...rest } = a;
128
+ const start = a.startedAt ? Date.parse(a.startedAt) : NaN, end = Date.parse(approvedAt);
129
+ out.push({ schema: RESEARCH_SCHEMA, kind: "task", project: projectKey(projectId), ...rest, outcome: "approved", approvedAt,
130
+ wallMs: Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null,
131
+ firstPass: a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer });
132
+ }
133
+ return out.sort((x, y) => x.approvedAt.localeCompare(y.approvedAt) || x.task - y.task);
134
+ }
135
+
136
+ /** Every record in one store file; a line that does not parse is skipped. */
137
+ export function readStore(file: string): ResearchRecord[] {
138
+ if (!existsSync(file)) return [];
139
+ return readFileSync(file, "utf8").split("\n").flatMap((line) => {
140
+ if (!line.trim()) return [];
141
+ try { const r = JSON.parse(line); return r?.schema === RESEARCH_SCHEMA ? [r as ResearchRecord] : []; } catch { return []; }
142
+ });
143
+ }
144
+ /** This project's store, or every project's with `all`. */
145
+ export function readStores(projectId: string | undefined, home = hubHome()): ResearchRecord[] {
146
+ if (projectId) return readStore(researchFile(projectId, home));
147
+ const dir = researchDir(home);
148
+ if (!existsSync(dir)) return [];
149
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).sort().flatMap((name) => readStore(join(dir, name)));
150
+ }
151
+
152
+ /** Append task records the store does not hold yet (same task, by `recordKey`), and labels; returns how many were written. */
153
+ export function appendRecords(projectId: string, records: ResearchRecord[], home = hubHome()): number {
154
+ if (!records.length) return 0;
155
+ const file = researchFile(projectId, home);
156
+ mkdirSync(researchDir(home), { recursive: true, mode: 0o700 });
157
+ chmodSync(researchDir(home), 0o700); // mkdir's mode applies only when it creates the directory
158
+ const held = new Set(readStore(file).flatMap((r) => r.kind === "task" ? [recordKey(r)] : []));
159
+ const added = records.filter((r) => r.kind === "label" || !held.has(recordKey(r)));
160
+ if (!added.length) return 0;
161
+ appendFileSync(file, added.map((r) => JSON.stringify(r)).join("\n") + "\n", { mode: 0o600 });
162
+ chmodSync(file, 0o600);
163
+ return added.length;
164
+ }
165
+
166
+ /**
167
+ * The record a person's label is for. `created` (a record's `createdAt`, from `ahub research export`) names one exactly.
168
+ * Otherwise the current board's task with that id, found by its proposal in `events`, so a label never lands on an
169
+ * earlier task with the same id; an id this hub's events do not have (a task from before `ahub reset --all`) is the
170
+ * store's only record with that id. Throws why there is none, or which ones to choose from.
171
+ */
172
+ export function labelTarget(records: ResearchRecord[], events: StampedEvent[], task: number, created?: string): TaskRecord {
173
+ // A record without a proposal time (its proposal was not in the events) cannot be told apart, so it takes no label.
174
+ const mine = records.filter((r): r is TaskRecord => r.kind === "task" && r.task === task && r.createdAt !== null);
175
+ if (created !== undefined) {
176
+ return mine.find((r) => r.createdAt === created) ?? fail(`no research record for task #${task} proposed at ${created}${mine.length ? ` (it has: ${mine.map((r) => r.createdAt).join(", ")})` : ""}`);
177
+ }
178
+ const proposed = events.filter((e) => e.type === "task" && e.id === task && e.event === "proposed").at(-1);
179
+ if (proposed) {
180
+ return mine.find((r) => r.createdAt === proposed.at)
181
+ ?? fail(`task #${task} has no research record yet: it is not approved, its record still waits for the turns that approved it, or it was approved while research was off (ahub research backfill builds that one)${mine.length ? `; an earlier task #${task} from before a reset is labelled with --created ${mine.map((r) => r.createdAt).join(" or ")}` : ""}`);
182
+ }
183
+ if (mine.length === 1) return mine[0]!;
184
+ if (!mine.length) throw new Error(records.some((r) => r.kind === "task" && r.task === task) ? `task #${task}'s research record has no proposal time (its proposal was not in the events), so it takes no label` : `no research record for task #${task}`);
185
+ throw new Error(`task #${task} is not on this hub's board and the store has ${mine.length} records with that id; name one with --created <time>: ${mine.map((r) => r.createdAt).join(", ")}`);
186
+ }
187
+ const fail = (message: string): never => { throw new Error(message); };
188
+
189
+ const pct = (n: number, d: number) => d ? n / d : null;
190
+ /** Linear interpolation between the closest ranks: the median of [100, 300] is 200. */
191
+ function quantile(values: number[], q: number): number | null {
192
+ if (!values.length) return null;
193
+ const sorted = [...values].sort((a, b) => a - b), at = q * (sorted.length - 1), low = Math.floor(at);
194
+ return sorted[low]! + (sorted[Math.min(low + 1, sorted.length - 1)]! - sorted[low]!) * (at - low);
195
+ }
196
+ export interface Measures {
197
+ tasks: number; succeeded: number; failedByLabel: number;
198
+ successRate: number | null; firstPassRate: number | null; reworkRate: number | null; checkFailureRate: number | null;
199
+ tokensPerTask: { median: number | null; p90: number | null }; wallMsPerTask: { median: number | null; p90: number | null };
200
+ }
201
+ function measures(records: TaskRecord[], labels: Map<string, Label>): Measures {
202
+ const failed = records.filter((r) => FAILED.has(labels.get(recordKey(r)) ?? "")).length;
203
+ return { tasks: records.length, succeeded: records.length - failed, failedByLabel: failed,
204
+ successRate: pct(records.length - failed, records.length), firstPassRate: pct(records.filter((r) => r.firstPass).length, records.length),
205
+ reworkRate: pct(records.reduce((n, r) => n + r.changesRequested, 0), records.length),
206
+ checkFailureRate: pct(records.reduce((n, r) => n + r.checkFailed, 0), records.reduce((n, r) => n + r.checkFailed + r.checkPassed, 0)),
207
+ tokensPerTask: { median: quantile(records.map((r) => r.tokens.total), 0.5), p90: quantile(records.map((r) => r.tokens.total), 0.9) },
208
+ wallMsPerTask: { median: quantile(records.flatMap((r) => r.wallMs ?? []), 0.5), p90: quantile(records.flatMap((r) => r.wallMs ?? []), 0.9) } };
209
+ }
210
+ export interface ResearchReport { overall: Measures; byClass: Record<string, Measures>; byOwner: Record<string, Measures>; byProject: Record<string, Measures>; byModel: Record<string, Measures> }
211
+ /**
212
+ * The derived measures, never stored as truth: each task counts once (its last record), and a person's latest label
213
+ * decides whether an approved task later failed. Records older than `since` (ms epoch, by approval) are left out.
214
+ */
215
+ export function researchReport(records: ResearchRecord[], since = 0): ResearchReport {
216
+ const latest = new Map<string, TaskRecord>(), labels = new Map<string, Label>();
217
+ for (const r of records) {
218
+ const key = recordKey(r);
219
+ if (r.kind === "label") { labels.set(key, r.label); continue; }
220
+ if (Date.parse(r.approvedAt) >= since) latest.set(key, r);
221
+ }
222
+ const all = [...latest.values()];
223
+ const group = (keys: (r: TaskRecord) => string[]) => {
224
+ // A Map: a peer or model may be named `constructor`, which a plain object answers with an inherited value.
225
+ const out = new Map<string, TaskRecord[]>();
226
+ for (const r of all) for (const k of keys(r)) out.set(k, [...(out.get(k) ?? []), r]);
227
+ return Object.fromEntries([...out].sort(([a], [b]) => a.localeCompare(b)).map(([k, rs]) => [k, measures(rs, labels)]));
228
+ };
229
+ return { overall: measures(all, labels), byClass: group((r) => [r.class ?? "unknown"]), byOwner: group((r) => r.owners.length ? [...new Set(r.owners)] : ["none"]),
230
+ byProject: group((r) => [r.project]), byModel: group((r) => r.models.length ? r.models : ["unknown"]) };
231
+ }
232
+
233
+ const fmtPct = (v: number | null) => v === null ? "-" : `${Math.round(v * 100)}%`;
234
+ const fmtNum = (v: number | null) => v === null ? "-" : String(Math.round(v));
235
+ export function formatResearch(r: ResearchReport): string[] {
236
+ const row = (name: string, m: Measures) => `${name.padEnd(24)} ${String(m.tasks).padStart(5)} ${fmtPct(m.successRate).padStart(7)} ${fmtPct(m.firstPassRate).padStart(10)} ${(m.reworkRate === null ? "-" : m.reworkRate.toFixed(2)).padStart(6)} ${fmtPct(m.checkFailureRate).padStart(11)} ${fmtNum(m.tokensPerTask.median).padStart(10)} ${fmtNum(m.tokensPerTask.p90).padStart(10)} ${(m.wallMsPerTask.median === null ? "-" : `${Math.round(m.wallMsPerTask.median / 60_000)}m`).padStart(8)}`;
237
+ const head = `${"".padEnd(24)} ${"tasks".padStart(5)} ${"success".padStart(7)} ${"first pass".padStart(10)} ${"rework".padStart(6)} ${"check fails".padStart(11)} ${"tokens p50".padStart(10)} ${"tokens p90".padStart(10)} ${"wall p50".padStart(8)}`;
238
+ const section = (title: string, groups: Record<string, Measures>) => Object.keys(groups).length ? [``, title, head, ...Object.entries(groups).map(([k, m]) => row(k, m))] : [];
239
+ return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject)];
240
+ }
241
+
242
+ export const CSV_COLUMNS = ["project", "task", "class", "pii", "outcome", "createdAt", "startedAt", "approvedAt", "wallMs", "activeMs", "owners",
243
+ "reviewer", "reviewRounds", "changesRequested", "checkPassed", "checkFailed", "dones", "reassignments", "firstPass", "stuck", "overlaps", "conflicts",
244
+ "testsPass", "testsFail", "tokens", "turns", "filesChanged", "models", "label", "writerVersion", "writerSource"] as const;
245
+ /** Task records as CSV with a header row, one row per record, each with the task's latest label; JSONL has every field. */
246
+ export function toCsv(records: ResearchRecord[]): string {
247
+ const labels = new Map<string, Label>();
248
+ for (const r of records) if (r.kind === "label") labels.set(recordKey(r), r.label);
249
+ const cell = (v: unknown) => { const s = v === null || v === undefined ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
250
+ const rows = records.filter((r): r is TaskRecord => r.kind === "task").map((r) => [r.project, r.task, r.class, r.pii, r.outcome, r.createdAt, r.startedAt,
251
+ r.approvedAt, r.wallMs, r.activeMs, r.owners.join(" "), r.reviewer, r.reviewRounds, r.changesRequested, r.checkPassed, r.checkFailed, r.dones,
252
+ r.reassignments, r.firstPass, r.stuck, r.overlaps, r.conflicts, r.tests.pass, r.tests.fail, r.tokens.total, r.turns, r.filesChanged, r.models.join(" "),
253
+ labels.get(recordKey(r)) ?? "", r.writer.version, r.writer.source].map(cell).join(","));
254
+ return [CSV_COLUMNS.join(","), ...rows].join("\n") + "\n";
255
+ }
package/src/hub/tasks.ts CHANGED
@@ -9,6 +9,7 @@ import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignm
9
9
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
10
10
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
11
11
  import { realPath } from "./project.ts";
12
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
12
13
  import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
13
14
 
14
15
  export interface TasksDeps {
@@ -470,7 +471,7 @@ export class Tasks {
470
471
  async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
471
472
  // Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
472
473
  const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
473
- if (!title) throw new Error("title is required");
474
+ if (!title) throw new PreEffectToolRefusal("title is required");
474
475
  // Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
475
476
  // Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
476
477
  let afterIds: unknown[] = [];
@@ -479,14 +480,14 @@ export class Tasks {
479
480
  const deps: number[] = [];
480
481
  for (const v of afterIds) {
481
482
  const id = typeof v === "number" || (typeof v === "string" && /^\s*\d+\s*$/.test(v)) ? Number(v) : NaN;
482
- if (!Number.isInteger(id)) throw new Error(`after: ${JSON.stringify(v)} is not a task id`);
483
- if (!this.d.board.get(id)) throw new Error(`after: no task #${id}`);
483
+ if (!Number.isInteger(id)) throw new PreEffectToolRefusal(`after: ${JSON.stringify(v)} is not a task id`);
484
+ if (!this.d.board.get(id)) throw new PreEffectToolRefusal(`after: no task #${id}`);
484
485
  if (!deps.includes(id)) deps.push(id);
485
486
  }
486
487
  // An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
487
488
  const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
488
489
  const given = input.class === undefined || input.class === "" ? undefined : input.class;
489
- if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new Error(`class must be one of ${CLASSES.join(", ")}`);
490
+ if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new PreEffectToolRefusal(`class must be one of ${CLASSES.join(", ")}`);
490
491
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
491
492
  const plan = cleanPlan(input.plan);
492
493
  // The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
@@ -511,7 +512,7 @@ export class Tasks {
511
512
  // A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
512
513
  const defaulted = !cls && input.owner === by;
513
514
  if (defaulted) cls = "implement";
514
- if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
515
+ if (!cls) throw new PreEffectToolRefusal(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
515
516
  const draft = { ...text, class: cls };
516
517
  let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals });
517
518
  if (screening) task = this.screenedTask(task, screened);
@@ -902,26 +903,47 @@ export class Tasks {
902
903
  }
903
904
 
904
905
  /** Nobody, the console user included, works on a task before what it waits for is approved. */
905
- private ready(task: Task): void {
906
+ private ready(task: Task, preEffect = false): void {
906
907
  const waits = this.waitsFor(task);
907
- if (waits.length) throw new Error(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
908
+ if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
908
909
  }
909
910
 
910
911
  /** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
911
912
  private async releaseDependents(approved: Task): Promise<void> {
912
- for (const t of this.d.board.list("proposed")) {
913
- if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
914
- await this.offerReady(t, `#${approved.id} approved`);
913
+ try {
914
+ for (const t of this.d.board.list("proposed")) {
915
+ if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
916
+ await this.offerReady(t, `#${approved.id} approved`);
917
+ }
918
+ } catch (e) {
919
+ this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
915
920
  }
916
921
  }
917
922
 
918
- private readonly offered = new Set<number>(); // ready tasks offered in this hub run
923
+ /**
924
+ * The one guard for a publish or notify after a board write (#231, #243): the write took effect, so a failure
925
+ * never throws back to the caller. A failed publish becomes a console notice naming what was lost; a failed notify
926
+ * is dropped (a second attempt would fail the same way). A lost publish is not retried here: the delivery
927
+ * journal's own recovery covers a storage error.
928
+ */
929
+ private releaseNotice(line: string, run?: () => unknown): void {
930
+ if (!run) {
931
+ try { this.d.notify(line); } catch { /* best effort; the release timer retries ownerless work */ }
932
+ return;
933
+ }
934
+ try { run(); } catch (e) {
935
+ try { this.d.notify(`${line}: ${e instanceof Error ? e.message : String(e)}`); } catch { /* best effort */ }
936
+ }
937
+ }
938
+
939
+ private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
919
940
  private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
920
941
 
921
942
  /**
922
943
  * A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless
923
944
  * with nothing left to wait for, and no later approval to offer them. The daemon calls this on its release timer:
924
- * each such task is offered once per hub run, once an attached peer can take it (peers attach one by one).
945
+ * each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
946
+ * up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
925
947
  */
926
948
  async releaseReady(): Promise<void> {
927
949
  for (const t of this.d.board.list("proposed")) {
@@ -936,26 +958,41 @@ export class Tasks {
936
958
 
937
959
  /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
938
960
  private async offerReady(stale: Task, why: string): Promise<void> {
939
- const t = this.d.board.get(stale.id);
940
- if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
941
- this.offered.add(t.id);
942
- const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
943
- this.d.notify(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
944
- await this.assignOwner(ready, HUB).catch((e: Error) => this.d.notify(`task ${this.publicTitle(ready)}: could not be assigned: ${e.message}`));
961
+ let offering = false;
962
+ try {
963
+ const t = this.d.board.get(stale.id);
964
+ if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
965
+ this.offered.add(t.id);
966
+ offering = true;
967
+ const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
968
+ this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
969
+ await this.assignOwner(ready, HUB);
970
+ } catch (e) {
971
+ this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
972
+ } finally {
973
+ if (offering) {
974
+ try {
975
+ if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
976
+ } catch (e) {
977
+ this.offered.delete(stale.id);
978
+ this.releaseNotice(`task ${this.publicTitle(stale)}: could not verify assignment: ${e instanceof Error ? e.message : String(e)}`);
979
+ }
980
+ }
981
+ }
945
982
  }
946
983
 
947
- private mine(task: Task, by: PeerId, role: "owner" | "reviewer"): void {
948
- if (by !== USER && task[role] !== by) throw new Error(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
984
+ private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
985
+ if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
949
986
  }
950
987
 
951
- private need(id: unknown, open = false): Task {
988
+ private need(id: unknown, open = false, preEffect = false): Task {
952
989
  // Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
953
990
  const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
954
- if (!Number.isInteger(n)) throw new Error(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
991
+ if (!Number.isInteger(n)) throw new (preEffect ? PreEffectToolRefusal : Error)(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
955
992
  const task = this.d.board.get(n);
956
- if (!task) throw new Error(`no task #${n}`);
993
+ if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
957
994
  // Handing a task to someone else only makes sense while there is work left on it.
958
- if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
995
+ if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
959
996
  return task;
960
997
  }
961
998
 
@@ -964,15 +1001,16 @@ export class Tasks {
964
1001
  * announced like one found at assignment (issue #31).
965
1002
  */
966
1003
  accept(by: PeerId, id: unknown, plan?: unknown): Task {
967
- const task = this.need(id);
968
- this.mine(task, by, "owner");
969
- this.ready(task);
1004
+ const task = this.need(id, false, true);
1005
+ this.mine(task, by, "owner", true);
1006
+ this.ready(task, true);
1007
+ if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
970
1008
  // Models send null or {} for an optional field they leave empty: neither replaces a plan.
971
1009
  const given = plan == null ? undefined : cleanPlan(plan);
972
1010
  const cleaned = given && Object.keys(given).length ? given : undefined;
973
1011
  // A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
974
1012
  if (cleaned && !this.isPii(task) && this.isPii({ signals: detectSignals({ title: "", detail: planText(cleaned), refs: {} }, this.d.routing(), this.d.cwd) })) {
975
- throw new Error("this plan matches a PII pattern and is not kept: other owners would see it");
1013
+ throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
976
1014
  }
977
1015
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
978
1016
  const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}) });
@@ -1031,8 +1069,8 @@ export class Tasks {
1031
1069
  };
1032
1070
 
1033
1071
  async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
1034
- const task = this.need(id, true);
1035
- this.mine(task, by, "owner");
1072
+ const task = this.need(id, true, true);
1073
+ this.mine(task, by, "owner", true);
1036
1074
  // The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
1037
1075
  const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason, task.owner ? { from: task.owner } : {});
1038
1076
  this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
@@ -1051,13 +1089,13 @@ export class Tasks {
1051
1089
 
1052
1090
  async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
1053
1091
  const valid = (): Task => {
1054
- const t = this.need(id);
1055
- this.mine(t, by, "owner");
1056
- if (t.state === "in_review" || t.state === "approved") throw new Error(`task #${t.id} is already ${t.state}`);
1057
- this.ready(t);
1092
+ const t = this.need(id, false, true);
1093
+ this.mine(t, by, "owner", true);
1094
+ if (t.state === "in_review" || t.state === "approved") throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
1095
+ this.ready(t, true);
1058
1096
  if (this.checking.has(t.id)) {
1059
1097
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
1060
- throw new Error(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
1098
+ throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
1061
1099
  }
1062
1100
  return t;
1063
1101
  };
@@ -1098,7 +1136,7 @@ export class Tasks {
1098
1136
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
1099
1137
  this.checking.set(task.id, task.owner);
1100
1138
  const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
1101
- this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
1139
+ this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
1102
1140
  const seen = { events: pending.history.length, owner: pending.owner };
1103
1141
  this.pendingChecks++;
1104
1142
  this.checkQueue = this.checkQueue
@@ -1228,11 +1266,11 @@ export class Tasks {
1228
1266
  this.tellCompleted(next, summary, withheld);
1229
1267
  if (!reviewer) {
1230
1268
  if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
1231
- this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
1269
+ this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
1232
1270
  await this.releaseDependents(next);
1233
1271
  }
1234
- else if (reviewer === USER) this.d.notify(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
1235
- else this.sendReview(next, reviewer);
1272
+ else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
1273
+ else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer));
1236
1274
  return next;
1237
1275
  }
1238
1276
 
@@ -1252,7 +1290,8 @@ export class Tasks {
1252
1290
  cohort.held.add(task.id);
1253
1291
  continue;
1254
1292
  }
1255
- this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld));
1293
+ this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${hit.task.owner}`, () =>
1294
+ this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld)));
1256
1295
  }
1257
1296
  }
1258
1297
 
@@ -1306,10 +1345,10 @@ export class Tasks {
1306
1345
 
1307
1346
  async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
1308
1347
  const valid = (): Task => {
1309
- const t = this.need(id);
1310
- this.mine(t, by, "reviewer");
1311
- if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
1312
- if (t.state !== "in_review") throw new Error(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
1348
+ const t = this.need(id, false, true);
1349
+ this.mine(t, by, "reviewer", true);
1350
+ if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
1351
+ if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
1313
1352
  return t;
1314
1353
  };
1315
1354
  const items = textList(unmet);
@@ -1330,7 +1369,8 @@ export class Tasks {
1330
1369
  }
1331
1370
  }
1332
1371
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
1333
- this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii);
1372
+ this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
1373
+ this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
1334
1374
  await this.releaseDependents(next);
1335
1375
  return next;
1336
1376
  }
@@ -1343,17 +1383,19 @@ export class Tasks {
1343
1383
  const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
1344
1384
  if (moved.owner !== rejected.owner) return moved;
1345
1385
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
1346
- this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
1386
+ this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
1387
+ this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii));
1347
1388
  return moved;
1348
1389
  }
1349
1390
  const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
1350
- this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii);
1391
+ this.releaseNotice(`task ${this.publicTitle(reopened)}: could not tell ${reopened.owner ?? "its owner"} changes were requested`, () =>
1392
+ this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii));
1351
1393
  return reopened;
1352
1394
  }
1353
1395
 
1354
1396
  /** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
1355
- async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual"): Promise<Task> {
1356
- let task = this.need(id, true);
1397
+ async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
1398
+ let task = this.need(id, true, preEffect);
1357
1399
  const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
1358
1400
  if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
1359
1401
  const from = task.owner;
@@ -1364,16 +1406,18 @@ export class Tasks {
1364
1406
  if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
1365
1407
  const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
1366
1408
  if (next.owner && next.owner !== from) {
1367
- this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
1409
+ // The move is on the board (#243): review() reaches here after its changes_requested write.
1410
+ this.releaseNotice(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
1368
1411
  this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
1369
- if (from) this.tell({ ...next, owner: from }, `Task #${next.id} moved to ${next.owner} (${why}). Stop working on it.`, this.isPii(next));
1370
- } else this.d.notify(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
1412
+ if (from) this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${from} it moved to ${next.owner}`, () =>
1413
+ this.tell({ ...next, owner: from }, `Task #${next.id} moved to ${next.owner} (${why}). Stop working on it.`, this.isPii(next)));
1414
+ } else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
1371
1415
  return this.d.board.get(next.id)!;
1372
1416
  }
1373
1417
 
1374
1418
  /** Caller authority is checked by the daemon; keep the real actor in task history. */
1375
- async assignTo(id: unknown, peer: PeerId, by: PeerId = USER): Promise<Task> {
1376
- const task = this.need(id, true);
1419
+ async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false): Promise<Task> {
1420
+ const task = this.need(id, true, preEffect);
1377
1421
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
1378
1422
  if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
1379
1423
  const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
@@ -1448,7 +1492,7 @@ export class Tasks {
1448
1492
  if (this.isPii(task) || !this.d.memory) return;
1449
1493
  // claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
1450
1494
  // not clear (#198), is not sent (#69).
1451
- if (withheld || !this.nameable(text)) return this.d.notify(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
1495
+ if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
1452
1496
  void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
1453
1497
  }
1454
1498
 
@@ -0,0 +1,7 @@
1
+ /** A hub guard proved this request was refused before any task or board effect (screen telemetry is allowed). Never wrap arbitrary failures in this type. */
2
+ export class PreEffectToolRefusal extends Error {
3
+ constructor(message: string) {
4
+ super(message);
5
+ this.name = "PreEffectToolRefusal";
6
+ }
7
+ }
package/src/hub/ui.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { taskProgress } from "../ui/task-progress.ts";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
 
@@ -20,7 +21,9 @@ export function startDashboard(options: DashboardOptions) {
20
21
  const now = options.now ?? Date.now;
21
22
  const tickets = new Map<string, number>();
22
23
  const sessions = new Map<string, number>();
23
- const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8");
24
+ // Inject the same pure public-board model used by the console before computing CSP hashes.
25
+ const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
26
+ .replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`);
24
27
  const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
25
28
  .map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
26
29
  const headers = {