@staix/agent-hub 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -3
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +4 -1
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -3
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/design/adapters.md +56 -0
  11. package/docs/design/agent-hub-architecture.md +12 -0
  12. package/docs/design/bus.md +8 -0
  13. package/docs/design/task-board.md +19 -1
  14. package/docs/dogfood.md +13 -7
  15. package/docs/operations.md +177 -20
  16. package/docs/quickstart.md +18 -3
  17. package/docs/sdlc.md +2 -2
  18. package/docs/security.md +45 -4
  19. package/docs/smoke.md +13 -2
  20. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  21. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  22. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  23. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  24. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  25. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  26. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  27. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  28. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  29. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  30. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  31. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  32. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  33. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  34. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  35. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  36. package/docs/verification/pi-150-model-free.ts +57 -0
  37. package/docs/verification/pi-150-native.mjs +21 -0
  38. package/docs/verification/pi-150-title-api.mjs +31 -0
  39. package/docs/verified.json +14 -14
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +715 -45
  43. package/src/adapters/compat.ts +10 -10
  44. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  45. package/src/adapters/local-worker.ts +22 -6
  46. package/src/adapters/pi.ts +92 -10
  47. package/src/adapters/registry-types.ts +4 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/cli/autonomy.ts +66 -1
  50. package/src/cli/console-state.ts +6 -2
  51. package/src/cli/help.ts +3 -1
  52. package/src/cli/identity.ts +8 -1
  53. package/src/cli/main.ts +39 -0
  54. package/src/cli/output.ts +5 -0
  55. package/src/cli/recovery-package.ts +9 -0
  56. package/src/cli/worktree-tidy.ts +158 -0
  57. package/src/cli/worktrees-output.ts +43 -0
  58. package/src/hub/board.ts +31 -5
  59. package/src/hub/bus.ts +4 -4
  60. package/src/hub/conductor.ts +4 -3
  61. package/src/hub/config-apply.ts +2 -1
  62. package/src/hub/control-client.ts +16 -1
  63. package/src/hub/control-write-trace.ts +409 -0
  64. package/src/hub/daemon.ts +408 -66
  65. package/src/hub/dogfood-read.ts +62 -9
  66. package/src/hub/hub-tools.ts +12 -6
  67. package/src/hub/manager.ts +9 -1
  68. package/src/hub/peer-doing.ts +33 -3
  69. package/src/hub/peers.ts +7 -0
  70. package/src/hub/routing.ts +9 -2
  71. package/src/hub/runtime-capture-prune.ts +4 -1
  72. package/src/hub/sdlc-release-authorization.ts +13 -4
  73. package/src/hub/sdlc-release.ts +23 -10
  74. package/src/hub/sdlc.ts +15 -2
  75. package/src/hub/settings.ts +3 -0
  76. package/src/hub/task-worktree.ts +331 -0
  77. package/src/hub/tasks.ts +192 -25
  78. package/src/hub/ui.ts +2 -0
  79. package/src/hub/worktree-prs.ts +100 -0
  80. package/src/hub/worktree-shelf.ts +246 -0
  81. package/src/local/deny.ts +49 -0
  82. package/src/local/tools.ts +4 -2
  83. package/src/memory/brief.ts +5 -0
  84. package/src/memory/capture.ts +1 -1
  85. package/src/memory/client.ts +5 -2
  86. package/src/pi/extension.ts +43 -3
  87. package/src/ui/index.html +133 -10
  88. package/src/ui/office-model.ts +64 -8
  89. package/src/ui/office-space.ts +1 -0
  90. package/src/ui/plugin.ts +14 -3
package/src/cli/main.ts CHANGED
@@ -105,6 +105,8 @@ import { benchPreflight, defaultIo, runBench } from "./bench.ts";
105
105
  import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
106
106
  import { goalCommand, memoryCommand, memoryStaleAnchorsCommand } from "./memory.ts";
107
107
  import { runSpecs, runSpecsStamp } from "./specs.ts";
108
+ import { confirmTidy, planTidy, realTidyGit, rowsFromReply, type TidyGit } from "./worktree-tidy.ts";
109
+ import { renderTidyPreview, renderTidyResult, renderWorktrees } from "./worktrees-output.ts";
108
110
  import { formatMemoryStaleAnchors } from "../hub/memory-stale-anchors.ts";
109
111
  import { AGENT_MEM_VERSION, assertAgentMemVersion, selectedMemoryProfile } from "../hub/memory-profile.ts";
110
112
  import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
@@ -2003,6 +2005,18 @@ const commands: Record<string, () => Promise<void> | void> = {
2003
2005
  } finally { hub.close(); }
2004
2006
  },
2005
2007
 
2008
+ title: async () => {
2009
+ const [peer, ...words] = args;
2010
+ if (!peer || !words.length) fail("usage: ahub title <peer> <text...>");
2011
+ const hub = await connect();
2012
+ try {
2013
+ const reply = await hub.request({ t: "title", peer, title: words.join(" ") }, 35_000).catch(() => ({ ok: false, error: "session title result is unconfirmed; the title may have been applied; check ahub status before retrying" }));
2014
+ if (reply.ok === false && reply.error === "no answer from the hub within 35 s") fail("session title request timed out; the title may have been applied; check ahub status before retrying");
2015
+ if (reply.ok !== true) fail(String(reply.error ?? `session title refused: ${String(reply.reason ?? "invalid")}; refresh ahub status, then retry`));
2016
+ console.log(`${peer}: session title updated`);
2017
+ } finally { hub.close(); }
2018
+ },
2019
+
2006
2020
  permission: async () => {
2007
2021
  const positional = args.filter(arg => arg !== "--yes");
2008
2022
  const [peer, mode] = positional;
@@ -2444,6 +2458,31 @@ const commands: Record<string, () => Promise<void> | void> = {
2444
2458
  process.exitCode = await runSpecsStamp(args.slice(1), cwd, loadConfig(cwd).specs, { isTTY: !!rl, ask: async question => /^y(es)?$/i.test((await rl!.question(`${question} [y/N] `)).trim()) });
2445
2459
  } finally { rl?.close(); }
2446
2460
  },
