@staix/agent-hub 0.27.0 → 0.29.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 (106) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +11 -3
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +9 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/tasks.md +7 -0
  8. package/docs/agent-notes/tests.md +7 -5
  9. package/docs/design/adapters.md +76 -0
  10. package/docs/design/agent-hub-architecture.md +67 -0
  11. package/docs/design/agent-hub.md +52 -0
  12. package/docs/design/bus.md +48 -0
  13. package/docs/design/task-board.md +86 -0
  14. package/docs/dogfood.md +21 -3
  15. package/docs/operations.md +370 -12
  16. package/docs/profiles.md +1 -0
  17. package/docs/quickstart.md +19 -3
  18. package/docs/sdlc.md +2 -2
  19. package/docs/security.md +24 -4
  20. package/docs/smoke.md +2 -0
  21. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  22. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  24. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  25. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  26. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  27. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  29. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  30. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  31. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  32. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  33. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  34. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  35. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  36. package/docs/verified.json +14 -14
  37. package/package.json +2 -2
  38. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  39. package/plugins/agent-hub/server.js +857 -125
  40. package/src/adapters/claude-channel.ts +12 -2
  41. package/src/adapters/compat.ts +1 -1
  42. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  43. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  45. package/src/adapters/jcode.ts +37 -4
  46. package/src/adapters/qwen-dual.ts +3 -2
  47. package/src/cli/agents.ts +4 -2
  48. package/src/cli/bench.ts +4 -2
  49. package/src/cli/board-read.ts +25 -0
  50. package/src/cli/claude-homes.ts +2 -2
  51. package/src/cli/console-state.ts +8 -3
  52. package/src/cli/help.ts +6 -5
  53. package/src/cli/identity.ts +9 -1
  54. package/src/cli/init-prompt.ts +179 -0
  55. package/src/cli/init-settings.ts +229 -0
  56. package/src/cli/init.ts +102 -9
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +106 -35
  59. package/src/cli/output.ts +19 -0
  60. package/src/cli/recovery-package.ts +9 -0
  61. package/src/cli/setup-homes.ts +18 -3
  62. package/src/cli/specs.ts +150 -0
  63. package/src/cli/terminal-recovery.ts +35 -11
  64. package/src/hub/board.ts +59 -5
  65. package/src/hub/bus.ts +54 -7
  66. package/src/hub/child-process.ts +6 -4
  67. package/src/hub/config-apply.ts +4 -1
  68. package/src/hub/config-edit.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -0
  70. package/src/hub/control-client.ts +43 -4
  71. package/src/hub/control-write-trace.ts +409 -0
  72. package/src/hub/cost-governance.ts +22 -6
  73. package/src/hub/crash.ts +20 -0
  74. package/src/hub/daemon.ts +470 -98
  75. package/src/hub/delivery-hints.ts +6 -3
  76. package/src/hub/delivery-journal.ts +14 -3
  77. package/src/hub/hub-tools.ts +10 -6
  78. package/src/hub/peers.ts +1 -1
  79. package/src/hub/review-brief.ts +18 -0
  80. package/src/hub/routing.ts +14 -3
  81. package/src/hub/runtime-capture-prune.ts +4 -1
  82. package/src/hub/sdlc-release-authorization.ts +13 -4
  83. package/src/hub/sdlc-release.ts +23 -10
  84. package/src/hub/sdlc.ts +15 -2
  85. package/src/hub/settings.ts +2 -0
  86. package/src/hub/specs-config.ts +43 -0
  87. package/src/hub/specs-task.ts +108 -0
  88. package/src/hub/specs.ts +1008 -0
  89. package/src/hub/task-list-page.ts +75 -0
  90. package/src/hub/task-sweep.ts +10 -1
  91. package/src/hub/task-worktree.ts +331 -0
  92. package/src/hub/tasks.ts +606 -53
  93. package/src/hub/terminal-open.ts +10 -5
  94. package/src/hub/tool-request.ts +131 -0
  95. package/src/local/deny.ts +49 -0
  96. package/src/local/tools.ts +4 -2
  97. package/src/memory/brief.ts +5 -0
  98. package/src/memory/client.ts +5 -2
  99. package/src/ui/office-model.ts +17 -1
  100. package/src/ui/office-space.ts +1 -1
  101. package/templates/config.json +1 -0
  102. package/templates/profiles/dev.json +1 -0
  103. package/templates/profiles/general.json +1 -0
  104. package/templates/profiles/research.json +1 -0
  105. package/templates/profiles/writing.json +1 -0
  106. package/templates/specs.block.md +5 -0
