@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
@@ -0,0 +1,246 @@
1
+ import { execFile } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import { basename, join, relative, sep } from "node:path";
4
+ import { childEnv } from "./child-process.ts";
5
+ import { hubGitArgv, hubGitEnv } from "./git.ts";
6
+ import { HUB } from "./envelope.ts";
7
+ import { realPath } from "./project.ts";
8
+ import type { Task } from "./board.ts";
9
+
10
+ /**
11
+ * #1057 worktree shelf. Which worktrees are finished is DERIVED at read time from facts and two person overrides, never stored as a flag.
12
+ * This module reads facts and computes the partition. It never deletes a worktree or a local branch: removal exists only in the person's
13
+ * CLI process (`worktree-tidy.ts`, a later change), and `test/worktree-shelf.test.ts` greps the daemon-reachable modules for any removal argv.
14
+ */
15
+
16
+ export type PrState = "open" | "merged" | "closed" | "unknown";
17
+ /** `settled` is what a PII-bound row says in a reply for a merged or closed PR: the reason is real on the host, but a reply carries no PR state of a private task. */
18
+ export type SettledReason = "merged" | "closed" | "quiet" | "parked" | "settled";
19
+ export type OverrideValue = "settled" | "active";
20
+ export interface WorktreeOverride { path: string; value: OverrideValue; at: number }
21
+
22
+ export interface WorktreeFact {
23
+ /** Absolute, host-only. A row shown to a person is displayed through `displayPath`. */
24
+ path: string;
25
+ branch: string | null;
26
+ head: string;
27
+ locked: boolean;
28
+ prunable: boolean;
29
+ /** The project's main checkout: never shelved. */
30
+ main: boolean;
31
+ /** A configured instance worktree (`.worktrees/<instance>`): never shelved. */
32
+ instance: boolean;
33
+ /** Max of the HEAD commit time, the worktree index mtime and the latest time someone other than the hub wrote to the bound task. 0 when none could be read. */
34
+ lastActiveAt: number;
35
+ /** False when the commit time or the index could not be read (a failed git call or a spent budget): such a row is never settled by a PR or by quiet time, because 'no newer work' is unknown. */
36
+ activityKnown?: boolean;
37
+ /** `private`: the bound task is a PII task. The binding still drives the partition and tidy's refusals on the host, but `row()` never puts its id, PR or state in a reply. */
38
+ task?: { id: number; state: string; pr?: number; repo?: string; private?: true };
39
+ pr: { state: PrState; changedAt?: number };
40
+ }
41
+ /** A row built for a reply. A PII-bound row carries `private: true` and `taskState: "private"` and no task id or PR. This is the one place a reply row is made, so redaction happens here (AGENTS.md), never at a call site. */
42
+ export interface ShelfRow { path: string; branch: string | null; reason?: SettledReason; task?: number; taskState?: string; pr?: number; private?: true; /** The main checkout or a declared instance worktree: never shelved, so a panel offers no Park for it. */ fixed?: true; lastActiveAt: number }
43
+ export interface Partition { live: ShelfRow[]; settled: ShelfRow[] }
44
+
45
+ const DAY_MS = 86_400_000;
46
+ const row = (fact: WorktreeFact, reason?: SettledReason): ShelfRow => ({
47
+ path: fact.path, branch: fact.branch, lastActiveAt: fact.lastActiveAt, ...(fact.main || fact.instance ? { fixed: true as const } : {}),
48
+ ...(reason ? { reason: fact.task?.private && (reason === "merged" || reason === "closed") ? "settled" as const : reason } : {}),
49
+ ...(fact.task?.private ? { private: true as const, taskState: "private" }
50
+ : { ...(fact.task ? { task: fact.task.id, taskState: fact.task.state } : {}), ...(fact.task?.pr !== undefined ? { pr: fact.task.pr } : {}) }),
51
+ });
52
+
53
+ /** An override counts only while no newer activity exists: `lastActiveAt <= at` (D2). Newer activity makes it inert. */
54
+ export const overrideInForce = (override: WorktreeOverride | undefined, lastActiveAt: number): override is WorktreeOverride =>
55
+ !!override && lastActiveAt <= override.at;
56
+
57
+ /**
58
+ * D3, pure. A row whose activity is unknown (`activityKnown === false`) is live: every settling rule compares with `lastActiveAt`, which is then not a fact.
59
+ * Rules in order: (1) the main checkout and instance worktrees are never shelved; (2) an effective override wins; (3) a merged or
60
+ * closed PR with `lastActiveAt <= changedAt` is settled; (4) `quietDays > 0` and a quiet span of at least that long is settled; (5) live.
61
+ * `now`, the facts and the overrides are arguments: no clock, no I/O.
62
+ */
63
+ export function partition(facts: readonly WorktreeFact[], overrides: readonly WorktreeOverride[], now: number, quietDays: number): Partition {
64
+ const byPath = new Map(overrides.map(o => [o.path, o]));
65
+ const out: Partition = { live: [], settled: [] };
66
+ for (const fact of facts) {
67
+ if (fact.main || fact.instance || fact.activityKnown === false) { out.live.push(row(fact)); continue; }
68
+ const override = byPath.get(fact.path);
69
+ if (overrideInForce(override, fact.lastActiveAt)) {
70
+ if (override.value === "settled") out.settled.push(row(fact, "parked")); else out.live.push(row(fact));
71
+ continue;
72
+ }
73
+ if ((fact.pr.state === "merged" || fact.pr.state === "closed") && fact.pr.changedAt !== undefined && fact.lastActiveAt <= fact.pr.changedAt) {
74
+ out.settled.push(row(fact, fact.pr.state)); continue;
75
+ }
76
+ if (quietDays > 0 && fact.lastActiveAt > 0 && now - fact.lastActiveAt >= quietDays * DAY_MS) { out.settled.push(row(fact, "quiet")); continue; }
77
+ out.live.push(row(fact));
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** Project-relative inside the root (the root itself is `.`), otherwise the basename only: paths stay host-only, as in `snapshots.ts`. */
83
+ export function displayPath(root: string, path: string): string {
84
+ if (path === root) return ".";
85
+ const rel = relative(root, path);
86
+ return rel && !rel.startsWith("..") && !rel.startsWith(sep) && rel !== path ? rel : basename(path);
87
+ }
88
+
89
+ /** Whether a path is the root or below it, so a row can be resolved against the project root and one outside it cannot. */
90
+ export const shelfInsideRoot = (root: string, path: string): boolean => path === root || (!relative(root, path).startsWith("..") && !relative(root, path).startsWith(sep) && relative(root, path) !== path);
91
+
92
+ /**
93
+ * The latest time anyone but the hub wrote to a task: its work, not its bookkeeping. The hub's own entries (the merge result, the owner notice,
94
+ * sweeps) follow the event they record, so counting them would put `lastActiveAt` after a merge's `changedAt` and a merged task could never settle (#1057 D1/D3).
95
+ */
96
+ export const taskWorkAt = (task: Pick<Task, "history" | "created">): number =>
97
+ Math.max(task.created, ...task.history.filter(h => h.by !== HUB && Number.isFinite(h.at)).map(h => h.at));
98
+
99
+ /** The task a worktree branch belongs to: the open task, else the most recently updated one, whose `refs.branch` equals the branch. */
100
+ export function boundTask(tasks: readonly Pick<Task, "id" | "state" | "refs" | "sdlc" | "updated" | "created" | "history">[], branch: string | null): WorktreeFact["task"] & { workAt: number } | undefined {
101
+ if (!branch) return undefined;
102
+ const matching = tasks.filter(t => t.refs.branch === branch);
103
+ if (!matching.length) return undefined;
104
+ const closed = (state: string) => state === "approved" || state === "cancelled";
105
+ const pick = [...matching].sort((a, b) => Number(closed(a.state)) - Number(closed(b.state)) || b.updated - a.updated)[0]!;
106
+ const pr = pick.sdlc?.pr, repo = pick.sdlc?.repo;
107
+ return { id: pick.id, state: pick.state, ...(typeof pr === "number" ? { pr } : {}), ...(typeof repo === "string" && typeof pr === "number" ? { repo } : {}), workAt: taskWorkAt(pick) };
108
+ }
109
+
110
+ /**
111
+ * PR state from evidence already on the board (D1): only the guarded merge's own record says `merged`. A cancelled task says nothing about its PR (`Tasks.cancel`
112
+ * touches only the board, so the PR can still be open on GitHub), and a task with no record says nothing either: both are `unknown`. Only the explicit refresh
113
+ * action, which reads GitHub, may learn `closed` or `open` (a later change).
114
+ */
115
+ export function prFromTask(task: Pick<Task, "state" | "sdlc" | "history"> | undefined, observed?: PrObserver): WorktreeFact["pr"] {
116
+ const merge = task?.sdlc?.merge;
117
+ if (merge && merge.state === "merged" && Number.isFinite(merge.at)) return { state: "merged", changedAt: merge.at };
118
+ // A PR state the person asked the hub to read (the explicit refresh action) is a stored fact, never fetched here: this module has no GitHub access (AC5).
119
+ const repo = task?.sdlc?.repo, pr = task?.sdlc?.pr;
120
+ const seen = typeof repo === "string" && typeof pr === "number" ? observed?.(repo, pr) : undefined;
121
+ if (seen) return { state: seen.state, ...(seen.changedAt !== undefined && seen.state !== "open" ? { changedAt: seen.changedAt } : {}) };
122
+ return { state: "unknown" };
123
+ }
124
+ /** The stored result of an explicit PR refresh for one repository and PR number, or undefined when none was read. */
125
+ export type PrObserver = (repo: string, pr: number) => { state: "open" | "merged" | "closed"; changedAt?: number } | undefined;
126
+
127
+ export interface RawWorktree { path: string; branch: string | null; head: string; locked: boolean; prunable: boolean }
128
+ /** `git worktree list --porcelain -z`: one NUL-separated field per attribute, an empty field ends a record. Bounded. */
129
+ export function parseWorktreeList(stdout: string, limit = 256): { rows: RawWorktree[]; complete: boolean } {
130
+ const rows: RawWorktree[] = [];
131
+ let current: Partial<RawWorktree> | undefined;
132
+ const flush = () => { if (current?.path) rows.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? "", locked: !!current.locked, prunable: !!current.prunable }); current = undefined; };
133
+ for (const field of stdout.split("\0")) {
134
+ if (field === "") { flush(); continue; }
135
+ if (field.startsWith("worktree ")) { flush(); current = { path: field.slice(9) }; continue; }
136
+ if (!current) continue;
137
+ if (field.startsWith("HEAD ")) current.head = field.slice(5);
138
+ else if (field.startsWith("branch refs/heads/")) current.branch = field.slice("branch refs/heads/".length);
139
+ else if (field === "locked" || field.startsWith("locked ")) current.locked = true;
140
+ else if (field === "prunable" || field.startsWith("prunable ")) current.prunable = true;
141
+ }
142
+ flush();
143
+ return { rows: rows.slice(0, limit), complete: rows.length <= limit };
144
+ }
145
+
146
+ export interface ShelfGitPort {
147
+ /** Read-only git, bounded by `timeoutMs`, never blocking the event loop. `undefined` is a failed or timed-out read, never an empty answer. */
148
+ run(cwd: string, args: string[], timeoutMs: number): Promise<{ status: number | null; stdout: string } | undefined>;
149
+ }
150
+ /** One git call's ceiling, and the whole read's: a slow repository answers `complete: false` after this, never a stalled hub (D1's snapshot-budget pattern). */
151
+ export const READ_TIMEOUT_MS = 5_000, TOTAL_BUDGET_MS = 3_000, READ_CONCURRENCY = 4;
152
+ export const realShelfGit: ShelfGitPort = {
153
+ async run(cwd, args, timeoutMs) {
154
+ if (!shelfReadAllowed(args)) throw new Error("the worktree shelf runs read-only git commands only");
155
+ const argv = hubGitArgv(["-C", cwd, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args]);
156
+ return await new Promise(resolve => {
157
+ execFile(argv[0]!, argv.slice(1), { encoding: "utf8", timeout: Math.max(1, Math.min(READ_TIMEOUT_MS, timeoutMs)), killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: hubGitEnv(childEnv(), { repositoryIsolation: true }) }, (error, stdout) => {
158
+ const code = (error as (Error & { code?: unknown }) | null)?.code;
159
+ // a non-zero exit is an answer (status), a spawn failure, timeout or overflow is not
160
+ resolve(error && typeof code !== "number" ? undefined : { status: typeof code === "number" ? code : 0, stdout: String(stdout ?? "") });
161
+ });
162
+ });
163
+ },
164
+ };
165
+ /**
166
+ * The only git argv shapes the shelf runs, all reads. `realShelfGit.run` refuses anything else before spawning, so no removal, branch, ref or
167
+ * prune argv can reach git from the daemon (AC4); the boundary test also greps the module text for them.
168
+ */
169
+ export const SHELF_GIT_READS: readonly (readonly string[])[] = [
170
+ ["worktree", "list", "--porcelain", "-z"],
171
+ ["log", "-1", "--format=%ct", "HEAD"],
172
+ ["rev-parse", "--absolute-git-dir"],
173
+ ];
174
+ export const shelfReadAllowed = (args: readonly string[]): boolean => SHELF_GIT_READS.some(shape => shape.length === args.length && shape.every((word, i) => word === args[i]));
175
+
176
+ export interface ShelfInput {
177
+ root: string;
178
+ /** Called AFTER the git reads, so the board is read once and fresh, never held across an await. */
179
+ tasks: () => readonly Pick<Task, "id" | "state" | "refs" | "sdlc" | "updated" | "created" | "history">[];
180
+ /** Whether a task is a PII task (`Tasks.isPii`). Its binding is kept for the partition and the refusals and redacted in the reply row. A host that does not pass it treats no task as private. */
181
+ isPrivate?: (taskId: number) => boolean;
182
+ /** Resolved instance worktree paths (`.worktrees/<instance>`): never shelved. */
183
+ instancePaths: readonly string[];
184
+ /** Stored PR observations (D1); called after the git reads, in the same synchronous segment as `tasks`. */
185
+ observed?: PrObserver;
186
+ git?: ShelfGitPort;
187
+ now?: () => number;
188
+ budgetMs?: number;
189
+ }
190
+ export interface ShelfFacts { facts: WorktreeFact[]; complete: boolean; available: boolean }
191
+
192
+ /** The worktree's own index mtime (D1: it covers uncommitted editing). `undefined` is unknown; a missing index file is a known 0. */
193
+ async function indexMtime(git: ShelfGitPort, path: string, timeoutMs: number): Promise<number | undefined> {
194
+ const dir = await git.run(path, ["rev-parse", "--absolute-git-dir"], timeoutMs);
195
+ if (!dir || dir.status !== 0) return undefined;
196
+ try { const info = await stat(join(dir.stdout.trim(), "index")); return info.isFile() ? info.mtimeMs : 0; }
197
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? 0 : undefined; }
198
+ }
199
+
200
+ interface Probe { commitAt: number; touched: number; known: boolean }
201
+
202
+ /**
203
+ * D1: facts per worktree. One `worktree list`, then a commit-time read and an index read per worktree, at most `READ_CONCURRENCY` at a time and all under one
204
+ * total budget. Asynchronous throughout: the daemon's event loop is never held. When the budget is spent, the rows not yet read are still listed, with unknown
205
+ * activity (live, never settled), and the result says `complete: false`. Never writes.
206
+ */
207
+ export async function readWorktreeFacts(input: ShelfInput): Promise<ShelfFacts> {
208
+ const git = input.git ?? realShelfGit, now = input.now ?? Date.now, deadline = now() + (input.budgetMs ?? TOTAL_BUDGET_MS);
209
+ const left = () => Math.max(0, deadline - now());
210
+ let root: string;
211
+ try { root = realPath(input.root); } catch { return { facts: [], complete: false, available: false }; }
212
+ const listed = await git.run(root, ["worktree", "list", "--porcelain", "-z"], left());
213
+ if (!listed || listed.status !== 0) return { facts: [], complete: false, available: false };
214
+ const parsed = parseWorktreeList(listed.stdout);
215
+ const paths = parsed.rows.map(raw => { try { return realPath(raw.path); } catch { return raw.path; } }); // a prunable worktree's directory is gone: keep the registered path
216
+ const probes: (Probe | undefined)[] = parsed.rows.map(() => undefined);
217
+ let next = 0, cut = false;
218
+ const worker = async () => {
219
+ for (;;) {
220
+ const i = next++;
221
+ if (i >= paths.length) return;
222
+ if (left() <= 0) { cut = true; return; }
223
+ const stamp = await git.run(paths[i]!, ["log", "-1", "--format=%ct", "HEAD"], left());
224
+ const text = stamp && stamp.status === 0 ? stamp.stdout.trim() : "";
225
+ const touched = left() > 0 ? await indexMtime(git, paths[i]!, left()) : undefined;
226
+ if (!/^\d{1,12}$/.test(text) || touched === undefined) { probes[i] = { commitAt: 0, touched: 0, known: false }; cut = cut || left() <= 0; continue; }
227
+ probes[i] = { commitAt: Number(text) * 1000, touched, known: true };
228
+ }
229
+ };
230
+ await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, paths.length) }, worker));
231
+ // From here to the return nothing is awaited: the board is read once, synchronously, so it is current for every row.
232
+ const tasks = input.tasks(), instances = new Set(input.instancePaths), facts: WorktreeFact[] = [];
233
+ let unread = false;
234
+ parsed.rows.forEach((raw, i) => {
235
+ const path = paths[i]!, probe = probes[i], task = boundTask(tasks, raw.branch), bound = task ? tasks.find(t => t.id === task.id) : undefined;
236
+ if (!probe?.known) unread = true;
237
+ facts.push({
238
+ path, branch: raw.branch, head: raw.head, locked: raw.locked, prunable: raw.prunable,
239
+ main: path === root, instance: instances.has(path),
240
+ lastActiveAt: Math.max(probe?.commitAt ?? 0, probe?.touched ?? 0, task?.workAt ?? 0), activityKnown: probe?.known === true,
241
+ ...(task ? { task: { id: task.id, state: task.state, ...(task.pr !== undefined ? { pr: task.pr } : {}), ...(task.repo !== undefined ? { repo: task.repo } : {}), ...(input.isPrivate?.(task.id) ? { private: true as const } : {}) } } : {}),
242
+ pr: prFromTask(bound, input.observed),
243
+ });
244
+ });
245
+ return { facts, complete: parsed.complete && !cut && !unread, available: true };
246
+ }
package/src/local/deny.ts CHANGED
@@ -77,6 +77,54 @@ const hooksUnder = (parent: string): string => {
77
77
  return depth > MAX_ANCHOR_DEPTH ? `^.*/${hfsFoldedName("hooks")}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName("hooks")}(/|$)`;
78
78
  };