2461
+ // #1057: `ahub worktrees [--json]` lists live and settled worktrees; `tidy` previews what is safe to remove; `tidy --confirm <fingerprint>` removes it, in THIS process,
2462
+ // never the daemon's. `--confirm` is console class (classifyPeerCommand) and refused under --live-test, so an agent can only list and preview.
2463
+ worktrees: async () => {
2464
+ const options = outputOptions();
2465
+ const rest = outputArguments();
2466
+ const sub = rest[0] === "tidy" ? "tidy" : undefined;
2467
+ const flags = sub ? rest.slice(1) : rest;
2468
+ const at = flags.indexOf("--confirm");
2469
+ const fingerprint = at >= 0 ? flags[at + 1] : undefined;
2470
+ const known = flags.every((arg, i) => arg === "--confirm" || i === at + 1 && at >= 0);
2471
+ if ((rest.length && !sub && rest.some(arg => !arg.startsWith("--"))) || !known || (at >= 0 && (!sub || fingerprint === undefined || fingerprint.startsWith("--"))) || (sub === undefined && at >= 0)) fail("usage: ahub worktrees [--json] | tidy [--json] | tidy --confirm <fingerprint>");
2472
+ const hub = await connect();
2473
+ const reply = await hub.request({ t: "worktrees" }, 20_000);
2474
+ hub.close();
2475
+ if (!reply.ok) return fail(String(reply.error ?? "the worktree shelf is unavailable"));
2476
+ if (!sub) return options.json ? console.log(JSON.stringify({ live: reply.live, settled: reply.settled, complete: reply.complete }, null, 2)) : printOutput(renderWorktrees(reply, options.columns, Date.now()), options.color);
2477
+ // tidy: read-only git in this process to decide what is safe; the daemon only said which rows are settled.
2478
+ const git: TidyGit = realTidyGit((argv, dir) => { const run = hubGitSync(argv, { cwd: dir, encoding: "utf8", timeout: 20_000, maxBuffer: 4 * 1024 * 1024, env: hubGitEnv(process.env, { repositoryIsolation: true }) }); return run.error ? undefined : { status: run.status, stdout: String(run.stdout ?? "") }; });
2479
+ const plan = planTidy(rowsFromReply(reply.settled ?? [], cwd, git), cwd, git);
2480
+ if (at < 0) return options.json ? console.log(JSON.stringify({ entries: plan.entries, removable: plan.removable, fingerprint: plan.fingerprint }, null, 2)) : printOutput(renderTidyPreview(plan, options.columns), options.color);
2481
+ const result = confirmTidy(plan, fingerprint!, cwd, git);
2482
+ if (!result.ok) return fail(result.error);
2483
+ if (options.json) return console.log(JSON.stringify({ outcomes: result.outcomes }, null, 2));
2484
+ printOutput(renderTidyResult(result.outcomes, options.columns), options.color);
2485
+ },
2447
2486
  usage: async () => {
2448
2487
  const options = outputOptions();
2449
2488
  const rest = outputArguments();
package/src/cli/output.ts CHANGED
@@ -14,6 +14,7 @@ import type { SpecsStatus } from "../hub/specs-task.ts";
14
14
 
15
15
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
16
16
  export interface StatusOutput {
17
+ controlWriteTiming?: { active: number; slowRequests: number; blockedLoopSamples: number; droppedActive: number; droppedRecent: number; droppedLogs: number; droppedSpans?: number; droppedMetadata?: number; expiredRequests?: number };
17
18
  specs?: SpecsStatus & { missingBlockHint?: boolean };
18
19
  queueBackpressure?: QueueBackpressureStatus;
19
20
  pendingRestart?: readonly { key: string; reason: string }[];
@@ -213,6 +214,10 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
213
214
  if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
214
215
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
215
216
  if (data.cwd) out.push(...lines(data.cwd, columns));
217
+ const timing = data.controlWriteTiming;
218
+ if (full && timing && Object.values(timing).every(value => Number.isSafeInteger(value) && value >= 0)) {
219
+ out.push(...lines(`control writes: active ${timing.active}, slow ${timing.slowRequests}, loop delays ${timing.blockedLoopSamples}, dropped ${timing.droppedActive + timing.droppedLogs + (timing.droppedSpans ?? 0) + (timing.droppedMetadata ?? 0)}, expired ${timing.expiredRequests ?? 0}; timings in hub.log`, columns, timing.slowRequests || timing.blockedLoopSamples ? "attention" : undefined));
220
+ }
216
221
  const specs = data.specs;
217
222
  if (specs && [specs.specCount, specs.documents, specs.errors, specs.stale].every(value => Number.isSafeInteger(value) && value >= 0)
218
223
  && (specs.specCount > 0 || specs.documents > 0 || specs.errors > 0)) {
@@ -127,6 +127,9 @@ export function verifyRecoveryTarget(kind: "restart" | "upgrade", root: string,
127
127
  return verifyPackage(root, version, recoveryPackageOptions(kind, root));
128
128
  }
129
129
 
130
+ /** #1120: the documentation checker that `ahub sdlc authorize-release` and release readiness spawn. A capture without it can never record release consent. */
131
+ export const RELEASE_CHECKER = "scripts/check-docs.mjs";
132
+ // ponytail: the checker is copied and kept by prune, but is not in packageDigest. Widening the digest would make every release staged before this change refuse as "cached release changed". Ceiling: a tampered checker in a capture is not detected by the digest. Upgrade path: fold it into packageDigest with a digest-version bump once no pre-#1120 capture or cached release remains.
130
133
  const RUNTIME_ASSETS = ["src", "plugins", "templates", ".claude-plugin", "package.json", "README.md", "LICENSE", "CHANGELOG.md"] as const;
131
134
  function runtimeDirectory(directory: string): void {
132
135
  let info;
@@ -159,6 +162,12 @@ function preserveRuntimeAssets(sourceRoot: string, destination: string, expected
159
162
  mkdirSync(temporary, { mode: 0o700 });
160
163
  try {
161
164
  for (const name of RUNTIME_ASSETS) cpSync(join(sourceRoot, name), join(temporary, name), { recursive: true, dereference: false });
165
+ // Absent in a source that predates the checker (a registry package before #1120 ships it): the capture then reports the checker as unavailable, never a stale document.
166
+ if (existsSync(join(sourceRoot, RELEASE_CHECKER))) {
167
+ if (!lstatSync(join(sourceRoot, RELEASE_CHECKER)).isFile()) throw new Error(`${RELEASE_CHECKER} must be a regular file to be preserved in a runtime capture; replace it with a regular file`);
168
+ mkdirSync(join(temporary, dirname(RELEASE_CHECKER)), { recursive: true });
169
+ cpSync(join(sourceRoot, RELEASE_CHECKER), join(temporary, RELEASE_CHECKER), { dereference: false });
170
+ }
162
171
  preserveRuntimeDependencies(sourceRoot, temporary);
163
172
  if (digest(temporary, true) !== expectedDigest || digest(sourceRoot) !== expectedDigest)
164
173
  throw new Error("source package changed while being preserved");
@@ -0,0 +1,158 @@
1
+ import { createHash } from "node:crypto";
2
+ import { statSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+
5
+ /**
6
+ * #1057 D5: `ahub worktrees tidy`. This module is the ONLY place a worktree or a local branch is removed, and it runs in the person's own CLI
7
+ * process: the daemon never imports it (a test greps for that). A preview reads facts and runs read-only git; `--confirm <fingerprint>` re-reads
8
+ * everything, refuses the whole run if the row set changed, and otherwise runs exactly two argv shapes per row:
9
+ *
10
+ * git -C <root> worktree remove <path> (no --force: git itself refuses a dirty or locked tree)
11
+ * git -C <root> branch -d <branch> (never -D: git itself refuses an unmerged branch)
12
+ *
13
+ * No other removal argv exists here, `--force` and `-D` appear in no argv, and `assertRemovalArgv` checks the shape right before each call.
14
+ */
15
+
16
+ /** One settled row from the hub's `worktrees` reply, resolved against the project root by `rowFromReply`; `head` and `locked` come from git here, never from the daemon. */
17
+ export interface TidyRow { path: string; shown: string; branch: string | null; head: string; locked: boolean; reason: string; instance: boolean; taskState?: string;
18
+ /** The branch THIS process read from git's own list (`null` when detached), as opposed to `branch`, which is the daemon's word. They must agree or the row is refused. */
19
+ liveBranch?: string | null }
20
+ export type RefusalReason = "branch changed since preview; run tidy again" | "main checkout" | "instance worktree" | "dirty" | "locked" | "no branch" | "unpushed commits" | "no upstream" | "branch not merged into the default branch" | "task not approved or cancelled" | "path missing" | "outside the project" | "git could not tell";
21
+ export interface TidyEntry { path: string; shown: string; branch: string | null; reason: string; refused: RefusalReason[] }
22
+ export interface TidyPlan { entries: TidyEntry[]; removable: TidyEntry[]; fingerprint: string }
23
+
24
+ /** Read-only and removal git, bounded. `undefined` is a failed or timed-out call, never an empty answer. */
25
+ export interface TidyGit { run(cwd: string, args: string[]): { status: number | null; stdout: string } | undefined }
26
+
27
+ /** The only two removal argv shapes. A word outside them, a flag, or a path that starts with `-` refuses before git runs. */
28
+ export function removalArgv(root: string, path: string, branch: string): [string[], string[]] {
29
+ return [["-C", root, "worktree", "remove", path], ["-C", root, "branch", "-d", branch]];
30
+ }
31
+ export function assertRemovalArgv(argv: readonly string[]): void {
32
+ const [, , verb, action, target] = argv;
33
+ const worktree = argv.length === 5 && argv[0] === "-C" && verb === "worktree" && action === "remove";
34
+ const branch = argv.length === 5 && argv[0] === "-C" && verb === "branch" && action === "-d";
35
+ if (!(worktree || branch) || !target || target.startsWith("-") || argv.some(word => word === "--force" || word === "-f" || word === "-D" || word === "--delete")) throw new Error("refused: not one of the two tidy removal commands");
36
+ if (!argv[1] || argv[1].startsWith("-")) throw new Error("refused: the project root is not a path");
37
+ }
38
+
39
+ const branchOk = (branch: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(branch) && !branch.includes("..") && !branch.endsWith("/") && !branch.endsWith(".lock");
40
+ const out = (r: { status: number | null; stdout: string } | undefined): string | undefined => r && r.status === 0 ? r.stdout.trim() : undefined;
41
+ const raw = (r: { status: number | null; stdout: string } | undefined): string | undefined => r && r.status === 0 ? r.stdout : undefined;
42
+
43
+ /**
44
+ * The refusal reasons for one settled row, from read-only git. Every check that cannot be answered refuses ("git could not tell"): unknown is never safe.
45
+ * Rows the daemon already shelved as `parked` still pass through these checks: parking is the person's wish to hide a row, not proof it is safe to delete.
46
+ */
47
+ export function refusals(row: TidyRow, root: string, git: TidyGit, defaultBranch: string | undefined, exists: (path: string) => boolean = p => { try { return statSync(p).isDirectory(); } catch { return false; } }): RefusalReason[] {
48
+ const why: RefusalReason[] = [];
49
+ if (row.path === root) return ["main checkout"];
50
+ if (row.instance) return ["instance worktree"];
51
+ if (!row.path.startsWith(root + "/")) why.push("outside the project");
52
+ if (row.locked) why.push("locked");
53
+ if (!row.branch || !branchOk(row.branch)) why.push("no branch");
54
+ // The daemon named a branch and git, read here and now, says another (or none): the checks below would inspect the wrong branch and `branch -d` would target a stale name.
55
+ if (row.liveBranch !== undefined && row.liveBranch !== row.branch) why.push("branch changed since preview; run tidy again");
56
+ if (row.taskState !== undefined && row.taskState !== "approved" && row.taskState !== "cancelled") why.push("task not approved or cancelled");
57
+ if (!exists(row.path)) { why.push("path missing"); return why; }
58
+ const status = git.run(row.path, ["status", "--porcelain", "--untracked-files=all"]);
59
+ if (!status || status.status !== 0) why.push("git could not tell"); else if (status.stdout.trim() !== "") why.push("dirty");
60
+ if (row.branch && branchOk(row.branch)) {
61
+ const upstream = git.run(row.path, ["rev-parse", "--abbrev-ref", `${row.branch}@{upstream}`]);
62
+ if (!upstream) why.push("git could not tell");
63
+ else if (upstream.status !== 0) why.push("no upstream");
64
+ else {
65
+ const ahead = out(git.run(row.path, ["rev-list", "--count", `${upstream.stdout.trim()}..${row.branch}`]));
66
+ if (ahead === undefined) why.push("git could not tell"); else if (ahead !== "0") why.push("unpushed commits");
67
+ }
68
+ if (!defaultBranch) why.push("git could not tell");
69
+ else {
70
+ const merged = git.run(row.path, ["merge-base", "--is-ancestor", row.branch, defaultBranch]);
71
+ if (!merged) why.push("git could not tell"); else if (merged.status === 1) why.push("branch not merged into the default branch"); else if (merged.status !== 0) why.push("git could not tell");
72
+ }
73
+ }
74
+ return [...new Set(why)];
75
+ }
76
+
77
+ /** The repository's default branch from its origin HEAD, else `main` or `master` when exactly one exists locally. Read-only. */
78
+ export function defaultBranchOf(root: string, git: TidyGit): string | undefined {
79
+ const head = out(git.run(root, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]));
80
+ if (head && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._/-]+$/.test(head)) return head;
81
+ const local = ["main", "master"].filter(name => git.run(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`])?.status === 0);
82
+ return local.length === 1 ? local[0] : undefined;
83
+ }
84
+
85
+ /** The fingerprint covers every row's path, branch, HEAD and refusals, in path order: any change to the set or to a row changes it. */
86
+ export function fingerprintOf(entries: readonly { path: string; branch: string | null; head: string; refused: readonly string[]; liveBranch?: string | null }[]): string {
87
+ const hash = createHash("sha256");
88
+ for (const e of [...entries].sort((a, b) => a.path.localeCompare(b.path))) hash.update(JSON.stringify([e.path, e.branch, e.liveBranch ?? null, e.head, [...e.refused].sort()])).update("\n");
89
+ return hash.digest("hex").slice(0, 16);
90
+ }
91
+
92
+ /**
93
+ * The daemon shows project-relative paths and never an absolute one outside the project. A settled row whose path is outside the project (`outside: true`)
94
+ * or that names `..` cannot be resolved safely, so it becomes a refused row with no path to operate on.
95
+ */
96
+ export function rowsFromReply(settled: readonly { path?: unknown; branch?: unknown; reason?: unknown; outside?: unknown; taskState?: unknown }[], root: string, git: TidyGit): TidyRow[] {
97
+ const inventory = raw(git.run(root, ["worktree", "list", "--porcelain", "-z"]));
98
+ const listed = new Map<string, { head: string; locked: boolean; branch: string | null }>();
99
+ let current: { path: string; head: string; locked: boolean; branch: string | null } | undefined;
100
+ for (const field of (inventory ?? "").split("\0")) {
101
+ if (field === "") { if (current) listed.set(current.path, current); current = undefined; continue; }
102
+ if (field.startsWith("worktree ")) { if (current) listed.set(current.path, current); current = { path: field.slice(9), head: "", locked: false, branch: null }; }
103
+ else if (current && field.startsWith("HEAD ")) current.head = field.slice(5);
104
+ else if (current && field.startsWith("branch refs/heads/")) current.branch = field.slice(18); // `detached` has no branch field: it stays null
105
+ else if (current && (field === "locked" || field.startsWith("locked "))) current.locked = true;
106
+ }
107
+ if (current) listed.set(current.path, current);
108
+ return settled.flatMap(row => {
109
+ if (typeof row.path !== "string" || row.outside === true || row.path.includes("\0") || row.path.split("/").includes("..")) return [];
110
+ const absolute = row.path === "." ? root : resolve(join(root, row.path));
111
+ const fact = listed.get(absolute);
112
+ // Only a path git itself lists is operated on: the daemon's word alone never names a directory to remove.
113
+ if (!fact) return [];
114
+ return [{ path: absolute, shown: row.path, branch: typeof row.branch === "string" ? row.branch : null, head: fact.head, locked: fact.locked, liveBranch: fact.branch, reason: String(row.reason ?? ""), instance: false, ...(typeof row.taskState === "string" ? { taskState: row.taskState } : {}) }];
115
+ });
116
+ }
117
+
118
+ export function planTidy(rows: readonly TidyRow[], root: string, git: TidyGit): TidyPlan {
119
+ const defaultBranch = defaultBranchOf(root, git);
120
+ const detailed = rows.map(row => ({ row, refused: refusals(row, root, git, defaultBranch) }));
121
+ const entries: TidyEntry[] = detailed.map(({ row, refused }) => ({ path: row.path, shown: row.shown, branch: row.branch, reason: row.reason, refused }));
122
+ return { entries, removable: entries.filter(e => e.refused.length === 0), fingerprint: fingerprintOf(detailed.map(({ row, refused }) => ({ path: row.path, branch: row.branch, ...(row.liveBranch !== undefined ? { liveBranch: row.liveBranch } : {}), head: row.head, refused }))) };
123
+ }
124
+
125
+ export interface TidyOutcome { path: string; shown: string; branch: string; worktreeRemoved: boolean; branchDeleted: boolean; note?: string }
126
+ /**
127
+ * Re-read, compare, then remove. `fresh` is a second `planTidy` the caller took AFTER the person typed the fingerprint, so a worktree that
128
+ * changed in between refuses the whole run before anything is removed. Each removal is read back; a failed `worktree remove` skips its branch.
129
+ */
130
+ export function confirmTidy(fresh: TidyPlan, fingerprint: string, root: string, git: TidyGit): { ok: false; error: string } | { ok: true; outcomes: TidyOutcome[] } {
131
+ if (!/^[0-9a-f]{16}$/.test(fingerprint)) return { ok: false, error: "the fingerprint is 16 hex digits, from the preview line `fingerprint`" };
132
+ if (fresh.fingerprint !== fingerprint) return { ok: false, error: "the worktrees changed since the preview, so nothing was removed; run ahub worktrees tidy again and confirm the new fingerprint" };
133
+ if (!fresh.removable.length) return { ok: false, error: "nothing is safe to remove; see the refusals in the preview" };
134
+ const outcomes: TidyOutcome[] = [];
135
+ for (const entry of fresh.removable) {
136
+ const branch = entry.branch!;
137
+ const [removeWorktree, deleteBranch] = removalArgv(root, entry.path, branch);
138
+ assertRemovalArgv(removeWorktree); assertRemovalArgv(deleteBranch);
139
+ const removed = git.run(root, removeWorktree.slice(2));
140
+ const gone = removed?.status === 0 && git.run(root, ["worktree", "list", "--porcelain", "-z"])?.stdout.split("\0").every(field => field !== `worktree ${entry.path}`) === true;
141
+ if (!gone) { outcomes.push({ path: entry.path, shown: entry.shown, branch, worktreeRemoved: false, branchDeleted: false, note: "git refused or did not confirm removing the worktree; it and its branch were kept" }); continue; }
142
+ const deleted = git.run(root, deleteBranch.slice(2));
143
+ const branchGone = deleted?.status === 0 && git.run(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`])?.status === 1;
144
+ outcomes.push({ path: entry.path, shown: entry.shown, branch, worktreeRemoved: true, branchDeleted: branchGone, ...(branchGone ? {} : { note: "the worktree was removed; git kept the branch (not fully merged), which is safe" }) });
145
+ }
146
+ return { ok: true, outcomes };
147
+ }
148
+
149
+ /** Real git, bounded, run in this CLI process. Only the removal argv of `removalArgv` and the read shapes the checks above use reach it. */
150
+ export const realTidyGit = (spawn: (argv: string[], cwd: string) => { status: number | null; stdout: string } | undefined): TidyGit => ({
151
+ run(cwd, args) {
152
+ const write = args[0] === "worktree" && args[1] === "remove" || args[0] === "branch" && args[1] === "-d";
153
+ if (write) assertRemovalArgv(["-C", cwd, ...args]); // a removal always runs in the project root, with the worktree path or branch as its operand
154
+ // `--no-optional-locks`: a plain `git status` rewrites the worktree's own index file, which is exactly what the shelf reads as activity (D1). Without it a tidy
155
+ // preview would make every settled worktree look freshly edited, and it would leave the shelf on the next read. The preview must not change what it measures.
156
+ return spawn(["--no-optional-locks", "-C", cwd, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args], cwd);
157
+ },
158
+ });
@@ -0,0 +1,43 @@
1
+ // Human output for `ahub worktrees` and `ahub worktrees tidy` (#1057). Pure: data in, spans out, no I/O. `--json` is the caller's branch and prints the same data.
2
+ import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
3
+ import { moment } from "./sdlc-output.ts";
4
+ import type { Span } from "./console-state.ts";
5
+
6
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
7
+ const reasonLabel = (row: any): string => row?.reason === "merged" ? "PR merged" : row?.reason === "closed" ? "PR closed" : row?.reason === "quiet" ? "quiet" : row?.reason === "parked" ? "parked" : row?.reason === "settled" ? "settled" : "";
8
+ const taskLabel = (row: any): string => row?.private === true ? "task: private" : typeof row?.task === "number" ? `#${row.task}${typeof row.pr === "number" ? ` PR ${row.pr}` : ""}` : "";
9
+
10
+ /** `ahub worktrees`: live rows, then the shelf with each settled row's reason. */
11
+ export function renderWorktrees(reply: unknown, columns?: number, now = Date.now()): Span[][] {
12
+ const r = reply as any, live = list(r?.live), settled = list(r?.settled);
13
+ const rows = (items: any[], withReason: boolean) => items.map((row: any) => [
14
+ s(row.path), s(row.branch ?? "(detached)", "muted"), s(taskLabel(row), "issueRef"), ...(withReason ? [s(reasonLabel(row), "attention")] : []), s(moment(row.lastActiveAt, now), "muted"),
15
+ ]);
16
+ const out: Span[][] = [...heading(`Worktrees: ${live.length} live, ${settled.length} settled`, columns)];
17
+ if (live.length) out.push(...grid(["PATH", "BRANCH", "TASK", "ACTIVE"], rows(live, false), columns));
18
+ if (settled.length) out.push(blank, ...heading("Shelf (settled)", columns), ...grid(["PATH", "BRANCH", "TASK", "WHY", "ACTIVE"], rows(settled, true), columns));
19
+ if (!live.length && !settled.length) out.push([s("no worktrees", "muted")]);
20
+ if (r?.complete === false) out.push(blank, [s("the list was cut off at its bound; some worktrees are not shown", "attention")]);
21
+ if (settled.length) out.push(blank, [s("preview what could be removed with ahub worktrees tidy; nothing is removed until you confirm", "muted")]);
22
+ return out;
23
+ }
24
+
25
+ /** `ahub worktrees tidy`: each settled row that is safe to remove, each refused row with its reasons, and the fingerprint to confirm. */
26
+ export function renderTidyPreview(plan: unknown, columns?: number): Span[][] {
27
+ const p = plan as any, entries = list(p?.entries), removable = list(p?.removable), refused = entries.filter((e: any) => list(e.refused).length);
28
+ const out: Span[][] = [...heading(`Tidy preview: ${removable.length} safe to remove, ${refused.length} refused`, columns)];
29
+ if (removable.length) out.push(...grid(["PATH", "BRANCH", "WHY SETTLED"], removable.map((e: any) => [s(e.shown), s(e.branch ?? ""), s(reasonLabel(e))]), columns));
30
+ if (refused.length) out.push(blank, ...heading("Refused", columns), ...grid(["PATH", "BRANCH", "REFUSED BECAUSE"], refused.map((e: any) => [s(e.shown), s(e.branch ?? ""), s(list(e.refused).join("; "), "attention")]), columns));
31
+ if (!entries.length) out.push([s("nothing is on the shelf", "muted")]);
32
+ if (removable.length) out.push(blank, ...pair("fingerprint", String(p.fingerprint), columns, 12), [s(`confirm with: ahub worktrees tidy --confirm ${p.fingerprint}`, "muted")], [s("each removal runs git worktree remove and git branch -d, without force; git keeps anything it considers unsafe", "muted")]);
33
+ return out;
34
+ }
35
+
36
+ /** `ahub worktrees tidy --confirm`: what happened to each row. */
37
+ export function renderTidyResult(outcomes: unknown, columns?: number): Span[][] {
38
+ const rows = list(outcomes);
39
+ const out: Span[][] = [...heading(`Tidy: ${rows.filter((o: any) => o.worktreeRemoved).length} of ${rows.length} removed`, columns)];
40
+ out.push(...grid(["PATH", "BRANCH", "WORKTREE", "BRANCH"], rows.map((o: any) => [s(o.shown), s(o.branch), s(o.worktreeRemoved ? "removed" : "kept", o.worktreeRemoved ? "muted" : "attention"), s(o.branchDeleted ? "deleted" : "kept", o.branchDeleted ? "muted" : "attention")]), columns));
41
+ for (const o of rows.filter((x: any) => x.note)) out.push([s(`${o.shown}: ${o.note}`, "muted")]);
42
+ return out;
43
+ }
package/src/hub/board.ts CHANGED
@@ -11,6 +11,7 @@ import type { ReviewerPoolReason } from "./reviewer-pool.ts";
11
11
  import type { GitHubSource } from "./github-import.ts";
12
12
  import type { Database } from "./db-schema.ts";
13
13
  import { openHubDatabase, advanceHubDbSchema } from "./db-schema.ts";
14
+ import { controlWriteSpan } from "./control-write-trace.ts";
14
15
  import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
15
16
  import { HUB, USER, type PeerId } from "./envelope.ts";
16
17
  import type { SweepRecord } from "./task-sweep.ts";
@@ -431,11 +432,11 @@ export class Board {
431
432
 
432
433
  /** Keep the generation and business write atomic without changing Bun's reported row count. */
433
434
  private taskWrite<T extends { changes: number }>(write: () => T): T {
434
- return this.db.transaction(() => {
435
+ return controlWriteSpan("board-write", () => this.db.transaction(() => {
435
436
  const result = { ...write() };
436
437
  if (result.changes) this.db.run("UPDATE board_status_revision SET revision=revision+1 WHERE id=1");
437
438
  return result;
438
- })();
439
+ })());
439
440
  }
440
441
 
441
442
  statusRevision(): number {
@@ -473,6 +474,8 @@ export class Board {
473
474
  this.db.run("CREATE TABLE IF NOT EXISTS security_scans (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, repo TEXT NOT NULL, sources TEXT NOT NULL, counts TEXT NOT NULL, total INTEGER)");
474
475
  // #843 AC3: one row per (agent id, installed version, planner) already queued, so a restart never repeats it.
475
476
  this.db.run("CREATE TABLE IF NOT EXISTS agent_drift_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
477
+ // #1057 D2: the person's park or pin of one worktree. Facts only: whether a worktree is settled is derived at read time and never stored here.
478
+ this.db.run("CREATE TABLE IF NOT EXISTS worktree_override (path TEXT PRIMARY KEY, value TEXT NOT NULL CHECK(value IN ('settled','active')), at INTEGER NOT NULL)");
476
479
  // Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
477
480
  this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
478
481
  this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
@@ -1473,6 +1476,26 @@ export class Board {
1473
1476
  return this.db.query("INSERT OR IGNORE INTO agent_drift_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
1474
1477
  }
1475
1478
  forgetAgentDriftNotice(marker: string): void { this.db.query("DELETE FROM agent_drift_notices WHERE marker=?").run(marker); }
1479
+ /** #1057 D2: every override, newest first, bounded. Rows made inert by later activity are removed lazily by the next write (`setWorktreeOverride`). */
1480
+ worktreeOverrides(): { path: string; value: "settled" | "active"; at: number }[] {
1481
+ return this.db.query("SELECT path, value, at FROM worktree_override ORDER BY at DESC LIMIT 1024").all() as { path: string; value: "settled" | "active"; at: number }[];
1482
+ }
1483
+ /** One override per path; a new write replaces the old. `path` is the absolute host path the shelf listed, never a name a request invented. */
1484
+ setWorktreeOverride(path: string, value: "settled" | "active", at: number): void {
1485
+ if (typeof path !== "string" || !path.startsWith("/") || path.length > 4096 || path.includes("\0")) throw new Error("invalid worktree path");
1486
+ if (value !== "settled" && value !== "active") throw new Error("invalid worktree override");
1487
+ if (!Number.isFinite(at) || at <= 0) throw new Error("invalid worktree override time");
1488
+ this.db.query("INSERT INTO worktree_override(path, value, at) VALUES(?,?,?) ON CONFLICT(path) DO UPDATE SET value=excluded.value, at=excluded.at").run(path, value, at);
1489
+ }
1490
+ clearWorktreeOverride(path: string): boolean {
1491
+ return this.db.query("DELETE FROM worktree_override WHERE path=?").run(path).changes > 0;
1492
+ }
1493
+ /** The lazy removal D2 names: an override whose worktree has newer activity is inert, so a write deletes it. `active` maps path to its lastActiveAt. */
1494
+ pruneInertWorktreeOverrides(activity: ReadonlyMap<string, number>): number {
1495
+ let removed = 0;
1496
+ for (const row of this.worktreeOverrides()) { const last = activity.get(row.path); if (last !== undefined && last > row.at) removed += this.clearWorktreeOverride(row.path) ? 1 : 0; }
1497
+ return removed;
1498
+ }
1476
1499
  clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
1477
1500
  /** One bounded primary-key metadata read for a retry's saved replacement cause. */
1478
1501
  stall(task: number): TaskStall | undefined {
@@ -1511,6 +1534,9 @@ export class Board {
1511
1534
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
1512
1535
  const untouchedReturn = event === "backlog returned untouched" && patch.state === "backlog" && patch.owner === null && patch.reviewer === null
1513
1536
  && ["proposed", "in_progress"].includes(task.state) && expectedFlowRevision !== undefined && !!note?.trim() && !taskHasWorkEvidence(task);
1537
+ const returnPlanning: HistoryEntry["planning"] = untouchedReturn && Object.hasOwn(patch, "reserved")
1538
+ ? { revision: (task.planningRevision ?? 0) + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null } } : undefined;
1539
+ if (returnPlanning) patch = { ...patch, reserved: planningPatch({ reserved: patch.reserved }).reserved };
1514
1540
  // A new delivered head with a configured check is owner work again until that check finishes.
1515
1541
  const reviewCheck = task.sdlc?.merge?.state !== "merged" && event === "done (checking)" && ["in_review", "approved"].includes(task.state) && patch.state === "in_progress"
1516
1542
  && expectedFlowRevision !== undefined && (by === task.owner || by === "user") && patch.owner === undefined && (patch.reviewer === undefined || task.state === "approved" && !task.reviewer && patch.reviewer === "user")
@@ -1586,10 +1612,10 @@ export class Board {
1586
1612
  && priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
1587
1613
  const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
1588
1614
  ? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
1589
- const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("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.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1615
+ const history = [...previousHistory, { at: Date.now(), by, event, ...(returnPlanning ? { planning: returnPlanning } : {}), ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("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.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
1590
1616
  this.taskWrite(() => {
1591
- const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
1592
- .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), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);
1617
+ const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ?, planning_revision = ? WHERE id = ?")
1618
+ .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), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, returnPlanning?.revision ?? task.planningRevision ?? 0, id);
1593
1619
  if (result.changes) {
1594
1620
  if (next.codeSymbols) this.db.query("INSERT INTO task_code_symbols(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(id, JSON.stringify(next.codeSymbols));
1595
1621
  else this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(id);
package/src/hub/bus.ts CHANGED
@@ -218,7 +218,7 @@ export class Bus {
218
218
  }
219
219
 
220
220
  /** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
221
- onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
221
+ onDelivered?: (peer: PeerId, originals: Envelope[], deliveryId?: string) => void;
222
222
  /** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
223
223
  onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
224
224
  /** Final rendered journal payload, and its first positive acknowledgement. Observation cannot alter settlement. */
@@ -777,7 +777,7 @@ export class Bus {
777
777
  else this.journal.transition(id, state, receipt.reason);
778
778
  if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) {
779
779
  this.memoryDelivery(peer, id, before.out, "delivered", before.originals);
780
- this.delivered(peer, before.originals);
780
+ this.delivered(peer, before.originals, id);
781
781
  }
782
782
  if (state === "failed") {
783
783
  if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason, receipt.quota === true && before.state === "dispatching");
@@ -1258,8 +1258,8 @@ export class Bus {
1258
1258
  try { this.onMemoryDelivery?.(peer, id, out, field); } catch { /* optional telemetry never alters delivery */ }
1259
1259
  }
1260
1260
 
1261
- private delivered(peer: PeerId, originals: Envelope[]): void {
1262
- try { this.onDelivered?.(peer, originals); } catch { /* observation must never affect delivery settlement */ }
1261
+ private delivered(peer: PeerId, originals: Envelope[], deliveryId?: string): void {
1262
+ try { this.onDelivered?.(peer, originals, deliveryId); } catch { /* observation must never affect delivery settlement */ }
1263
1263
  }
1264
1264
 
1265
1265
  private deliveryFailed(peer: PeerId): void {
@@ -9,7 +9,7 @@ import { PreEffectToolRefusal } from "./tool-refusal.ts";
9
9
  import type { StartMode } from "./start-mode.ts";
10
10
  import type { HubEvent } from "./events.ts";
11
11
  import type { SupervisionFeed } from "./supervision.ts";
12
- import { DOING_STUB, type PeerDoing } from "./peer-doing.ts";
12
+ import { DOING_STUB, INCOMING_LABEL_NOTE, type PeerDoing } from "./peer-doing.ts";
13
13
  import type { Budget } from "./budget.ts";
14
14
  import { windowKind, type WindowKind } from "./window-kind.ts";
15
15
  import { supportsHeadlessInstances } from "./instances.ts";
@@ -144,11 +144,12 @@ export function publicConductorStatus(input: ConductorStatusInput, now = Date.no
144
144
  conductorHold: p.hold ? { actor: p.hold.actor, since: numberOrNull(p.hold.since) } : null,
145
145
  budgetPause: p.budgetPause ? { since: numberOrNull(p.budgetPause.since), resetsAt: numberOrNull(p.budgetPause.resetsAt) } : null,
146
146
  windows: p.windows === undefined ? null : p.windows.map((w) => ({ id: w.id, used: numberOrNull(w.used), resetsAt: numberOrNull(w.resetsAt), at: numberOrNull(w.at), source: w.source ?? null })),
147
- // #878: screened by the daemon, but written by the peer itself: framed as untrusted for the conductor agent.
147
+ // Native reports retain peer authorship; incoming message labels carry their own display provenance.
148
148
  doing: !p.doing ? null : p.doing.text === DOING_STUB ? { text: DOING_STUB, source: p.doing.source, at: numberOrNull(p.doing.at) }
149
+ : p.doing.source === "label" ? { text: sanitize(p.doing.text), source: "label", at: numberOrNull(p.doing.at), note: INCOMING_LABEL_NOTE, untrusted: true }
149
150
  : { text: sanitize(p.doing.text), source: p.doing.source, at: numberOrNull(p.doing.at), from: p.peer, untrusted: true },
150
151
  })),
151
- ...(input.peers.some(p => p.doing && p.doing.text !== DOING_STUB) ? { doingNote: DOING_NOTE } : {}),
152
+ ...(input.peers.some(p => p.doing && p.doing.source !== "label" && p.doing.text !== DOING_STUB) ? { doingNote: DOING_NOTE } : {}),
152
153
  taskCounts: Object.fromEntries(Object.entries(input.taskCounts).map(([state, count]) => [state, numberOrNull(count)])),
153
154
  approvals: input.approvals.map((a) => ({ peer: a.peer, tool: a.tool ?? null, ageMs: a.at === undefined || !Number.isFinite(a.at) ? null : Math.max(0, now - a.at) })),
154
155
  };
@@ -227,6 +227,7 @@ const schema: Record<string, Check> = {
227
227
  else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
228
228
  } },
229
229
  snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool, runners: researchRunners }), bench: block({ enabled: bool }),
230
+ worktrees: block({ quiet_days: number(0, 3650, true), alert_over: number(0, 100_000, true) }),
230
231
  peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
231
232
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
232
233
  start: (v, k) => { block({ agents: strings })(v, k); if (object(v, k).agents !== undefined) checkStartAgents(object(v, k).agents, `${k}.agents`); },
@@ -273,7 +274,7 @@ const restart: Record<string, string> = {
273
274
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
274
275
  text_projection: "Converter and cache ownership are bound at startup", corpus: "The corpus index is bound to its roots at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
275
276
  };
276
- const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet", "specs"]);
277
+ const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "worktrees", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet", "specs"]);
277
278
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
278
279
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
279
280
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
@@ -4,6 +4,13 @@ import { projectContext } from "./project.ts";
4
4
  import { newToolRequestId, TOOL_WRITE_FEATURE, toolWriteOperation, toolWriteUnknownReply, toolWriteRefusedReply } from "./tool-request.ts";
5
5
  import { externalAdapterMetadata, externalInstanceCatalog, type ExternalInstanceCatalog, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
6
6
  import { negotiateProtocol, protocolWindow, type ProtocolWindow } from "./protocol-window.ts";
7
+ import { ControlWriteTelemetry, controlWriteMetadata, controlWriteSpan } from "./control-write-trace.ts";
8
+
9
+ // The client has no authoritative task privacy view. Keep kind/timing only, never its args or task id.
10
+ const clientWriteTelemetry = new ControlWriteTelemetry({ emit: line => {
11
+ if (process.env.AHUB_CONTROL_WRITE_TIMING === "1") console.error(`[agent-hub] ${line}`);
12
+ } });
13
+ const publicClientTiming = () => true;
7
14
 
8
15
  export function stateDirFor(cwd: string): string {
9
16
  return projectContext(cwd).stateDir;
@@ -221,6 +228,14 @@ export class ControlClient {
221
228
 
222
229
  /** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
223
230
  request(msg: Record<string, unknown>, timeoutMs = 30_000, signal?: AbortSignal): Promise<any> {
231
+ if (process.env.AHUB_CONTROL_WRITE_TIMING !== "1") return this.requestBody(msg, timeoutMs, signal);
232
+ const metadata = controlWriteMetadata(msg);
233
+ if (!metadata) return this.requestBody(msg, timeoutMs, signal);
234
+ return clientWriteTelemetry.run({ kind: metadata.kind }, publicClientTiming, () => controlWriteSpan("client-wait", () =>
235
+ this.requestBody(msg, timeoutMs, signal)));
236
+ }
237
+
238
+ private requestBody(msg: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<any> {
224
239
  if (this.ws.readyState !== WebSocket.OPEN || this.closing && msg.t !== "request_cancel") return Promise.resolve({ ok: false, error: "hub connection is not open" });
225
240
  const invocation = msg.t === "task" && msg.op === "hub_invoke";
226
241
  const operation = toolWriteOperation(msg), write = operation !== undefined;
@@ -266,7 +281,7 @@ export class ControlClient {
266
281
  signal?.addEventListener("abort", aborted, { once: true });
267
282
  if (signal?.aborted) { aborted(); return; }
268
283
  timer = timeoutMs ? setTimeout(() => abandon(`no answer from the hub within ${Math.round(timeoutMs / 1000)} s`), timeoutMs) : undefined;
269
- try { this.ws.send(JSON.stringify({ ...wire, rid })); }
284
+ try { controlWriteSpan("client-send", () => this.ws.send(JSON.stringify({ ...wire, rid }))); }
270
285
  catch { abandon("hub request could not be sent"); }
271
286
  });
272
287
  }