@@ -0,0 +1,75 @@
1
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
2
+
3
+ /** #1083: `hub_task_list` pages. 628 tasks were 2.4 MB, over every agent tool-output limit. */
4
+ export const LIST_DEFAULT_LIMIT = 50;
5
+ export const LIST_MAX_LIMIT = 200;
6
+
7
+ export interface ListQuery {
8
+ limit: number;
9
+ /** Keyset position: the `updated` and `id` of the last row of the previous page. */
10
+ after?: { updated: number; id: number };
11
+ /** Exactly these tasks, full rows, no paging. */
12
+ ids?: number[];
13
+ /** Full rows instead of the compact id/state/owner/reviewer/title row. */
14
+ detail: boolean;
15
+ }
16
+
17
+ const PAGING_KEYS = ["limit", "cursor", "ids", "detail"] as const;
18
+
19
+ /** True when the caller named any paging argument, whoever it is. */
20
+ export const hasPagingArgs = (args: Record<string, unknown>): boolean => PAGING_KEYS.some(key => args[key] !== undefined);
21
+
22
+ /**
23
+ * Read and validate the paging arguments. Every refusal names the cause and the next step, because it is the only signal a model gets for a rejected call.
24
+ * `agent` callers always page; the person's console keeps the full array unless it names a paging argument, so `ahub tasks` output stays stable.
25
+ */
26
+ export function parseListQuery(args: Record<string, unknown>, agent: boolean): ListQuery | undefined {
27
+ if (!agent && !hasPagingArgs(args)) return undefined;
28
+ const { limit, cursor, ids, detail } = args;
29
+ if (detail !== undefined && typeof detail !== "boolean") throw new PreEffectToolRefusal("detail must be true or false; omit it for compact rows (id, state, owner, reviewer, title)");
30
+ if (ids !== undefined) {
31
+ if (!Array.isArray(ids) || !ids.length || ids.length > LIST_MAX_LIMIT || ids.some(id => !Number.isSafeInteger(id) || (id as number) < 1) || new Set(ids).size !== ids.length) {
32
+ throw new PreEffectToolRefusal(`ids must be 1 to ${LIST_MAX_LIMIT} distinct positive task numbers; page with limit and cursor to read more than ${LIST_MAX_LIMIT} tasks`);
33
+ }
34
+ if (limit !== undefined || cursor !== undefined) throw new PreEffectToolRefusal("ids returns every task you list; drop limit and cursor, or drop ids to page the board");
35
+ return { limit: ids.length, ids: ids as number[], detail: true };
36
+ }
37
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || (limit as number) < 1 || (limit as number) > LIST_MAX_LIMIT)) {
38
+ throw new PreEffectToolRefusal(`limit must be a whole number from 1 to ${LIST_MAX_LIMIT} (default ${LIST_DEFAULT_LIMIT}); read the rest with the cursor the reply gives`);
39
+ }
40
+ let after: ListQuery["after"];
41
+ if (cursor !== undefined) {
42
+ const match = typeof cursor === "string" && cursor.length <= 40 ? /^([0-9]{1,16})\.([1-9][0-9]{0,15})$/.exec(cursor) : null;
43
+ if (!match) throw new PreEffectToolRefusal("cursor must be the `next` value from the previous hub_task_list reply, unchanged; start again without a cursor");
44
+ after = { updated: Number(match[1]), id: Number(match[2]) };
45
+ }
46
+ return { limit: (limit as number | undefined) ?? LIST_DEFAULT_LIMIT, ...(after ? { after } : {}), detail: detail === true };
47
+ }
48
+
49
+ export interface ListPage<T> {
50
+ rows: T[];
51
+ /** Rows that matched the filters, before the cursor and the limit. */
52
+ total: number;
53
+ /** Pass as `cursor` for the next page; absent on the last page. */
54
+ next?: string;
55
+ }
56
+
57
+ /** Newest updated first, ties by id descending, so a cursor stays valid while other tasks change. */
58
+ export function pageTasks<T extends { id: number; updated: number }>(rows: readonly T[], query: ListQuery): ListPage<T> {
59
+ const total = rows.length;
60
+ if (query.ids) {
61
+ const wanted = new Set(query.ids);
62
+ const found = rows.filter(row => wanted.has(row.id)).sort((a, b) => query.ids!.indexOf(a.id) - query.ids!.indexOf(b.id));
63
+ return { rows: found, total: found.length };
64
+ }
65
+ const ordered = [...rows].sort((a, b) => b.updated - a.updated || b.id - a.id);
66
+ const after = query.after;
67
+ const rest = after ? ordered.filter(row => row.updated < after.updated || row.updated === after.updated && row.id < after.id) : ordered;
68
+ const page = rest.slice(0, query.limit), last = page[page.length - 1];
69
+ return { rows: page, total, ...(rest.length > page.length && last ? { next: `${last.updated}.${last.id}` } : {}) };
70
+ }
71
+
72
+ /** What a compact row keeps: enough to choose a task, never a body, plan or history. */
73
+ export function compactRow(view: Record<string, unknown>): Record<string, unknown> {
74
+ return { id: view.id, state: view.state, owner: view.owner ?? null, reviewer: view.reviewer ?? null, title: view.title };
75
+ }
@@ -66,8 +66,13 @@ export function nextSweep(task: Task, config: TaskSweepConfig, now: number, read
66
66
  return { kind, activity, step: previous ? (previous.step + 1) as 2 | 3 : 1, at: now };
67
67
  }
68
68
 
