@staix/agent-hub 0.12.42 → 0.12.44

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 (100) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +5 -4
  3. package/docs/adding-an-ai-tool.md +140 -0
  4. package/docs/agent-notes/README.md +5 -0
  5. package/docs/agent-notes/adapters.md +11 -4
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/bus.md +2 -0
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/tasks.md +7 -2
  10. package/docs/agent-notes/tests.md +6 -2
  11. package/docs/operations.md +159 -26
  12. package/docs/quickstart.md +11 -3
  13. package/docs/sdlc.md +1 -1
  14. package/docs/security.md +7 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
  16. package/docs/verification/2026-10-21-0.12.43.md +50 -0
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
  19. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  20. package/docs/verification/2026-10-21-pii-screen.md +14 -0
  21. package/docs/verified.json +34 -19
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +808 -255
  25. package/src/adapters/acp-owned-group.ts +124 -0
  26. package/src/adapters/acp.ts +92 -25
  27. package/src/adapters/agy/definition.ts +1 -3
  28. package/src/adapters/agy/entry.ts +4 -1
  29. package/src/adapters/agy/hooks.ts +7 -2
  30. package/src/adapters/channel-peer.ts +143 -0
  31. package/src/adapters/claude-channel.ts +7 -7
  32. package/src/adapters/codex-appserver.ts +16 -2
  33. package/src/adapters/configured-acp.ts +124 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  35. package/src/adapters/definitions/native-channel-state.ts +41 -0
  36. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  37. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  38. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  39. package/src/adapters/definitions/qwen.ts +3 -8
  40. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  41. package/src/adapters/jcode.ts +4 -0
  42. package/src/adapters/local-worker.ts +4 -0
  43. package/src/adapters/pi.ts +50 -16
  44. package/src/adapters/registry-types.ts +18 -16
  45. package/src/adapters/registry.ts +68 -6
  46. package/src/adapters/reviewer-print.ts +4 -0
  47. package/src/cli/agy-mcp.ts +3 -2
  48. package/src/cli/completion.ts +173 -0
  49. package/src/cli/configured-acp-entry.ts +58 -0
  50. package/src/cli/console-state.ts +116 -24
  51. package/src/cli/console.ts +9 -6
  52. package/src/cli/facts-hook.ts +2 -1
  53. package/src/cli/help.ts +8 -1
  54. package/src/cli/identity.ts +8 -7
  55. package/src/cli/launch.ts +5 -4
  56. package/src/cli/main.js +5 -1
  57. package/src/cli/main.ts +84 -27
  58. package/src/cli/memory.ts +13 -10
  59. package/src/cli/output.ts +1 -0
  60. package/src/cli/preview.ts +9 -0
  61. package/src/cli/recovery-guidance.ts +1 -1
  62. package/src/cli/terminal-recovery.ts +33 -21
  63. package/src/cli/upgrade-restore-state.ts +3 -2
  64. package/src/cli/upgrade-runtime.ts +21 -6
  65. package/src/cli/upgrade.ts +3 -2
  66. package/src/hub/board.ts +9 -6
  67. package/src/hub/budget.ts +9 -2
  68. package/src/hub/bus.ts +6 -2
  69. package/src/hub/claude-state.ts +2 -37
  70. package/src/hub/conductor.ts +8 -6
  71. package/src/hub/config-apply.ts +26 -14
  72. package/src/hub/config-reload.ts +39 -11
  73. package/src/hub/config-trust.ts +1 -1
  74. package/src/hub/context-window.ts +6 -0
  75. package/src/hub/control-client.ts +1 -1
  76. package/src/hub/crash.ts +8 -6
  77. package/src/hub/daemon.ts +471 -338
  78. package/src/hub/hub-tools.ts +37 -1
  79. package/src/hub/instance-config.ts +20 -11
  80. package/src/hub/instance-execution.ts +4 -4
  81. package/src/hub/instances.ts +45 -21
  82. package/src/hub/lifecycle-schema.ts +5 -3
  83. package/src/hub/main-checkout.ts +2 -2
  84. package/src/hub/manager.ts +10 -2
  85. package/src/hub/memory-profile.ts +16 -3
  86. package/src/hub/no-writes.ts +50 -0
  87. package/src/hub/permission-mode.ts +9 -7
  88. package/src/hub/roster.ts +7 -4
  89. package/src/hub/routing.ts +21 -20
  90. package/src/hub/settings.ts +51 -17
  91. package/src/hub/snapshots.ts +276 -17
  92. package/src/hub/start-mode.ts +10 -8
  93. package/src/hub/task-sweep.ts +8 -2
  94. package/src/hub/tasks.ts +101 -19
  95. package/src/memory/capture.ts +2 -1
  96. package/src/ui/index.html +5 -0
  97. package/templates/AGENTS.block.md +1 -1
  98. package/templates/profiles/general.json +1 -1
  99. package/templates/profiles/research.json +1 -1
  100. package/templates/profiles/writing.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