79
79
 
80
+ /**
81
+ * #1123: a sandboxed command may not move what a branch, HEAD or a linked worktree points at. `refs/**` (branches, tags, `refs/stash`), `packed-refs*`, `HEAD*` (its lock too) and
82
+ * `worktrees/**` (a linked worktree's admin dir: its HEAD, index, logs) are refused directly under every plain `.git` and under each external git dir (the common dir and a linked
83
+ * worktree's own, whatever they are named). The hub's own git runs outside the sandbox and is unaffected, so #1115's hub commit still lands.
84
+ *
85
+ * Every name must sit DIRECTLY under its git dir. An unanchored `refs` or `worktrees` filter would also match an ANCESTOR of the project with that name, and a project under
86
+ * `~/worktrees/` would lose every `.git` write (the #520 `hooks` lesson). So each rule is adjacent by construction: one literal for `.git` + a short name, or, for the long names
87
+ * (`worktrees`, `packed-refs`, where `.git` + name would pass the 1024-byte literal limit), a front half and a back half that must both match, or a depth anchor for an external dir.
88
+ * A submodule's git dir (`.git/modules/<name>`, nested ones, and `.git/worktrees/<wt>/modules/<name>` for a linked worktree) is covered the same way (`subDirs`, the discovered
89
+ * ones: a git dir that is not discovered is refused whole by the #518/#520 rules), per the person's "deny .git writes" decision: nothing is listed as a residual.
90
+ */
91
+ const refDepth = (d: string): number => d.split("/").filter(Boolean).length;
92
+ /**
93
+ * `^(/[^/]+){depth}/<name>(/|$)`: `name` as the entry directly under `d`, the depth written out because seatbelt ignores `{n}`. The folded names cost (measured, bytes of regex): `refs` 334,
94
+ * `worktrees` 669, so past the depth where the path plus the name would near 1024 it falls back to the unanchored name, which over-refuses only under an ancestor with that name and
95
+ * never under-protects.
96
+ */
97
+ const refUnder = (d: string, name: string): string => {
98
+ const depth = refDepth(d), cost = Buffer.byteLength(hfsFoldedName(name)) + depth * 6 + 8;
99
+ return cost > 1000 ? `^.*/${hfsFoldedName(name)}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName(name)}(/|$)`;
100
+ };
101
+ function gitRefRules(gitDirs: string[], subDirs: string[] = []): string[] {
102
+ const git = hfsFoldedName(".git");
103
+ const re = (s: string) => `(regex ${sbplString(s)})`;
104
+ const name = (n: string) => hfsFoldedName(n);
105
+ // A linked worktree's own submodule git dirs (`worktrees/<wt>/modules/**`) are governed by the #518/#520 rules, not these.
106
+ const plainSub = `(require-not ${re(`^.*/${git}/worktrees/[^/]+/modules(/|$)`)})`;
107
+ const plain = [
108
+ // `.git` (335 bytes) + `HEAD` or `refs` (334) fit one literal: adjacent by construction. `HEAD.lock` goes with `HEAD`.
109
+ re(`^.*/${git}/${name("HEAD")}(\\.lock)?$`),
110
+ re(`^.*/${git}/${name("refs")}(/|$)`),
111
+ // `.git` + `packed-refs` (803) and `.git` + `worktrees` (669) do not fit one literal. The front literal pins the start of the name right under a `.git`, and for `worktrees` the
112
+ // back literal pins its end; `packed` alone covers `packed-refs` and its lock/new files, which is the intent.
113
+ re(`^.*/${git}/${name("packed")}`),
114
+ `(require-all ${re(`^.*/${git}/${name("work")}`)} ${re(`^.*${name("trees")}(/|$)`)} ${plainSub})`,
115
+ ];
116
+ const external = [...gitDirs, ...subDirs].flatMap((d) => {
117
+ const own = sbplString(d), direct = re(`^${sbplEscape(d)}/[^/]+$`);
118
+ return [
119
+ `(require-all ${direct} ${re(`^.*/${name("HEAD")}(\\.lock)?$`)})`,
120
+ `(require-all ${direct} ${re(`^.*/${name("packed")}`)})`,
121
+ `(require-all (subpath ${own}) ${re(refUnder(d, "refs"))})`,
122
+ `(require-all (subpath ${own}) ${re(refUnder(d, "worktrees"))} (require-not ${re(`^${sbplEscape(d)}/worktrees/[^/]+/modules(/|$)`)}))`,
123
+ ];
124
+ });
125
+ return [...plain, ...external];
126
+ }
127
+
80
128
  export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit: readonly { root: string; entries: string[]; dirs?: string[] }[] = [], worktreesRoot?: string): string[] {
81
129
  const git = hfsFoldedName(".git");
82
130
  const re = (s: string) => `(regex ${sbplString(s)})`;
@@ -157,6 +205,7 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit:
157
205
  `(require-all ${re(`^.*/${git}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
158
206
  re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
159
207
  `(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
208
+ ...gitRefRules(gitDirs, allDirs),
160
209
  ...modules,
161
210
  ...siblings,
162
211
  ...external,
@@ -117,7 +117,7 @@ export const TOOL_SCHEMAS = [
117
117
  fn("write", "Create or overwrite a file inside the project. Needs the user's approval.", { path: str, content: str }, ["path", "content"]),
118
118
  fn("edit", "Replace one exact, unique occurrence of `old` with `new` in a file. Needs the user's approval.", { path: str, old: str, new: str }, ["path", "old", "new"]),
119
119
  fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it. Follows hub permission policy: proven bounded read-only commands may run automatically in ask-when-needed; other commands ask.", { command: str, timeout_s: { type: "integer", minimum: 1, maximum: 600 } }, ["command"]),
120
- fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
120
+ fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused. The sandbox does not let you move a branch, tag or HEAD anywhere (commit, branch, tag, stash, switch, merge, rebase and reset fail, submodules included), nor stage with add inside a linked worktree: edit files, and the hub commits and pushes them when you call hub_task_done.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
121
121
  fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"]),
122
122
  ];
123
123
 
@@ -235,7 +235,9 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
235
235
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
236
236
  const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
237
237
  try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
238
- return `${res.output}\n(exit ${res.code})`;
238
+ // #1123: the profile refuses writes to refs, HEAD, packed-refs and a linked worktree's admin dir, so a commit, branch, tag, stash or switch cannot succeed here. Say so, with the next step.
239
+ const refused = res.code !== 0 && /(?:\.git\/(?:refs|HEAD|packed-refs|worktrees)\b|cannot lock ref|index\.lock)[^\n]*(?:Operation not permitted|not permitted)/i.test(res.output);
240
+ return `${res.output}${refused ? "\nnote: the sandbox does not let you move a branch, tag or HEAD, or stage inside a linked worktree (#1123). Leave your edits in the working tree and finish with hub_task_done. For implementation work the hub stages, commits and pushes them for you; for work that changes no files, or a private task, there is nothing for it to commit, so do not try to commit it yourself." : ""}\n(exit ${res.code})`;
239
241
  }
240
242
  case "doc_read": {
241
243
  if (!ctx.docRead) return "error: doc_read is not enabled for this project (local.doc_read in .agenthub/config.local.json)";
@@ -1,4 +1,5 @@
1
1
  import { buildIntentBrief, type IntentBriefOptions, type IntentBriefSource } from "@staix/agent-mem";
2
+ import { controlWriteSpan } from "../hub/control-write-trace.ts";
2
3
  import type { PeerId } from "../hub/envelope.ts";
3
4
  import type { MemoryClient } from "./client.ts";
4
5
 
@@ -70,6 +71,10 @@ export class Briefs {
70
71
  ) {}
71
72
 
72
73
  async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
74
+ return controlWriteSpan("memory", () => this.forTaskBody(peer, task, priority));
75
+ }
76
+
77
+ private async forTaskBody(peer: PeerId, task: BriefTask, priority: readonly NativeItem[]): Promise<string | undefined> {
73
78
  // A brief snapshots its configured cap once, before either optional source is selected.
74
79
  const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
75
80
  let corpusRevision: string | undefined;
@@ -38,7 +38,7 @@ export class Capture {
38
38
  }
39
39
 
40
40
  observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
41
- if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || this.opts.skip?.includes(call.tool)) return;
41
+ if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_set_title" || this.opts.skip?.includes(call.tool)) return;
42
42
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
43
43
  if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
44
44
  // ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
@@ -1,5 +1,6 @@
1
1
  import packageJson from "../../package.json";
2
2
  import { createHash } from "node:crypto";
3
+ import { controlWriteSpan } from "../hub/control-write-trace.ts";
3
4
  import { CaptureStore, SearchIndex, looksSecret, memoryBase, readCaptureConfig, type CaptureOptions as StoreCaptureOptions, type MemoryStore, type CandidateStore, type View } from "@staix/agent-mem";
4
5
 
5
6
  export interface CaptureEvent {
@@ -54,8 +55,10 @@ export class MemoryClient {
54
55
  }
55
56
  close(): void { this.closed = true; this.releaseCapture(); }
56
57
  private run<T>(work: (o: MemoryClientOptions) => T): T | undefined {
57
- try { if (!this.options || this.closed) return undefined; this.options.check(); return work(this.options); }
58
- catch { this.releaseCapture(); this.options?.log?.("amem operation unavailable"); return undefined; }
58
+ return controlWriteSpan("memory", () => {
59
+ try { if (!this.options || this.closed) return undefined; this.options.check(); return work(this.options); }
60
+ catch { this.releaseCapture(); this.options?.log?.("amem operation unavailable"); return undefined; }
61
+ });
59
62
  }
60
63
  private rows(o: MemoryClientOptions): View[] {
61
64
  const store = o.store;
@@ -23,6 +23,8 @@ let shellSeq = 0;
23
23
  let forcedFailure = "";
24
24
  let turnGeneration = 0;
25
25
  let sessionId = "";
26
+ let titleRevision = 0;
27
+ let titleEpoch = "";
26
28
  let pendingPromptId: string | undefined;
27
29
  let activePromptId: string | undefined;
28
30
  let settlement: Promise<void> | undefined;
@@ -93,11 +95,36 @@ async function poll(pi: ExtensionAPI): Promise<void> {
93
95
  await post("/ack", { id: command.id, ok: false, error: "Pi prompt insertion did not synchronously claim a native turn", refusal: { kind: "prompt_uncertain", promptId: command.id, sessionId, generation: turnGeneration } });
94
96
  continue;
95
97
  }
98
+ } else if (command.type === "set_session_title") {
99
+ const refuse = (cause: string, error: string) => post("/ack", { id: command.id, ok: true, result: { ok: false, reason: "invalid", cause, error, sessionId, titleEpoch, titleRevision } });
100
+ const nativeId = runtimeCtx?.sessionManager?.getHeader?.()?.id;
101
+ if (command.sessionId !== sessionId || nativeId !== sessionId || command.titleEpoch !== undefined && command.titleEpoch !== titleEpoch) {
102
+ await refuse("session", "Pi native session changed before the title write; check ahub status and reconnect the current session before retrying."); continue;
103
+ }
104
+ if (command.generation !== turnGeneration) {
105
+ await refuse("generation", "Pi native turn changed before the title write; wait for the current turn and check ahub status before retrying."); continue;
106
+ }
107
+ if (typeof command.title !== "string" || !command.title.trim() || [...command.title].length > 80 || typeof command.onlyIfUnnamed !== "boolean") {
108
+ await refuse("arguments", "Pi title command arguments are invalid; use a 1-80 character normalized title and a conditional-write flag before retrying."); continue;
109
+ }
110
+ if (typeof pi.getSessionName !== "function" || typeof pi.setSessionName !== "function") {
111
+ await refuse("native_unavailable", "Pi native title API is unavailable; restart Pi with the supported hub extension and check ahub status before retrying."); continue;
112
+ }
113
+ // The native read and synchronous write share one JS turn: a manual rename always wins against the agent.
114
+ const before = pi.getSessionName() ?? "";
115
+ if (command.onlyIfUnnamed && before) {
116
+ await post("/ack", { id: command.id, ok: true, result: { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title.", sessionId, titleEpoch, titleRevision, sessionName: before } }); continue;
117
+ }
118
+ pi.setSessionName(command.title);
119
+ const sessionName = pi.getSessionName() ?? "";
120
+ await post("/ack", { id: command.id, ok: true, result: { ok: sessionName === command.title, ...(sessionName === command.title ? {} : { reason: "invalid", cause: "native_readback", mayHaveApplied: true, error: "Pi native title readback did not confirm the write; the title may have been applied; check ahub status before retrying." }), sessionId, titleEpoch, titleRevision, sessionName } });
121
+ continue;
96
122
  } else if (command.type === "get_session_state") {
97
123
  const entries = runtimeCtx.sessionManager.getEntries();
98
124
  await post("/ack", { id: command.id, ok: true, result: {
99
125
  sessionId: runtimeCtx.sessionManager.getHeader()?.id,
100
126
  sessionFile: runtimeCtx.sessionManager.getSessionFile(),
127
+ ...(typeof pi.getSessionName === "function" ? { sessionName: pi.getSessionName() ?? "", titleEpoch, titleRevision } : {}),
101
128
  idle: runtimeCtx.isIdle() === true,
102
129
  empty: entries.every((entry: any) => ["model_change", "thinking_level_change"].includes(entry.type)),
103
130
  } });
@@ -120,7 +147,14 @@ async function poll(pi: ExtensionAPI): Promise<void> {
120
147
  }
121
148
  }
122
149
  await post("/ack", { id: command.id, ok: true });
123
- } catch (error) { if (pendingPromptId === command.id) pendingPromptId = undefined; await post("/ack", { id: command.id, ok: false, error: (error as Error).message }); }
150
+ } catch (error) {
151
+ if (command.type === "set_session_title") {
152
+ await post("/ack", { id: command.id, ok: true, result: { ok: false, reason: "invalid", cause: "native_failed", mayHaveApplied: true, error: "Pi native title API failed without confirming the write; the title may have been applied; check ahub status before retrying.", sessionId, titleEpoch, titleRevision } });
153
+ continue;
154
+ }
155
+ if (pendingPromptId === command.id) pendingPromptId = undefined;
156
+ await post("/ack", { id: command.id, ok: false, error: (error as Error).message });
157
+ }
124
158
  } catch { await new Promise((resolve) => setTimeout(resolve, 250)); }
125
159
  }
126
160
  }
@@ -142,8 +176,9 @@ export default function(pi: ExtensionAPI): void {
142
176
  shutdown = ctx.shutdown;
143
177
  const state = ctx.sessionManager.getHeader();
144
178
  sessionId = String(state?.id ?? "");
179
+ titleRevision = 0; titleEpoch = crypto.randomUUID();
145
180
  try {
146
- const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile(), approvalTurnAbort: typeof ctx.abort === "function", fencedPrompt: true });
181
+ const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), ...(typeof pi.getSessionName === "function" ? { sessionName: pi.getSessionName() ?? "", titleEpoch, titleRevision } : {}), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile(), approvalTurnAbort: typeof ctx.abort === "function", fencedPrompt: true });
147
182
  if (claimed?.ok === false) { ctx.shutdown?.(); return; }
148
183
  } catch (error) { ctx.shutdown?.(); throw error; }
149
184
  if (!pollStarted) { pollStarted = true; void poll(pi); }
@@ -168,8 +203,13 @@ export default function(pi: ExtensionAPI): void {
168
203
  lastActivity = now;
169
204
  try { await post("/event", { type: "activity" }); } catch { /* shutdown owns bridge cleanup */ }
170
205
  });
206
+ pi.on("session_info_changed", async () => {
207
+ if (runtimeCtx?.sessionManager?.getHeader?.()?.id !== sessionId || typeof pi.getSessionName !== "function") return;
208
+ try { await post("/event", { type: "session_title", sessionId, titleEpoch, titleRevision: ++titleRevision, sessionName: pi.getSessionName() ?? "" }); }
209
+ catch { /* title observation must not disrupt the native session */ }
210
+ });
171
211
  // Native retries/continuations emit more starts before the one logical agent_settled.
172
- pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; turnGeneration++; activePromptId = pendingPromptId ?? activePromptId; pendingPromptId = undefined; await post("/event", { type: "agent_start", generation: turnGeneration, ...(activePromptId ? { promptId: activePromptId } : {}) }); });
212
+ pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; turnGeneration++; activePromptId = pendingPromptId ?? activePromptId; pendingPromptId = undefined; await post("/event", { type: "agent_start", sessionId, ...(typeof pi.getSessionName === "function" ? { sessionName: pi.getSessionName() ?? "", titleEpoch, titleRevision } : {}), generation: turnGeneration, ...(activePromptId ? { promptId: activePromptId } : {}) }); });
173
213
  pi.on("model_select", async (_event: any, ctx: any) => { if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") ctx.shutdown?.(); });
174
214
  pi.on("before_agent_start", async (event: any, ctx: any) => {
175
215
  if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return { systemPrompt: event.systemPrompt }; }