69
+ /** A planned start is the earliest automatic start time; the card is eligible at that boundary. */
70
+ export function plannedStartPending(task: Pick<Task, "plannedStart">, now: number): boolean {
71
+ return task.plannedStart != null && (!Number.isFinite(now) || task.plannedStart > now);
72
+ }
73
+
69
74
  export interface GroomingItem {
70
- kind: "text-dependency" | "overlap" | "duplicate" | "over-wip" | "stale" | "superseded" | "blocked" | "closed-issue";
75
+ kind: "text-dependency" | "overlap" | "duplicate" | "over-wip" | "stale" | "superseded" | "blocked" | "closed-issue" | "scheduled";
71
76
  task: number;
72
77
  related?: number[];
73
78
  code?: string;
@@ -75,6 +80,8 @@ export interface GroomingItem {
75
80
  }
76
81
  export interface PlannerGroomingOptions {
77
82
  repository: string;
83
+ /** Host clock snapshot; omitted by legacy pure callers. */
84
+ now?: number;
78
85
  limits: Record<string, number>;
79
86
  wip: Record<string, number>;
80
87
  states: Record<string, string>;
@@ -162,6 +169,8 @@ export function plannerGrooming(tasks: readonly Task[], options: PlannerGrooming
162
169
  }
163
170
  if (replacements.size) add({ kind: "superseded", task: task.id, related: [...replacements].sort((a, b) => a - b) });
164
171
  if (task.state === "cancelled") continue; // Closed rows contribute supersession evidence only.
172
+ if ((task.state === "backlog" || task.state === "proposed" && !task.owner) && options.now !== undefined && plannedStartPending(task, options.now))
173
+ add({ kind: "scheduled", task: task.id });
165
174
  const issues = new Set<number>();
166
175
  for (const issue of [task.external?.issue, task.sdlc?.issue]) {
167
176
  if (issue === undefined) continue;
@@ -0,0 +1,331 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { isDenied } from "../local/deny.ts";
6
+ import { childEnv } from "./child-process.ts";
7
+ import type { PeerId } from "./envelope.ts";
8
+ import { hubGitArgv, hubGitEnv, hubGitSync } from "./git.ts";
9
+ import { grantablePath } from "./permission-mode.ts";
10
+ import { realPath } from "./project.ts";
11
+
12
+ /**
13
+ * #1115: a hub-sandboxed peer (Pi, the local worker) cannot run `git worktree add` or commit, because its profile refuses `.git` writes that
14
+ * matter (#270). The hub makes the task worktree for it at the fixed root `.worktrees/<peer>-<task>`, the peer edits files there, and the hub
15
+ * stages, commits and pushes at `hub_task_done`. The sandbox profile is NOT changed. It already lets a sandboxed command write
16
+ * `.git/worktrees/<name>/{HEAD,index}` and `.git/refs/heads` (#864 only closes config, hooks, commondir and packs), so this module never trusts
17
+ * what the peer could have written there: the base commit is recorded at creation, and a hub commit needs the worktree still registered at
18
+ * its path, HEAD still the task branch, and the branch tip still the recorded base (or the hub's own previous commit).
19
+ */
20
+ const PEER = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
21
+ const SHA = /^[0-9a-f]{40}$/;
22
+ const MAX_PATHS = 512;
23
+ const GIT_TIMEOUT_MS = 30_000, PUSH_TIMEOUT_MS = 90_000, AVAILABLE_TTL_MS = 30_000;
24
+ /** Repository configuration that would make a hub git call run a program, read through a helper or reach another remote. The #845 host path refuses the same keys. */
25
+ const CONFIG_REFUSED = "^(include\\.path|includeif\\..*\\.path|extensions\\.partialclone|remote\\..*\\.promisor|filter\\..*\\.(clean|smudge|process)|diff\\..*\\.(command|textconv)|core\\.(sshcommand|gitproxy)|remote\\.origin\\.(vcs|proxy|receivepack|push|mirror)|push\\.pushoption)$";
26
+
27
+ export const taskWorktreePath = (root: string, peer: PeerId, task: number): string => join(root, ".worktrees", `${peer}-${task}`);
28
+ export const taskWorktreeBranch = (peer: PeerId, task: number): string => `task/${task}-${peer}`;
29
+
30
+ export interface TaskWorktreeEntry { peer: string; task: number; path: string; branch: string; base: string; /** The hub's own last commit on the branch, once it made one. */ head?: string }
31
+ export type TaskWorktreeAvailability = { ok: true } | { ok: false; reason: string; next: string };
32
+ export type TaskWorktreeResult = { ok: true; path: string; branch: string; base: string; created: boolean } | { ok: false; reason: string };
33
+ export type TaskWorktreeCommit = { ok: true; commit: string; branch: string; pushed: boolean; note?: string } | { ok: false; reason: string; commit?: string };
34
+ export interface TaskWorktreeRemoval { removed: boolean; branchDeleted: boolean; kept?: string }
35
+
36
+ export interface TaskWorktreePort {
37
+ /** Only a peer whose own sandbox cannot make the worktree is served by the hub; every other peer keeps making its own. */
38
+ applies(peer: PeerId): boolean;
39
+ available(): TaskWorktreeAvailability;
40
+ create(input: { peer: PeerId; task: number }): TaskWorktreeResult;
41
+ describe(peer: PeerId, task: number): Pick<TaskWorktreeEntry, "path" | "branch" | "base"> | undefined;
42
+ commitAndPush(input: { peer: PeerId; task: number; title: string }): Promise<TaskWorktreeCommit>;
43
+ remove(input: { peer: PeerId; task: number }): TaskWorktreeRemoval;
44
+ }
45
+
46
+ /**
47
+ * Whether the hub may push for this project, from the GitHub repository it learned at startup (`expected`) and the URLs `origin` has now. `undefined` means the project has no GitHub
48
+ * origin the hub trusts, so the hub commits and does not push; `false` means an origin that is not this project's repository (changed since startup), which refuses the push.
49
+ */
50
+ export function originVerdict(expected: string | undefined, urls: string[], repositoryOf: (url: string) => string | undefined): boolean | undefined {
51
+ if (!expected) return undefined;
52
+ const wanted = expected.toLowerCase();
53
+ return urls.length > 0 && urls.length <= 16 && urls.every(url => repositoryOf(url)?.toLowerCase() === wanted);
54
+ }
55
+
56
+ export interface TaskWorktreeOptions {
57
+ /** The project root: the repository's top level, and the sandbox root of every hub-sandboxed peer. */
58
+ root: string;
59
+ /** Hub-owned record file, outside anything a peer's sandbox may write (`.agenthub` is refused to it, #270). */
60
+ stateFile: string;
61
+ applies: (peer: PeerId) => boolean;
62
+ deny: () => string[];
63
+ protectInstructions: () => boolean;
64
+ /** `originVerdict`: true pushes, false refuses, undefined (or no callback) commits without pushing. */
65
+ pushAllowed?: (urls: string[]) => boolean | undefined;
66
+ now?: () => number;
67
+ /** Test seam: runs after the staged change was validated and before the hub commits it, where a sandboxed peer could race the worktree's own index. */
68
+ beforeCommit?: () => void;
69
+ }
70
+
71
+ interface Run { status: number | null; stdout: string; stderr: string }
72
+ const clean = (text: string): string => text.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
73
+ /** Peer-written bytes never reach an error text whole: bounded and without control characters. */
74
+ const shown = (text: string, limit = 160): string => clean(text).slice(0, limit);
75
+
76
+ export class TaskWorktrees implements TaskWorktreePort {
77
+ private readonly root: string;
78
+ private chain: Promise<unknown> = Promise.resolve();
79
+ private cached?: { at: number; value: TaskWorktreeAvailability };
80
+ constructor(private readonly o: TaskWorktreeOptions) { this.root = realPath(o.root); }
81
+
82
+ applies = (peer: PeerId): boolean => this.o.applies(peer);
83
+
84
+ /** `extra` is merged after the isolation, which strips GIT_INDEX_FILE: only the hub's own private index passes through it. */
85
+ private git(args: string[], cwd = this.root, timeout = GIT_TIMEOUT_MS, extra: Record<string, string> = {}): Run {
86
+ const run = hubGitSync(["-C", cwd, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args], { encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024, env: { ...hubGitEnv(childEnv(), { repositoryIsolation: true }), ...extra } });
87
+ return { status: run.error ? null : run.status, stdout: String(run.stdout ?? ""), stderr: String(run.stderr ?? "") };
88
+ }
89
+ private gitAsync(args: string[], cwd: string, timeoutMs: number, extra: Record<string, string> = {}): Promise<Run> {
90
+ return new Promise(resolve => {
91
+ const argv = hubGitArgv(["-C", cwd, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args]);
92
+ const child = spawn(argv[0]!, argv.slice(1), { env: { ...hubGitEnv(childEnv(), { repositoryIsolation: true }), ...extra }, stdio: ["ignore", "pipe", "pipe"], detached: true });
93
+ let stdout = "", stderr = "", done = false;
94
+ const finish = (status: number | null) => { if (done) return; done = true; clearTimeout(timer); resolve({ status, stdout, stderr }); };
95
+ const timer = setTimeout(() => { try { process.kill(-child.pid!, "SIGKILL"); } catch { child.kill("SIGKILL"); } finish(null); }, timeoutMs);
96
+ child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
97
+ child.stdout.on("data", chunk => { if (stdout.length < 8 * 1024 * 1024) stdout += chunk; });
98
+ child.stderr.on("data", chunk => { if (stderr.length < 64 * 1024) stderr += chunk; });
99
+ child.once("error", () => finish(null));
100
+ child.once("close", code => finish(code));
101
+ });
102
+ }
103
+
104
+ private readRecords(): Record<string, TaskWorktreeEntry> {
105
+ let text: string;
106
+ try { text = readFileSync(this.o.stateFile, "utf8"); } catch (error) {
107
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
108
+ throw new Error("the task worktree record file is unreadable; the person can delete it after checking .worktrees");
109
+ }
110
+ try {
111
+ const parsed = JSON.parse(text) as Record<string, TaskWorktreeEntry>;
112
+ for (const entry of Object.values(parsed)) if (!PEER.test(entry.peer) || !Number.isSafeInteger(entry.task) || !SHA.test(entry.base) || (entry.head !== undefined && !SHA.test(entry.head))) throw new Error("bad record");
113
+ return parsed;
114
+ } catch { throw new Error("the task worktree record file is malformed; the person can delete it after checking .worktrees"); }
115
+ }
116
+ private writeRecords(records: Record<string, TaskWorktreeEntry>): void {
117
+ mkdirSync(dirname(this.o.stateFile), { recursive: true, mode: 0o700 });
118
+ const temporary = `${this.o.stateFile}.${process.pid}.tmp`;
119
+ writeFileSync(temporary, JSON.stringify(records), { mode: 0o600 });
120
+ renameSync(temporary, this.o.stateFile);
121
+ }
122
+ /**
123
+ * Every record change reads the file fresh and writes it back in one synchronous step, so a change made while another call awaits git is never overwritten by an older
124
+ * snapshot of the file (create, commit and remove all go through it).
125
+ */
126
+ private mutate<T>(change: (records: Record<string, TaskWorktreeEntry>) => T): T {
127
+ const records = this.readRecords();
128
+ const result = change(records);
129
+ this.writeRecords(records);
130
+ return result;
131
+ }
132
+ private key = (peer: PeerId, task: number) => `${peer}-${task}`;
133
+
134
+ /**
135
+ * Whether the REPOSITORY's own configuration lets the hub run git on it (F-worktree-config-scope). The guard is against configuration a repository or a peer can plant:
136
+ * the shared `.git/config` (`--local`, which a linked worktree reads too) and, where `extensions.worktreeConfig` is on, the worktree's own `config.worktree`. The person's
137
+ * global and system config is theirs and is left alone: git-lfs alone puts `filter.lfs.*` there on most machines, and refusing it would switch the feature off for them.
138
+ * The hardened git helper's isolation still applies to that config.
139
+ */
140
+ private configProblem(cwd: string): string | undefined {
141
+ const scopes: string[][] = [["--local"]];
142
+ const extension = this.git(["config", "--local", "--type=bool", "--get", "extensions.worktreeConfig"], cwd);
143
+ if (extension.status === 0 && extension.stdout.trim() === "true") scopes.push(["--worktree"]);
144
+ for (const scope of scopes) {
145
+ const found = this.git(["config", ...scope, "--get-regexp", CONFIG_REFUSED], cwd);
146
+ if (found.status === 0 && found.stdout.trim()) return `the repository configuration names a helper, filter, include or remote override (${shown(found.stdout.split("\n")[0] ?? "", 60)}); the hub will not run git with it. Ask the person to remove it, or run this work with a peer that makes its own worktree`;
147
+ if (found.status !== 0 && found.status !== 1) return "the repository configuration could not be read";
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ /** Whether the hub can make a worktree here: read-only, cached for 30 seconds, so a status read never waits on git twice. */
153
+ available(): TaskWorktreeAvailability {
154
+ const now = (this.o.now ?? Date.now)();
155
+ if (this.cached && now - this.cached.at < AVAILABLE_TTL_MS) return this.cached.value;
156
+ const value = this.probe();
157
+ this.cached = { at: now, value };
158
+ return value;
159
+ }
160
+ private probe(): TaskWorktreeAvailability {
161
+ const no = (reason: string, next: string): TaskWorktreeAvailability => ({ ok: false, reason, next });
162
+ const top = this.git(["rev-parse", "--show-toplevel"]);
163
+ if (top.status !== 0) return no("the project is not a git repository", "run `git init` there, or use a peer that does not need a worktree");
164
+ let toplevel: string;
165
+ try { toplevel = realPath(top.stdout.trim()); } catch { return no("the repository root could not be resolved", "check the project directory"); }
166
+ if (toplevel !== this.root) return no("the project root is not the repository root", "start the hub in the repository's top directory");
167
+ if (this.git(["rev-parse", "--is-bare-repository"]).stdout.trim() === "true") return no("the repository is bare", "use a non-bare checkout");
168
+ if (!this.base()) return no("the repository has no commit to branch from", "make a first commit");
169
+ try { const info = lstatSync(join(this.root, ".worktrees")); if (info.isSymbolicLink() || !info.isDirectory()) return no(".worktrees is not a plain directory", "replace it with a real directory"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return no(".worktrees could not be inspected", "check its permissions"); }
170
+ const config = this.configProblem(this.root);
171
+ return config ? no(config, "see the cause") : { ok: true };
172
+ }
173
+ /** `origin/HEAD` when the repository has one, else the main checkout's HEAD. No fetch: the hub's network calls stay with the push. */
174
+ private base(): string | undefined {
175
+ for (const ref of ["refs/remotes/origin/HEAD", "HEAD"]) {
176
+ const out = this.git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
177
+ const sha = out.stdout.trim();
178
+ if (out.status === 0 && SHA.test(sha)) return sha;
179
+ }
180
+ return undefined;
181
+ }
182
+ private tip(branch: string): string | undefined {
183
+ const out = this.git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`]);
184
+ return out.status === 0 && SHA.test(out.stdout.trim()) ? out.stdout.trim() : undefined;
185
+ }
186
+
187
+ describe(peer: PeerId, task: number): Pick<TaskWorktreeEntry, "path" | "branch" | "base"> | undefined {
188
+ try { const entry = this.readRecords()[this.key(peer, task)]; return entry ? { path: entry.path, branch: entry.branch, base: entry.base } : undefined; } catch { return undefined; }
189
+ }
190
+
191
+ create(input: { peer: PeerId; task: number }): TaskWorktreeResult {
192
+ const { peer, task } = input;
193
+ const fail = (reason: string): TaskWorktreeResult => ({ ok: false, reason });
194
+ if (!PEER.test(peer) || !Number.isSafeInteger(task) || task < 1) return fail("the peer or task id cannot name a worktree");
195
+ let records: Record<string, TaskWorktreeEntry>;
196
+ try { records = this.readRecords(); } catch (error) { return fail((error as Error).message); }
197
+ const path = taskWorktreePath(this.root, peer, task), branch = taskWorktreeBranch(peer, task);
198
+ const existing = records[this.key(peer, task)];
199
+ if (existing) {
200
+ // A repeated accept of the same task: the worktree is the one made before, never a second one.
201
+ try { if (!lstatSync(existing.path).isDirectory()) throw new Error("not a directory"); } catch { return fail(`the recorded worktree ${existing.path} is missing; decline the task so the hub clears it, then accept it again`); }
202
+ return { ok: true, path: existing.path, branch: existing.branch, base: existing.base, created: false };
203
+ }
204
+ this.cached = undefined;
205
+ const avail = this.available();
206
+ if (!avail.ok) return fail(`${avail.reason}; ${avail.next}`);
207
+ if (this.tip(branch)) return fail(`the branch ${branch} already exists; delete it or decline the task so the person can clear it`);
208
+ try { lstatSync(path); return fail(`${path} already exists; remove it or decline the task so the person can clear it`); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return fail(`${path} could not be inspected`); }
209
+ const base = this.base();
210
+ if (!base) return fail("the repository has no commit to branch from");
211
+ try { mkdirSync(join(this.root, ".worktrees"), { recursive: true }); } catch { return fail(".worktrees could not be created; check the project directory permissions"); }
212
+ const added = this.git(["worktree", "add", "--quiet", "-b", branch, path, base]);
213
+ // The tree and branch are the hub's own, a moment old, with nothing a peer wrote: `--force` here only clears a half-made one.
214
+ const undo = () => { this.git(["worktree", "remove", "--force", path]); this.git(["branch", "-D", branch]); this.git(["worktree", "prune"]); };
215
+ if (added.status !== 0) { undo(); return fail(`git worktree add failed: ${shown(added.stderr)}`); }
216
+ const head = this.git(["rev-parse", "HEAD"], path).stdout.trim(), symbolic = this.git(["symbolic-ref", "-q", "HEAD"], path).stdout.trim();
217
+ if (head !== base || symbolic !== `refs/heads/${branch}` || this.tip(branch) !== base) { undo(); return fail("the new worktree did not come up on the expected base; nothing was kept"); }
218
+ try { this.mutate(current => { current[this.key(peer, task)] = { peer, task, path, branch, base }; }); } catch { undo(); return fail("the worktree record could not be written; nothing was kept"); }
219
+ return { ok: true, path, branch, base, created: true };
220
+ }
221
+
222
+ /** One hub commit at a time per worktree: a repeated or parallel `hub_task_done` queues behind the first. */
223
+ commitAndPush(input: { peer: PeerId; task: number; title: string }): Promise<TaskWorktreeCommit> {
224
+ const next = this.chain.then(() => this.commit(input), () => this.commit(input));
225
+ this.chain = next.catch(() => undefined);
226
+ return next;
227
+ }
228
+
229
+ private async commit(input: { peer: PeerId; task: number; title: string }): Promise<TaskWorktreeCommit> {
230
+ const { peer, task } = input;
231
+ const fail = (reason: string, commit?: string): TaskWorktreeCommit => ({ ok: false, reason, ...(commit ? { commit } : {}) });
232
+ let records: Record<string, TaskWorktreeEntry>;
233
+ try { records = this.readRecords(); } catch (error) { return fail((error as Error).message); }
234
+ const entry = records[this.key(peer, task)];
235
+ if (!entry) return fail("the hub made no worktree for this task");
236
+ const { path, branch, base } = entry, expectedTip = entry.head ?? base;
237
+ // 1. Registered at the fixed path, on the task branch, and the tip is where the hub left it. The peer can write HEAD and refs, so none of it is trusted unread.
238
+ let real: string;
239
+ try { real = realPath(path); } catch { return fail(`the worktree ${path} is missing; the hub cannot commit it. Decline the task so the hub clears it`); }
240
+ if (real !== path) return fail(`the worktree path now resolves to ${shown(real)}, not ${path}; the hub will not commit it. Decline the task`);
241
+ const listed = this.git(["worktree", "list", "--porcelain", "-z"]).stdout.split("\0\0").filter(Boolean).map(block => block.split("\0"));
242
+ const mine = listed.find(fields => { const at = fields.find(f => f.startsWith("worktree "))?.slice(9); try { return !!at && realPath(at) === real; } catch { return false; } });
243
+ if (!mine || mine.some(f => f === "prunable" || f.startsWith("prunable "))) return fail("the worktree is no longer registered with git; the hub will not commit it. Decline the task so the hub clears it");
244
+ if (mine.find(f => f.startsWith("branch "))?.slice(7) !== `refs/heads/${branch}`) return fail(`the worktree is no longer on ${branch} (HEAD was changed); the hub will not commit it. Decline the task so the hub clears it`);
245
+ if (this.git(["symbolic-ref", "-q", "HEAD"], path).stdout.trim() !== `refs/heads/${branch}`) return fail(`HEAD of the worktree is not ${branch}; the hub will not commit it. Decline the task so the hub clears it`);
246
+ const tip = this.tip(branch);
247
+ if (tip !== expectedTip) return fail(`${branch} is at ${tip?.slice(0, 12) ?? "nothing"}, not at the commit the hub recorded (${expectedTip.slice(0, 12)}); the peer must not move the branch. The hub will not commit or push it. Decline the task so the hub clears it`);
248
+ const problem = this.configProblem(path);
249
+ if (problem) return fail(problem);
250
+ // 2. The peer can write the worktree's own index (#864), so the hub never reads it: it builds a PRIVATE index in its state dir (outside every sandbox root) from the recorded tip,
251
+ // adds the working tree to that, hashes it with write-tree, and validates and commits that exact tree. Swapping the worktree's index after the check cannot change what is committed.
252
+ const index = join(dirname(this.o.stateFile), `task-index-${randomUUID()}`), own = { GIT_INDEX_FILE: index };
253
+ try {
254
+ if (this.git(["read-tree", expectedTip], path, GIT_TIMEOUT_MS, own).status !== 0) return fail("the hub's private index could not be built from the recorded commit");
255
+ const added = await this.gitAsync(["add", "-A", "--", "."], path, GIT_TIMEOUT_MS, own);
256
+ if (added.status !== 0) return fail(`staging the worktree failed: ${shown(added.stderr)}`);
257
+ const written = this.git(["write-tree"], path, GIT_TIMEOUT_MS, own), tree = written.stdout.trim();
258
+ if (written.status !== 0 || !SHA.test(tree)) return fail("the staged tree could not be written");
259
+ const raw = this.git(["diff-tree", "-r", "--raw", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", expectedTip, tree], path);
260
+ if (raw.status !== 0) return fail("the staged change could not be listed");
261
+ const parts = raw.stdout.split("\0").filter(Boolean), changed: { mode: string; path: string }[] = [];
262
+ for (let i = 0; i + 1 < parts.length; i += 2) { const meta = /^:(\d{6}) (\d{6}) /.exec(parts[i]!); if (!meta) return fail("the staged change has an unreadable entry"); changed.push({ mode: meta[2]!, path: parts[i + 1]! }); }
263
+ // Nothing new since the hub's own commit: this is a retry after a failed push, so push the commit it already made.
264
+ if (!changed.length && entry.head) return this.push(path, branch, entry.head, peer);
265
+ if (!changed.length) return fail(`no changes under ${path}: edit files there, then call hub_task_done again`);
266
+ if (changed.length > MAX_PATHS) return fail(`${changed.length} paths changed, more than the ${MAX_PATHS} the hub commits for a sandboxed peer; split the work into smaller tasks`);
267
+ // 3. The same path policy a person's grant uses: the denylist, the agent and hub write names, and (when the policy is on) the instruction files.
268
+ const deny = this.o.deny(), protect = this.o.protectInstructions();
269
+ const refused = changed.filter(c => c.mode === "160000" || isDenied(c.path, deny) || !grantablePath(path, join(path, c.path), c.path, protect));
270
+ if (refused.length) return fail(`the hub will not commit ${refused.length} changed path${refused.length === 1 ? "" : "s"} (${refused.slice(0, 3).map(c => shown(c.path, 60)).join(", ")}): a secret, a git or hub or agent configuration name, a protected instruction file or a submodule. Revert ${refused.length === 1 ? "it" : "them"} in the worktree and call hub_task_done again`);
271
+ this.o.beforeCommit?.();
272
+ // 4. The commit object is made from the validated tree by the hub (no hook, no signing, no index read), and the branch moves to it only if it still sits at the recorded tip.
273
+ const title = shown(input.title, 200) || `task ${task}`;
274
+ const made = this.git(["-c", `user.name=${peer}`, "-c", `user.email=${peer}@agent-hub.invalid`, "-c", "commit.gpgSign=false", "commit-tree", tree, "-p", expectedTip, "-m", title, "-m", `Task #${task} by ${peer}. Committed by the hub for a sandboxed peer.`], path), commit = made.stdout.trim();
275
+ if (made.status !== 0 || !SHA.test(commit)) return fail(`git commit-tree failed: ${shown(made.stderr)}`);
276
+ if (this.git(["update-ref", `refs/heads/${branch}`, commit, expectedTip], path).status !== 0) return fail(`${branch} moved while the hub was committing; nothing was kept. Decline the task so the hub clears it`);
277
+ if (this.tip(branch) !== commit || this.git(["rev-parse", `${commit}^{tree}`], path).stdout.trim() !== tree) return fail("the commit did not land on the task branch as expected; the hub will not push it");
278
+ // The worktree's own index (peer-writable) is brought in line with the new HEAD, and refreshed (read-tree leaves no stat data, so diff-index would call every file modified), so `git status`
279
+ // there is clean and `remove` can tell finished work from uncommitted work. Nothing is ever committed from it.
280
+ this.git(["read-tree", commit], path); this.git(["update-index", "-q", "--refresh"], path);
281
+ try { this.mutate(current => { current[this.key(peer, task)] = { ...(current[this.key(peer, task)] ?? entry), head: commit }; }); } catch { return fail("the commit was made but the hub could not record it; call hub_task_done again", commit); }
282
+ return this.push(path, branch, commit, peer);
283
+ } finally { rmSync(index, { force: true }); }
284
+ }
285
+
286
+ /** 5. One explicit refspec for this branch, only to an origin that is this project's repository. */
287
+ private async push(path: string, branch: string, commit: string, _peer: PeerId): Promise<TaskWorktreeCommit> {
288
+ const fail = (reason: string, sha?: string): TaskWorktreeCommit => ({ ok: false, reason, ...(sha ? { commit: sha } : {}) });
289
+ if (this.tip(branch) !== commit) return fail(`${branch} no longer points at the hub's commit ${commit.slice(0, 12)}; the hub will not push it. Decline the task so the hub clears it`);
290
+ const urls = [...this.git(["remote", "get-url", "--push", "--all", "origin"], path).stdout.trim().split("\n"), ...this.git(["remote", "get-url", "--all", "origin"], path).stdout.trim().split("\n")].filter(Boolean);
291
+ const verdict = this.o.pushAllowed?.(urls);
292
+ // No GitHub origin was configured when the hub started (a local repository, or another host): the commit is the result, and `hub_task_done` carries it without a push.
293
+ // An origin that is not this project's repository (changed since startup, or two URLs that disagree) is refused with the commit kept.
294
+ if (verdict === undefined) return { ok: true, commit, branch, pushed: false, note: "the project has no GitHub origin the hub trusts, so the branch was committed but not pushed" };
295
+ if (verdict !== true) return fail(`committed ${commit.slice(0, 12)} on ${branch} but origin is not this project's repository, so nothing was pushed; ask the person to check the origin URL, then call hub_task_done again`, commit);
296
+ const pushed = await this.gitAsync(["-c", "push.followTags=false", "-c", "remote.origin.mirror=false", "-c", "push.recurseSubmodules=no", "-c", "push.gpgSign=false", "push", "--no-verify", "origin", `refs/heads/${branch}:refs/heads/${branch}`], path, PUSH_TIMEOUT_MS);
297
+ if (pushed.status !== 0) return fail(`committed ${commit.slice(0, 12)} on ${branch} but the push failed (${shown(pushed.stderr, 120) || "no output"}); the commit is kept, call hub_task_done again to retry the push`, commit);
298
+ return { ok: true, commit, branch, pushed: true };
299
+ }
300
+
301
+ /**
302
+ * Decline, cancel or a failed accept: remove the worktree and delete the branch, so no empty ref stays. A worktree with uncommitted work is
303
+ * reported and kept, never force-removed, and a branch the hub committed to is kept (its commit may be pushed and under review).
304
+ */
305
+ remove(input: { peer: PeerId; task: number }): TaskWorktreeRemoval {
306
+ const { peer, task } = input;
307
+ let records: Record<string, TaskWorktreeEntry>;
308
+ try { records = this.readRecords(); } catch (error) { return { removed: false, branchDeleted: false, kept: (error as Error).message }; }
309
+ const entry = records[this.key(peer, task)];
310
+ if (!entry) return { removed: false, branchDeleted: false };
311
+ const { path, branch, base } = entry;
312
+ let present = true;
313
+ try { lstatSync(path); } catch { present = false; }
314
+ if (present) {
315
+ const untracked = this.git(["ls-files", "--others", "--exclude-standard", "-z"], path);
316
+ const changed = this.git(["diff-index", "--quiet", "--no-ext-diff", "--no-textconv", entry.head ?? base], path);
317
+ const staged = this.git(["diff-index", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", entry.head ?? base], path);
318
+ if (untracked.status !== 0 || untracked.stdout.length || changed.status !== 0 || staged.status !== 0) {
319
+ return { removed: false, branchDeleted: false, kept: `${path} has uncommitted changes or could not be checked, so it was kept with branch ${branch}; the person can review it, then run \`git worktree remove\` there` };
320
+ }
321
+ const gone = this.git(["worktree", "remove", path]);
322
+ if (gone.status !== 0) return { removed: false, branchDeleted: false, kept: `${path} could not be removed (${shown(gone.stderr, 100)}); it was kept with branch ${branch}` };
323
+ } else this.git(["worktree", "prune"]);
324
+ let branchDeleted = false;
325
+ const tip = this.tip(branch);
326
+ if (tip === undefined) branchDeleted = true;
327
+ else if (tip === base && entry.head === undefined) branchDeleted = this.git(["branch", "-D", branch]).status === 0;
328
+ try { this.mutate(current => { delete current[this.key(peer, task)]; }); } catch { return { removed: true, branchDeleted, kept: "the worktree record could not be updated" }; }
329
+ return { removed: true, branchDeleted, ...(branchDeleted ? {} : { kept: `branch ${branch} has the hub's commit and was kept` }) };
330
+ }
331
+ }