3
+ import { createHash } from "node:crypto";
3
4
  import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
5
6
  import { childEnv } from "./child-process.ts";
@@ -12,17 +13,28 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
12
13
  * Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
13
14
  * and HEAD never change. They hold tracked and unignored files as `git add -A` sees them, shell-made changes included.
14
15
  * ponytail: snapshots run synchronously on the turn boundary (before the agent sees its prompt); a very large tree
15
- * costs that much latency per turn, `snapshots.enabled` is the switch and `turn_end.snapshotMs` the measurement.
16
+ * costs that much latency per turn for unbudgeted CLI calls. The daemon shares a bounded burst deadline;
17
+ * exhausted boundaries are unknown. `snapshots.enabled` is the switch and `turn_end.snapshotMs` the measurement.
18
+ * ponytail: the deadline caps Git child waits, not synchronous metadata/index-copy syscalls. If those become
19
+ * measured blockers, move capture to an isolated worker with native-boundary admission fencing.
16
20
  * ponytail: the trees are unreferenced, so `git gc` may prune them after gc.pruneExpire (two weeks by default); undo
17
21
  * of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
18
22
  */
19
- const git = (top: string | Repo, args: string[], env: Record<string, string> = {}) =>
20
- hubGitSync(repoGitArgs(top, args), {
21
- encoding: "utf8",
22
- env: { ...repoGitEnv(top), ...env },
23
- // These run inside the bus's state change: a hung git (a huge new directory, a stuck filter) must not freeze the hub.
24
- timeout: SNAPSHOT_TIMEOUT_MS,
23
+ export interface SnapshotBudget { deadline: number; }
24
+ export class SnapshotBudgetExceeded extends Error {
25
+ constructor() { super("snapshot burst budget exhausted; boundary evidence is unknown"); }
26
+ }
27
+ const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget, maxBuffer?: number) => {
28
+ const argv = repoGitArgs(top, args);
29
+ const remaining = budget ? budget.deadline - performance.now() : SNAPSHOT_TIMEOUT_MS;
30
+ if (remaining <= 0) throw new SnapshotBudgetExceeded();
31
+ const result = hubGitSync(argv, {
32
+ encoding: "utf8", env: { ...repoGitEnv(top), ...env }, ...(maxBuffer === undefined ? {} : { maxBuffer }),
33
+ timeout: Math.max(1, Math.min(SNAPSHOT_TIMEOUT_MS, Math.floor(remaining))),
25
34
  });
35
+ if (budget && (performance.now() >= budget.deadline || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT")) throw new SnapshotBudgetExceeded();
36
+ return result;
37
+ };
26
38
  const SNAPSHOT_TIMEOUT_MS = 10_000;
27
39
 
28
40
  /** A repository and where the project sits in it: `prefix` is "" at the top level, else "sub/dir/". */
@@ -64,8 +76,8 @@ export function repoGitEnv(repo: string | Repo, env = process.env): NodeJS.Proce
64
76
  }
65
77
 
66
78
  /** The repository holding `root`, or undefined when it is not inside a work tree. */
67
- export function repoOf(root: string): Repo | undefined {
68
- const r = git(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"]);
79
+ export function repoOf(root: string, budget?: SnapshotBudget): Repo | undefined {
80
+ const r = git(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"], {}, budget);
69
81
  if (r.status === 0) {
70
82
  const [top, dir, prefix = ""] = r.stdout.replace(/\n$/, "").split("\n");
71
83
  if (top && dir) return { top, dir, prefix };
@@ -82,7 +94,7 @@ export function repoOf(root: string): Repo | undefined {
82
94
  if (ignore && (!ignore.isFile() || ignore.isSymbolicLink() || ignore.nlink !== 1)) return undefined;
83
95
  if (!shadowStorageSafe(dir)) return undefined;
84
96
  const repo: Repo = { top, dir: realPath(dir), prefix: "", shadow: true };
85
- const probe = git(repo, ["rev-parse", "--absolute-git-dir"]);
97
+ const probe = git(repo, ["rev-parse", "--absolute-git-dir"], {}, budget);
86
98
  return probe.status === 0 && realPath(probe.stdout.trim()) === repo.dir ? repo : undefined;
87
99
  }
88
100
  /** Opt-in creation only; a real repository is never replaced or modified. */
@@ -107,8 +119,252 @@ const literal = (path: string) => `:(literal)${path}`;
107
119
  /** Only the project's own files: a hub in a subdirectory of a larger repository snapshots that subdirectory. */
108
120
  const scope = (repo: Repo) => (repo.prefix ? literal(repo.prefix) : ".");
109
121
 
122
+ /** Committed HEAD tree, null for an unborn HEAD, undefined when evidence cannot be read. */
123
+ export function headTree(repo: Repo, budget?: SnapshotBudget): string | null | undefined {
124
+ const result = git(repo, ["rev-parse", "--verify", "--quiet", "HEAD^{tree}"], {}, budget);
125
+ return result.status === 0 ? result.stdout.trim() : result.status === 1 ? null : undefined;
126
+ }
127
+
128
+ /** One probe binds the committed HEAD id and tree; an unborn HEAD needs positive missing-ref proof. */
129
+ export function headState(repo: Repo, budget?: SnapshotBudget): { head: string | null; tree: string | null } | undefined {
130
+ const result = git(repo, ["rev-parse", "--quiet", "HEAD", "HEAD^{tree}"], {}, budget);
131
+ const values = result.status === 0 ? result.stdout.trim().split("\n") : [];
132
+ if (values.length === 2 && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(values[0]!) &&
133
+ /^[0-9a-f]+$/.test(values[1]!) && values[0]!.length === values[1]!.length) return { head: values[0]!, tree: values[1]! };
134
+ if (result.status === 0) return undefined;
135
+ const symbolic = git(repo, ["symbolic-ref", "--quiet", "HEAD"], {}, budget);
136
+ const ref = symbolic.status === 0 ? symbolic.stdout.trim() : "";
137
+ if (!ref.startsWith("refs/heads/")) return undefined;
138
+ const missing = git(repo, ["show-ref", "--verify", "--quiet", ref], {}, budget);
139
+ if (missing.status !== 1) return undefined;
140
+ const current = git(repo, ["rev-parse", "--verify", "--quiet", "HEAD"], {}, budget);
141
+ return current.status === 1 ? { head: null, tree: null } : undefined;
142
+ }
143
+
144
+ /** One bounded probe covers shared local/remote branch refs, including commit and push progress. Names never escape. */
145
+ export function branchHeads(repo: Repo, budget?: SnapshotBudget): string | undefined {
146
+ const result = git(repo, ["for-each-ref", "--sort=refname", "--format=%(refname) %(objectname)", "refs/heads/", "refs/remotes/"], {}, budget, 1024 * 1024);
147
+ if (result.status !== 0 || result.error) return undefined;
148
+ const digest = createHash("sha256").update(result.stdout).digest("hex");
149
+ if (budget && performance.now() >= budget.deadline) throw new SnapshotBudgetExceeded();
150
+ return digest;
151
+ }
152
+
153
+ /** Negative canonical evidence needs exclusive scope, including detached linked worktrees. Paths stay host-only. */
154
+ export function soleWorktree(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
155
+ const result = git(repo, ["worktree", "list", "--porcelain", "-z"], {}, budget, 1024 * 1024);
156
+ if (result.status !== 0 || result.error) return undefined;
157
+ // A project-bound shadow registers its private bare Git directory, not its explicit --work-tree root.
158
+ const registeredRoot = repo.shadow ? repo.dir : repo.top;
159
+ let count = 0;
160
+ for (const field of result.stdout.split("\0")) {
161
+ if (budget && performance.now() >= budget.deadline) throw new SnapshotBudgetExceeded();
162
+ if (!field.startsWith("worktree ")) continue;
163
+ if (field.slice(9) !== registeredRoot || ++count > 1) return false;
164
+ }
165
+ return count === 1 ? true : undefined;
166
+ }
167
+
168
+ /** A bounded append cursor; raw reflog identities and messages never leave the reader. */
169
+ export interface HeadMoveCursor {
170
+ readonly head: string | null;
171
+ readonly tree: string | null;
172
+ readonly unbornRefHash?: string;
173
+ readonly dev: number;
174
+ readonly ino: number;
175
+ readonly size: number;
176
+ readonly anchorOffset: number;
177
+ readonly anchor: string;
178
+ }
179
+ const HEAD_LOG_TAIL_BYTES = 4096;
180
+ const HEAD_LOG_MAX_APPEND_BYTES = 128 * 1024;
181
+ const HEAD_LOG_MAX_MOVES = 64;
182
+ const checkSnapshotBudget = (budget?: SnapshotBudget) => { if (budget && performance.now() >= budget.deadline) throw new SnapshotBudgetExceeded(); };
183
+ const sameLogFile = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino;
184
+ function readHeadLog<T>(repo: Repo, budget: SnapshotBudget | undefined, read: (fd: number, stat: Stats) => T | undefined): T | undefined {
185
+ let fd: number | undefined;
186
+ try {
187
+ checkSnapshotBudget(budget);
188
+ const logs = join(repo.dir, "logs"), file = join(logs, "HEAD");
189
+ const directory = lstatSync(logs);
190
+ if (!directory.isDirectory() || directory.isSymbolicLink()) return undefined;
191
+ const named = lstatSync(file);
192
+ if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1) return undefined;
193
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
194
+ const before = fstatSync(fd);
195
+ if (!before.isFile() || before.nlink !== 1 || !sameLogFile(named, before) || before.size !== named.size) return undefined;
196
+ const result = read(fd, before);
197
+ const after = fstatSync(fd), current = lstatSync(file), parent = lstatSync(logs);
198
+ checkSnapshotBudget(budget);
199
+ if (!sameLogFile(before, after) || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs ||
200
+ !current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !sameLogFile(after, current) || after.size !== current.size || after.mtimeMs !== current.mtimeMs || after.ctimeMs !== current.ctimeMs ||
201
+ !parent.isDirectory() || parent.isSymbolicLink() || !sameLogFile(directory, parent)) return undefined;
202
+ return result;
203
+ } catch (error) { if (error instanceof SnapshotBudgetExceeded) throw error; return undefined; }
204
+ finally { if (fd !== undefined) closeSync(fd); }
205
+ }
206
+ function headLogBytes(fd: number, offset: number, length: number, budget?: SnapshotBudget): Buffer | undefined {
207
+ const data = Buffer.alloc(length);
208
+ let used = 0;
209
+ while (used < length) {
210
+ checkSnapshotBudget(budget);
211
+ const count = readSync(fd, data, used, length - used, offset + used);
212
+ if (!count) return undefined;
213
+ used += count;
214
+ }
215
+ return data;
216
+ }
217
+ const headLogHash = (data: Buffer) => createHash("sha256").update(data).digest("hex");
218
+ function headLogRow(line: string): { old: string; next: string; action: string } | undefined {
219
+ const row = /^([0-9a-f]{40}|[0-9a-f]{64}) ([0-9a-f]{40}|[0-9a-f]{64}) [^\r\n\t]+ [0-9]+ [+-][0-9]{4}\t([^\r\n]*)$/.exec(line);
220
+ return row && row[1]!.length === row[2]!.length ? { old: row[1]!, next: row[2]!, action: row[3]! } : undefined;
221
+ }
222
+ function missingHeadLog(repo: Repo): boolean {
223
+ try {
224
+ const parent = lstatSync(join(repo.dir, "logs"));
225
+ if (!parent.isDirectory() || parent.isSymbolicLink()) return false;
226
+ } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
227
+ try { lstatSync(join(repo.dir, "logs", "HEAD")); return false; }
228
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
229
+ }
230
+ /** Read only a bounded, stable symbolic HEAD when the caller already proved it unborn. */
231
+ function symbolicHeadRef(repo: Repo, budget?: SnapshotBudget): string | undefined {
232
+ let fd: number | undefined;
233
+ try {
234
+ checkSnapshotBudget(budget);
235
+ const file = join(repo.dir, "HEAD"), named = lstatSync(file);
236
+ if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size > 4096) return undefined;
237
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
238
+ const before = fstatSync(fd);
239
+ if (!before.isFile() || before.nlink !== 1 || !sameLogFile(named, before) || named.size !== before.size) return undefined;
240
+ const bytes = headLogBytes(fd, 0, before.size, budget);
241
+ const after = fstatSync(fd), current = lstatSync(file);
242
+ checkSnapshotBudget(budget);
243
+ if (!bytes || !sameLogFile(before, after) || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs ||
244
+ !current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !sameLogFile(after, current) || after.size !== current.size ||
245
+ after.mtimeMs !== current.mtimeMs || after.ctimeMs !== current.ctimeMs) return undefined;
246
+ return /^ref: (refs\/heads\/[^\r\n]+)\n?$/.exec(bytes.toString("utf8"))?.[1];
247
+ } catch (error) { if (error instanceof SnapshotBudgetExceeded) throw error; return undefined; }
248
+ finally { if (fd !== undefined) closeSync(fd); }
249
+ }
250
+ /** provenHead is host-only, paired with expectedTree from the very same headState probe. */
251
+ export function headMoveCursor(repo: Repo, expectedTree: string | null, budget?: SnapshotBudget, provenHead?: string | null): HeadMoveCursor | undefined {
252
+ if (provenHead !== undefined && (provenHead === null) !== (expectedTree === null)) return undefined;
253
+ if (expectedTree === null) {
254
+ let ref: string | undefined;
255
+ if (provenHead === null) ref = symbolicHeadRef(repo, budget);
256
+ else {
257
+ const symbolic = git(repo, ["symbolic-ref", "--quiet", "HEAD"], {}, budget);
258
+ ref = symbolic.status === 0 ? symbolic.stdout.trim() : undefined;
259
+ if (!ref?.startsWith("refs/heads/")) return undefined;
260
+ const missing = git(repo, ["show-ref", "--verify", "--quiet", ref], {}, budget);
261
+ if (missing.status !== 1) return undefined;
262
+ const current = git(repo, ["rev-parse", "--verify", "--quiet", "HEAD"], {}, budget);
263
+ if (current.status !== 1) return undefined;
264
+ }
265
+ if (!ref || !missingHeadLog(repo)) return undefined;
266
+ checkSnapshotBudget(budget);
267
+ return { head: null, tree: null, unbornRefHash: createHash("sha256").update(ref).digest("hex"), dev: 0, ino: 0, size: 0, anchorOffset: 0, anchor: headLogHash(Buffer.alloc(0)) };
268
+ }
269
+ const cursor = readHeadLog(repo, budget, (fd, stat): HeadMoveCursor | undefined => {
270
+ if (stat.size <= 0 || !Number.isSafeInteger(stat.size)) return undefined;
271
+ const anchorOffset = Math.max(0, stat.size - HEAD_LOG_TAIL_BYTES);
272
+ const tail = headLogBytes(fd, anchorOffset, stat.size - anchorOffset, budget);
273
+ if (!tail || tail[tail.length - 1] !== 10) return undefined;
274
+ const text = tail.toString("utf8"), start = text.lastIndexOf("\n", text.length - 2) + 1;
275
+ if (anchorOffset > 0 && start === 0) return undefined;
276
+ const last = headLogRow(text.slice(start, -1));
277
+ if (!last) return undefined;
278
+ return { head: last.next, tree: expectedTree, dev: stat.dev, ino: stat.ino, size: stat.size, anchorOffset, anchor: headLogHash(tail) };
279
+ });
280
+ if (!cursor) return undefined;
281
+ if (provenHead !== undefined) return cursor.head === provenHead ? cursor : undefined;
282
+ // Bind the observed log tip and its immutable tree to current HEAD in one bounded probe.
283
+ const current = git(repo, ["rev-parse", "--quiet", "HEAD", `${cursor.head}^{tree}`], {}, budget);
284
+ const values = current.status === 0 ? current.stdout.trim().split("\n") : [];
285
+ return values.length === 2 && values[0] === cursor.head && values[1] === expectedTree ? cursor : undefined;
286
+ }
287
+ export function incomingHeadPaths(repo: Repo, start: HeadMoveCursor, end: HeadMoveCursor, budget?: SnapshotBudget): string[] | undefined {
288
+ const validCursor = (cursor: HeadMoveCursor) => Number.isSafeInteger(cursor.size) && cursor.size >= 0 &&
289
+ (cursor.size > 0 ? !!cursor.head && !!cursor.tree : cursor.head === null && cursor.tree === null && /^[0-9a-f]{64}$/.test(cursor.unbornRefHash ?? "")) &&
290
+ cursor.anchorOffset === Math.max(0, cursor.size - HEAD_LOG_TAIL_BYTES) && /^[0-9a-f]{64}$/.test(cursor.anchor);
291
+ if (!validCursor(start) || !validCursor(end) || (start.size > 0 && (start.dev !== end.dev || start.ino !== end.ino)) ||
292
+ end.size < start.size || end.size - start.size > HEAD_LOG_MAX_APPEND_BYTES) return undefined;
293
+ if (!end.size) {
294
+ checkSnapshotBudget(budget);
295
+ const unchanged = start.unbornRefHash === end.unbornRefHash && missingHeadLog(repo);
296
+ checkSnapshotBudget(budget);
297
+ return unchanged ? [] : undefined;
298
+ }
299
+ const appended = readHeadLog(repo, budget, (fd, stat) => {
300
+ if (stat.dev !== end.dev || stat.ino !== end.ino || stat.size < end.size) return undefined;
301
+ for (const cursor of [start, end]) {
302
+ const anchor = headLogBytes(fd, cursor.anchorOffset, cursor.size - cursor.anchorOffset, budget);
303
+ if (!anchor || headLogHash(anchor) !== cursor.anchor) return undefined;
304
+ }
305
+ return headLogBytes(fd, start.size, end.size - start.size, budget);
306
+ });
307
+ if (!appended) return undefined;
308
+ if (!appended.length) return start.head === end.head && start.tree === end.tree ? [] : undefined;
309
+ if (appended[appended.length - 1] !== 10) return undefined;
310
+ const lines = appended.toString("utf8").slice(0, -1).split("\n");
311
+ if (lines.length > HEAD_LOG_MAX_MOVES) return undefined;
312
+ const own = new Set<string>(), incoming = new Set<string>();
313
+ let previous = start.head;
314
+ for (const line of lines) {
315
+ checkSnapshotBudget(budget);
316
+ const row = headLogRow(line);
317
+ if (!row || (previous === null ? !/^0+$/.test(row.old) : row.old !== previous)) return undefined;
318
+ // Git preserves the invoking command in sequencer actions (for example pull -q --rebase (pick)).
319
+ // Classify the native phase before considering ordinary command-shaped incoming actions.
320
+ const separator = row.action.indexOf(": ");
321
+ const action = separator < 0 ? "" : row.action.slice(0, separator);
322
+ const detail = separator < 0 ? "" : row.action.slice(separator + 2);
323
+ const sequencerOwn = /^.+ \((?:pick|reword|edit|squash|fixup|continue|finish)\)$/.test(action);
324
+ const ownMove = sequencerOwn || /^(?:commit(?: \(amend\)| \(initial\))?|cherry-pick)$/.test(action);
325
+ const incomingStart = /^(?:rebase|pull(?: [^(]*)?) \(start\)$/.test(action) && /^checkout .+/.test(detail);
326
+ const incomingCommand = !action.includes("(") && /^(?:merge|pull)(?: [^:]+)?$/.test(action) && !!detail;
327
+ let incomingMove = !ownMove && (incomingStart || incomingCommand || action === "checkout" && /^moving from .+ to .+$/.test(detail));
328
+ if (action === "reset" && /^moving to .+/.test(detail)) {
329
+ // Policy: a positively fast-forward reset is incoming, even when its target contains the peer's own
330
+ // descendant commit; reflog ancestry alone cannot distinguish that intent. Rewinds/unknown resets refuse.
331
+ const ancestor = git(repo, ["merge-base", "--is-ancestor", row.old, row.next], {}, budget);
332
+ if (ancestor.status !== 0) return undefined;
333
+ incomingMove = true;
334
+ }
335
+ if (!ownMove && !incomingMove) return undefined;
336
+ let paths: string[] | undefined;
337
+ if (/^0+$/.test(row.old)) {
338
+ const root = git(repo, ["diff-tree", "--root", "--no-commit-id", "-r", "--no-renames", "--name-only", "-z", row.next, "--", scope(repo)], {}, budget);
339
+ paths = root.status === 0 ? root.stdout.split("\0").filter(Boolean) : undefined;
340
+ } else paths = knownChangedPaths(repo, row.old, row.next, budget);
341
+ if (!paths) return undefined;
342
+ for (const path of paths) { checkSnapshotBudget(budget); (ownMove ? own : incoming).add(path); }
343
+ previous = row.next;
344
+ }
345
+ if (previous !== end.head) return undefined;
346
+ // A path touched by both kinds cannot be separated at file-level undo granularity.
347
+ const hasAncestor = (paths: Set<string>, path: string) => {
348
+ for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) {
349
+ checkSnapshotBudget(budget);
350
+ if (paths.has(path.slice(0, at))) return true;
351
+ }
352
+ return false;
353
+ };
354
+ for (const path of own) {
355
+ checkSnapshotBudget(budget);
356
+ if (incoming.has(path) || hasAncestor(incoming, path)) return undefined;
357
+ }
358
+ for (const path of incoming) {
359
+ checkSnapshotBudget(budget);
360
+ if (hasAncestor(own, path)) return undefined;
361
+ }
362
+ checkSnapshotBudget(budget);
363
+ return [...incoming];
364
+ }
365
+
110
366
  /** A tree id for the project's files as they are now, leaving out the hub's own state. */
111
- export function snapshot(repo: Repo): string | undefined {
367
+ export function snapshot(repo: Repo, budget?: SnapshotBudget): string | undefined {
112
368
  const tmp = mkdtempSync(join(tmpdir(), "ahub-snap-"));
113
369
  try {
114
370
  const env = { GIT_INDEX_FILE: join(tmp, "index") };
@@ -122,12 +378,12 @@ export function snapshot(repo: Repo): string | undefined {
122
378
  utimesSync(env.GIT_INDEX_FILE, atime, mtime);
123
379
  }
124
380
  // `ahub reset --all` archives hub.db (task text, PII included) under .agenthub/archive: never copy it either.
125
- if (git(repo, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**", ":(exclude,glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(exclude,glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
381
+ if (git(repo, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**", ":(exclude,glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(exclude,glob)**/.agenthub/shadow.git/**"] : [])], env, budget).status !== 0) return undefined;
126
382
  // The copy starts from the user's index: hub state somebody tracked or staged is still in it, and the exclude above
127
383
  // only keeps `add` from touching it. Take it out explicitly; -f because staged state the hub has rewritten since
128
384
  // matches neither HEAD nor the file, which `rm --cached` otherwise refuses. Only the copy changes.
129
- if (git(repo, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**", ":(glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
130
- const r = git(repo, ["write-tree"], env);
385
+ if (git(repo, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**", ":(glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(glob)**/.agenthub/shadow.git/**"] : [])], env, budget).status !== 0) return undefined;
386
+ const r = git(repo, ["write-tree"], env, budget);
131
387
  return r.status === 0 ? r.stdout.trim() : undefined;
132
388
  } finally {
133
389
  rmSync(tmp, { recursive: true, force: true });
@@ -135,9 +391,12 @@ export function snapshot(repo: Repo): string | undefined {
135
391
  }
136
392
 
137
393
  /** Project paths (relative to the top level) that differ between two trees. */
394
+ export function knownChangedPaths(repo: Repo, from: string, to: string, budget?: SnapshotBudget): string[] | undefined {
395
+ const r = git(repo, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)], {}, budget);
396
+ return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : undefined;
397
+ }
138
398
  export function changedPaths(repo: Repo, from: string, to: string): string[] {
139
- const r = git(repo, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)]);
140
- return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : [];
399
+ return knownChangedPaths(repo, from, to) ?? [];
141
400
  }
142
401
 
143
402
  const inTree = (top: string | Repo, tree: string, path: string) => git(top, ["rev-parse", "--verify", "--quiet", `${tree}:${path}`]).status === 0;
@@ -1,4 +1,4 @@
1
- import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS, type KindMap } from "../adapters/registry.ts";
1
+ import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
2
2
 
3
3
  /**
4
4
  * #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
@@ -20,27 +20,29 @@ export const isStartablePeer = (value: unknown): value is StartablePeer => typeo
20
20
 
21
21
  export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
22
22
  /** The checked per-peer start modes; only peers with both forms may be configured. */
23
- export function peerStartConfig(value: unknown, instances?: KindMap): PeerStartConfig {
23
+ export function peerStartConfig(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog): PeerStartConfig {
24
24
  if (value === undefined) return {};
25
25
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
26
26
  const result: PeerStartConfig = {};
27
27
  for (const [peer, block] of Object.entries(value)) {
28
- const extra = definitionOf(peer, instances)?.kind !== peer && !!definitionOf(peer, instances);
29
- if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer) && !extra) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
28
+ const configured = !!catalog?.definitionOf(peer) && !definitionOf(peer);
29
+ const extra = (catalog?.definitionOf ?? definitionOf)(peer, instances)?.kind !== peer && !!(catalog?.definitionOf ?? definitionOf)(peer, instances);
30
+ if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer) && !extra && !configured) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
30
31
  if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
32
+ if (Object.keys(block).some(key => key !== "start_mode")) throw new Error(`peers.${peer}: unsupported setting`);
31
33
  const mode = (block as { start_mode?: unknown }).start_mode;
32
34
  if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
33
- if (extra && mode !== undefined && mode !== "headless") throw new Error(`peers.${peer}.start_mode: extra instances require headless mode in I2`);
35
+ if ((extra || configured) && mode !== undefined && mode !== "headless") throw new Error(`peers.${peer}.start_mode: extra instances require headless mode in I2`);
34
36
  result[peer] = mode === undefined ? {} : { start_mode: mode as StartMode };
35
37
  }
36
38
  return result;
37
39
  }
38
40
 
39
41
  /** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
40
- export function startModeOf(peers: PeerStartConfig | undefined, peer: string, instances?: KindMap): StartMode {
41
- if (definitionOf(peer, instances)?.kind !== peer && definitionOf(peer, instances)) return peers?.[peer]?.start_mode ?? "headless";
42
+ export function startModeOf(peers: PeerStartConfig | undefined, peer: string, instances?: KindMap, catalog?: ProjectAdapterCatalog): StartMode {
43
+ if ((catalog?.definitionOf ?? definitionOf)(peer, instances)?.kind !== peer && (catalog?.definitionOf ?? definitionOf)(peer, instances)) return peers?.[peer]?.start_mode ?? "headless";
42
44
  if ((REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? definitionOf(peer)?.start.defaultForm ?? "tui";
43
- return definitionOf(peer)?.start.defaultForm ?? "headless";
45
+ return (catalog?.definitionOf ?? definitionOf)(peer)?.start.defaultForm ?? "headless";
44
46
  }
45
47
 
46
48
  /**
@@ -10,6 +10,7 @@ export interface SweepRecord {
10
10
  }
11
11
  export interface TaskSweepConfig {
12
12
  enabled: boolean;
13
+ no_writes_turns?: number;
13
14
  interval_s: number;
14
15
  unaccepted_min: number;
15
16
  idle_min: number;
@@ -19,7 +20,7 @@ export interface TaskSweepConfig {
19
20
  auto_reassign: boolean;
20
21
  }
21
22
  export const DEFAULT_TASK_SWEEP: TaskSweepConfig = {
22
- enabled: false, ready_min: 60, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
23
+ enabled: false, no_writes_turns: 2, ready_min: 60, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
23
24
  };
24
25
 
25
26
  /** Config cannot silently enable a sweep or reassignment through a truthy string. */
@@ -33,6 +34,11 @@ export function taskSweepConfig(input: unknown): TaskSweepConfig {
33
34
  if (typeof values[key] !== "boolean") throw new Error(`task_sweep.${key} must be a boolean`);
34
35
  out[key] = values[key];
35
36
  }
37
+ if (values.no_writes_turns !== undefined) {
38
+ const limit = values.no_writes_turns;
39
+ if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 0) throw new Error("task_sweep.no_writes_turns must be a safe nonnegative integer");
40
+ out.no_writes_turns = limit;
41
+ }
36
42
  for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"] as const) {
37
43
  if (values[key] === undefined) continue;
38
44
  const value = values[key];
@@ -77,7 +83,7 @@ export interface PlannerGroomingOptions {
77
83
  acceptedIssues?: ReadonlyMap<number, { repo: string; issue: number; lastEditedAt: string | null }>;
78
84
  }
79
85
  const GROOMING_TASK_LIMIT = 500, GROOMING_ITEM_LIMIT = 256;
80
- const GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency"]);
86
+ const GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency", "no-writes"]);
81
87
  const groomingRepo = (value: unknown): string | undefined => {
82
88
  if (typeof value !== "string") return undefined;
83
89
  if (value.startsWith("path:/") && value.length <= 4096 && !/\p{Cc}/u.test(value)) return value;