@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1444 -2
  3. package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
  4. package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
  5. package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
  6. package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
  7. package/dist/index.ts +9874 -0
  8. package/dist/index.ts.map +7 -0
  9. package/package.json +82 -4
  10. package/src/abortable.ts +43 -0
  11. package/src/agent-color.ts +161 -0
  12. package/src/agent-file-toggle.ts +298 -0
  13. package/src/agent-manager.ts +2431 -0
  14. package/src/agent-runner.ts +1703 -0
  15. package/src/agent-source-badge.ts +75 -0
  16. package/src/agent-types.ts +346 -0
  17. package/src/child-context.ts +33 -0
  18. package/src/clock.ts +25 -0
  19. package/src/completion-outbox.ts +144 -0
  20. package/src/context.ts +58 -0
  21. package/src/cross-extension-rpc.ts +200 -0
  22. package/src/custom-agents.ts +441 -0
  23. package/src/default-agents.ts +126 -0
  24. package/src/detached-results.ts +150 -0
  25. package/src/enabled-models.ts +181 -0
  26. package/src/enterprise-local.ts +67 -0
  27. package/src/env.ts +33 -0
  28. package/src/error-message.ts +11 -0
  29. package/src/git-path.ts +66 -0
  30. package/src/group-join.ts +142 -0
  31. package/src/home-paths.ts +27 -0
  32. package/src/index.ts +5047 -0
  33. package/src/invocation-config.ts +158 -0
  34. package/src/memory.ts +179 -0
  35. package/src/mention-clone.ts +200 -0
  36. package/src/mention.ts +141 -0
  37. package/src/model-call.ts +83 -0
  38. package/src/model-resolver.ts +333 -0
  39. package/src/model-scope.ts +71 -0
  40. package/src/model-selection.ts +170 -0
  41. package/src/model-tiers.ts +176 -0
  42. package/src/nested-tools.ts +512 -0
  43. package/src/notification-result.ts +118 -0
  44. package/src/output-file.ts +186 -0
  45. package/src/package-resources.ts +639 -0
  46. package/src/plan-mode-children.ts +176 -0
  47. package/src/process-budget.ts +346 -0
  48. package/src/prompts.ts +161 -0
  49. package/src/quota-admission.ts +121 -0
  50. package/src/resource-loader.ts +65 -0
  51. package/src/schedule-store.ts +153 -0
  52. package/src/schedule.ts +514 -0
  53. package/src/settings.ts +851 -0
  54. package/src/skill-loader.ts +288 -0
  55. package/src/stale-ctx.ts +16 -0
  56. package/src/startup-error.ts +29 -0
  57. package/src/status-note.ts +113 -0
  58. package/src/structured-output.ts +130 -0
  59. package/src/types.ts +487 -0
  60. package/src/ui/agent-mention.ts +219 -0
  61. package/src/ui/agent-row.ts +245 -0
  62. package/src/ui/agent-widget.ts +722 -0
  63. package/src/ui/conversation-viewer.ts +837 -0
  64. package/src/ui/fallback-flag.ts +114 -0
  65. package/src/ui/fleet-list.ts +1020 -0
  66. package/src/ui/fleet-state.ts +154 -0
  67. package/src/ui/notification-view.ts +139 -0
  68. package/src/ui/schedule-menu.ts +105 -0
  69. package/src/ui/select-item.ts +45 -0
  70. package/src/ui/viewer-keys.ts +39 -0
  71. package/src/ui/workflow-card.ts +459 -0
  72. package/src/ui/workflow-dialog.ts +1138 -0
  73. package/src/ui/workflow-menu.ts +193 -0
  74. package/src/usage.ts +167 -0
  75. package/src/workflow/collisions.ts +123 -0
  76. package/src/workflow/entry.ts +47 -0
  77. package/src/workflow/host.ts +398 -0
  78. package/src/workflow/journal.ts +176 -0
  79. package/src/workflow/json-schema.ts +129 -0
  80. package/src/workflow/meta.ts +329 -0
  81. package/src/workflow/progress.ts +554 -0
  82. package/src/workflow/runtime.ts +1249 -0
  83. package/src/workflow/saved.ts +259 -0
  84. package/src/workflow/task.ts +317 -0
  85. package/src/workflow/tool-description.ts +200 -0
  86. package/src/workflow/worker-source.ts +903 -0
  87. package/src/worktree.ts +520 -0
  88. package/src/xml.ts +13 -0
@@ -0,0 +1,520 @@
1
+ /**
2
+ * worktree.ts — Git worktree isolation for agents.
3
+ *
4
+ * Creates a temporary git worktree so the agent works on an isolated copy of the repo.
5
+ * On completion, if no changes were made and nothing in it would be lost, the worktree is cleaned up.
6
+ * If changes exist, a branch is created and returned in the result.
7
+ * If git cannot inspect, commit or remove, the worktree is kept and the result carries `error` and `path`.
8
+ *
9
+ * Every git call goes through `pi.exec` (async) rather than `execFileSync`: a
10
+ * worktree copy can take seconds, and a session that spawns several isolated
11
+ * agents at once would otherwise serialize them all on the TUI's event loop.
12
+ */
13
+
14
+ import { randomUUID } from "node:crypto";
15
+ import { type BigIntStats, existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join, relative, resolve } from "node:path";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { errorMessage } from "./error-message.js";
20
+ import { QUOTE_PATH_ARGS, unquoteGitPath } from "./git-path.js";
21
+
22
+ export interface WorktreeInfo {
23
+ /** Absolute path to the worktree directory (the copied repo's root). */
24
+ path: string;
25
+ /** Branch name created for this worktree (if changes exist). */
26
+ branch: string;
27
+ /** Commit SHA that the worktree was created from. */
28
+ baseSha: string;
29
+ /** Resolved toplevel of the repo the worktree was created from; the result branch lands here. */
30
+ repoRoot: string;
31
+ /**
32
+ * Where the agent should work inside the worktree: the equivalent of the
33
+ * cwd the worktree was created from. Equals `path` when that cwd was the
34
+ * repo root; points at the copied subdirectory when it was deeper (e.g. a
35
+ * monorepo package), so the requested scoping survives isolation.
36
+ */
37
+ workPath: string;
38
+ }
39
+
40
+ /**
41
+ * Project-wide switch for worktree isolation (`worktreeIsolation` in
42
+ * subagents.json). Default `true` — unchanged behaviour.
43
+ *
44
+ * The `"off"` isolation value gives a model a legal way to decline a worktree,
45
+ * but it still depends on the model choosing it. This is the deterministic half
46
+ * of the same fix: on a large repo where every worktree costs real time and
47
+ * disk (#184), turning it off means no caller can create one, whatever it
48
+ * passes.
49
+ */
50
+ let worktreeIsolationEnabled = true;
51
+
52
+ export function setWorktreeIsolationEnabled(enabled: boolean): void {
53
+ worktreeIsolationEnabled = enabled;
54
+ }
55
+
56
+ export function isWorktreeIsolationEnabled(): boolean {
57
+ return worktreeIsolationEnabled;
58
+ }
59
+
60
+ export interface WorktreeCleanupResult {
61
+ /** Whether changes were found in the worktree. */
62
+ hasChanges: boolean;
63
+ /** Branch name if changes were committed. */
64
+ branch?: string;
65
+ /** Worktree path if it was kept. */
66
+ path?: string;
67
+ /** Cleanup failure; the worktree may contain uncommitted or unreachable work. */
68
+ error?: string;
69
+ }
70
+
71
+ /**
72
+ * Run git and return its trimmed stdout, throwing on failure so callers keep
73
+ * the try/catch control flow `execFileSync` gave them.
74
+ *
75
+ * `pi.exec` never rejects — it reports failure in the result — and a command
76
+ * killed by its timeout comes back as `killed` with an exit code of 0, so both
77
+ * have to be checked to reproduce `execFileSync`'s "throws on anything but a
78
+ * clean exit".
79
+ */
80
+ async function git(pi: ExtensionAPI, cwd: string, args: string[], timeout: number): Promise<string> {
81
+ return (await gitRaw(pi, cwd, args, timeout)).trim();
82
+ }
83
+
84
+ /** `git`, with stdout untrimmed: a path may start or end with whitespace. */
85
+ async function gitRaw(pi: ExtensionAPI, cwd: string, args: string[], timeout: number): Promise<string> {
86
+ const result = await pi.exec("git", args, { cwd, timeout });
87
+ if (result.killed) throw new GitTimeoutError(`\`git ${args.join(" ")}\` timed out after ${timeout}ms in ${cwd}`);
88
+ if (result.code !== 0) {
89
+ throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed (exit ${result.code})`);
90
+ }
91
+ return result.stdout;
92
+ }
93
+
94
+ /** A git call killed by its timeout — reported as-is, never as a repo-state cause. */
95
+ class GitTimeoutError extends Error {}
96
+
97
+ /**
98
+ * Why `createWorktree` could not isolate an agent. The message names the
99
+ * directory it tried and the specific cause, so a caller can tell "wrong
100
+ * directory" from "repo with no commits" from a git failure.
101
+ */
102
+ export class WorktreeError extends Error {}
103
+
104
+ /** Run one git step, rethrowing a failure as a `WorktreeError` whose message is `cause(detail)`. */
105
+ async function step(run: Promise<string>, cause: (detail: string) => string): Promise<string> {
106
+ try {
107
+ return await run;
108
+ } catch (err) {
109
+ const detail = errorMessage(err);
110
+ throw new WorktreeError(err instanceof GitTimeoutError ? detail : cause(detail));
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Create a temporary git worktree for an agent, from the repo containing `cwd`
116
+ * (which may be any directory inside it, not just the toplevel).
117
+ *
118
+ * @throws WorktreeError — `cwd` is not inside a git work tree, the repo has no
119
+ * commits yet, `git worktree add` failed, or a git call timed out.
120
+ */
121
+ export async function createWorktree(pi: ExtensionAPI, cwd: string, agentId: string): Promise<WorktreeInfo> {
122
+ const inside = await step(
123
+ git(pi, cwd, ["rev-parse", "--is-inside-work-tree"], 5000),
124
+ detail => `${cwd} is not inside a git repository (${detail})`,
125
+ );
126
+ // "false" with a clean exit: inside a `.git` dir or a bare repo.
127
+ if (inside !== "true") throw new WorktreeError(`${cwd} is inside a git directory but not a work tree`);
128
+ // realpath both sides — git emits resolved paths while cwd may arrive
129
+ // through a symlink (macOS /tmp).
130
+ const repoRoot = realpathSync(await step(
131
+ git(pi, cwd, ["rev-parse", "--show-toplevel"], 5000),
132
+ detail => `${cwd} has no git work tree toplevel (${detail})`,
133
+ ));
134
+ const baseSha = await step(
135
+ git(pi, cwd, ["rev-parse", "--verify", "HEAD"], 5000),
136
+ () => `${repoRoot} has no commits yet; commit at least once`,
137
+ );
138
+ // Where cwd sits inside the repo ("" at the root): the agent must work at
139
+ // the same subdirectory inside the copy, or a monorepo-package cwd would
140
+ // silently widen to the whole repo.
141
+ const subdir = relative(repoRoot, realpathSync(cwd));
142
+
143
+ const branch = `pi-agent-${agentId}`;
144
+ const suffix = randomUUID().slice(0, 8);
145
+ const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
146
+
147
+ // Create detached worktree at HEAD
148
+ await step(
149
+ git(pi, cwd, ["worktree", "add", "--detach", worktreePath, "HEAD"], 30000),
150
+ detail => `\`git worktree add\` failed in ${repoRoot}: ${detail}`,
151
+ );
152
+ return { path: worktreePath, branch, baseSha, repoRoot, workPath: subdir ? join(worktreePath, subdir) : worktreePath };
153
+ }
154
+
155
+ /**
156
+ * Clean up a worktree after agent completion.
157
+ * - If no changes: remove worktree entirely.
158
+ * - If changes exist: create a branch, commit changes, return branch info.
159
+ */
160
+ export async function cleanupWorktree(
161
+ pi: ExtensionAPI,
162
+ cwd: string,
163
+ worktree: WorktreeInfo,
164
+ agentDescription: string,
165
+ ): Promise<WorktreeCleanupResult> {
166
+ if (!existsSync(worktree.path)) {
167
+ return { hasChanges: false };
168
+ }
169
+
170
+ let branch: string | undefined;
171
+ try {
172
+ // Submodule state lives in the worktree's private git dir and is deleted with it.
173
+ await assertSubmodulesDisposable(pi, worktree.path, worktree.baseSha);
174
+ // Check for uncommitted changes in the worktree
175
+ const status = await dirtyState(pi, worktree.path);
176
+
177
+ if (status) {
178
+ // Changes exist — stage, commit, and create a branch
179
+ const modes = await indexOnlyModes(pi, worktree.path);
180
+ if (modes.length > 0) {
181
+ throw new Error(`mode staged only in the index; worktree kept: ${modes.join(", ")}`);
182
+ }
183
+ await git(pi, worktree.path, ["add", "-A"], 10000);
184
+ // `add -A` skips a moved gitlink hidden by `submodule.<name>.ignore = all`; an empty
185
+ // commit would fail opaquely, so say why and keep the worktree.
186
+ if (!(await git(pi, worktree.path, ["diff", "--cached", "--name-only"], 10000))) {
187
+ throw new Error(`uncommitted changes could not be staged (a submodule \`ignore\` setting can hide a moved gitlink); worktree kept:\n${status}`);
188
+ }
189
+ // Truncate description for commit message (no shell sanitization needed — pi.exec uses argv)
190
+ const safeDesc = agentDescription.slice(0, 200);
191
+ const commitMsg = `pi-agent: ${safeDesc}`;
192
+ await git(pi, worktree.path, ["commit", "--no-verify", "-m", commitMsg], 10000);
193
+ } else {
194
+ const currentSha = await git(pi, worktree.path, ["rev-parse", "HEAD"], 5000);
195
+
196
+ if (currentSha === worktree.baseSha) {
197
+ // No changes — remove worktree
198
+ await removeWorktree(pi, cwd, worktree.path, worktree.baseSha);
199
+ return { hasChanges: false };
200
+ }
201
+ }
202
+
203
+ // Create a branch pointing to the worktree's HEAD.
204
+ // If the branch already exists, append a suffix to avoid overwriting previous work.
205
+ let branchName = worktree.branch;
206
+ try {
207
+ await git(pi, worktree.path, ["branch", branchName], 5000);
208
+ } catch {
209
+ // Branch already exists — use a unique suffix
210
+ branchName = `${worktree.branch}-${Date.now()}`;
211
+ await git(pi, worktree.path, ["branch", branchName], 5000);
212
+ }
213
+ // Update branch name in worktree info for the caller
214
+ worktree.branch = branchName;
215
+ branch = branchName;
216
+
217
+ // Remove the worktree (branch persists in main repo)
218
+ await removeWorktree(pi, cwd, worktree.path, worktree.baseSha);
219
+
220
+ return {
221
+ hasChanges: true,
222
+ branch: worktree.branch,
223
+ };
224
+ } catch (error) {
225
+ // A failed status means the contents are unknown, not clean. Never remove
226
+ // the tree after a failed preservation step: even committed HEAD may have
227
+ // no reachable branch yet.
228
+ return {
229
+ hasChanges: true,
230
+ ...(branch ? { branch } : {}),
231
+ path: worktree.path,
232
+ error: (error instanceof Error ? error.message : String(error)) || "Unknown git failure",
233
+ };
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Lines of a path listing, read without `-z` and with `core.quotePath=true`: every
239
+ * non-ASCII path byte is escaped, so `pi.exec` decoding stdout chunk by chunk cannot
240
+ * corrupt a path (see `unquoteGitPath`). Each line's paths are decoded by the caller.
241
+ */
242
+ async function gitLines(pi: ExtensionAPI, repo: string, args: string[], timeout: number): Promise<string[]> {
243
+ return (await gitRaw(pi, repo, [...QUOTE_PATH_ARGS, ...args], timeout)).split("\n").filter(Boolean);
244
+ }
245
+
246
+ const INDEX_BATCH = 200;
247
+
248
+ /** `git update-index <flag> -- <paths>` in argv-sized batches. */
249
+ async function updateIndex(pi: ExtensionAPI, repo: string, flag: string, paths: string[]): Promise<void> {
250
+ for (let i = 0; i < paths.length; i += INDEX_BATCH) {
251
+ await git(pi, repo, ["update-index", flag, "--", ...paths.slice(i, i + INDEX_BATCH)], 30000);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Clear the assume-unchanged and skip-worktree index bits of `repo`: with either
257
+ * set, `status` and git's own removal check ignore edits to the file, so an edited
258
+ * file would be deleted with the worktree. A skip-worktree file absent from disk
259
+ * (sparse checkout) is left alone: clearing it would report a deletion nobody made.
260
+ * The tree is disposable, so mutating its index is fine.
261
+ */
262
+ async function clearHiddenIndexBits(pi: ExtensionAPI, repo: string): Promise<void> {
263
+ const assumed: string[] = [];
264
+ const skipped: string[] = [];
265
+ // `-v` tags: lowercase = assume-unchanged, `S` (or `s` with both) = skip-worktree.
266
+ for (const entry of await gitLines(pi, repo, ["ls-files", "-v"], 30000)) {
267
+ const tag = entry.charAt(0);
268
+ const hidden = tag !== tag.toUpperCase() || tag === "S";
269
+ if (!hidden) continue;
270
+ const path = unquoteGitPath(entry.slice(2));
271
+ if (tag !== tag.toUpperCase()) assumed.push(path);
272
+ if (tag.toLowerCase() === "s" && lstatOrUndefined(join(repo, path))) skipped.push(path);
273
+ }
274
+ await updateIndex(pi, repo, "--no-assume-unchanged", assumed);
275
+ await updateIndex(pi, repo, "--no-skip-worktree", skipped);
276
+ }
277
+
278
+ /** `lstat` of `path`, or undefined when it is missing or unreachable (ENOENT, ENOTDIR). */
279
+ function lstatOrUndefined(path: string): BigIntStats | undefined {
280
+ try {
281
+ return lstatSync(path, { bigint: true });
282
+ } catch (error) {
283
+ // Anything else (EACCES, ...) is unknown, not absent: let cleanup fail and keep the tree.
284
+ const code = (error as NodeJS.ErrnoException).code;
285
+ if (code === "ENOENT" || code === "ENOTDIR") return undefined;
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Regular files whose staged executable bit (index vs `HEAD`, a new file counting as a change)
292
+ * differs from the file on disk, decoded. `add -A` restages from disk and would silently drop
293
+ * such a mode, and an index-only mode cannot be told from a later on-disk change, so cleanup
294
+ * keeps the worktree instead of guessing. A plain unstaged `chmod` is not listed: the index
295
+ * still matches `HEAD`, so `add -A` commits the disk mode. Skipped when `core.fileMode=false`:
296
+ * git ignores the bit then, so a mismatch is not a change.
297
+ */
298
+ async function indexOnlyModes(pi: ExtensionAPI, repo: string): Promise<string[]> {
299
+ if ((await git(pi, repo, ["config", "--type=bool", "--default", "true", "core.fileMode"], 5000)) === "false") return [];
300
+ const paths: string[] = [];
301
+ // `:<old mode> <new mode> <old sha> <new sha> <status>` TAB `<path>`
302
+ for (const line of await gitLines(pi, repo, ["diff", "--cached", "--raw", "--no-renames"], 30000)) {
303
+ const tab = line.indexOf("\t");
304
+ const [oldMode, newMode] = line.slice(1, tab).split(" ");
305
+ if (oldMode === newMode || (newMode !== "100644" && newMode !== "100755")) continue;
306
+ const path = unquoteGitPath(line.slice(tab + 1));
307
+ const disk = lstatOrUndefined(join(repo, path));
308
+ if (disk?.isFile() && ((disk.mode & 0o100n) !== 0n) !== (newMode === "100755")) paths.push(path);
309
+ }
310
+ return paths;
311
+ }
312
+
313
+ /**
314
+ * Uncommitted state of `repo`, untracked files and submodule changes included.
315
+ * `status.showUntrackedFiles=no` in user or repo config would otherwise hide new
316
+ * files from `status`, and `submodule.<name>.ignore` / `diff.ignoreSubmodules`
317
+ * would hide a moved gitlink; a clean-looking tree gets removed with them.
318
+ * Index bits that hide edits are cleared first, see `clearHiddenIndexBits`.
319
+ */
320
+ async function dirtyState(pi: ExtensionAPI, repo: string): Promise<string> {
321
+ await clearHiddenIndexBits(pi, repo);
322
+ return await git(pi, repo, ["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"], 10000);
323
+ }
324
+
325
+ const GITLINK_MODE = "160000";
326
+
327
+ /**
328
+ * Gitlinks (mode 160000) of `repo` as path -> sha: the index when `rev` is
329
+ * omitted, else the tree of `rev`. Independent of `.gitmodules`: git refuses to
330
+ * remove a worktree on the gitlink, not on the config file.
331
+ */
332
+ async function gitlinks(pi: ExtensionAPI, repo: string, rev?: string): Promise<Map<string, string>> {
333
+ const links = new Map<string, string>();
334
+ for (const entry of await gitLines(pi, repo, rev ? ["ls-tree", "-r", rev] : ["ls-files", "-s"], 30000)) {
335
+ // `gitLines` drops blanks, so every entry is `<fields>` TAB `<path>`.
336
+ const tab = entry.indexOf("\t");
337
+ // ls-files: `<mode> <sha> <stage>`; ls-tree: `<mode> <type> <sha>`.
338
+ const [mode, second, third] = entry.slice(0, tab).split(" ");
339
+ if (mode === GITLINK_MODE) links.set(unquoteGitPath(entry.slice(tab + 1)), rev ? (third as string) : (second as string));
340
+ }
341
+ return links;
342
+ }
343
+
344
+ /** What removing a repository would destroy: stashes and commits (branch, tag or HEAD) no remote-tracking ref (or `published`) contains. */
345
+ async function unpublished(pi: ExtensionAPI, gitArgs: string[], cwd: string, published: Iterable<string>): Promise<string> {
346
+ const stash = await git(pi, cwd, [...gitArgs, "stash", "list"], 10000);
347
+ // `--not` with an object this repository lacks would fail the whole listing.
348
+ const known: string[] = [];
349
+ for (const sha of published) {
350
+ const probe = await pi.exec("git", [...gitArgs, "cat-file", "-e", `${sha}^{commit}`], { cwd, timeout: 5000 });
351
+ if (!probe.killed && probe.code === 0) known.push(sha);
352
+ }
353
+ // Tags count: a local tag can be the only ref holding a commit. Offline there is
354
+ // no telling a fetched tag from a local one, so an unpublished tagged commit fails safe.
355
+ const commits = await git(pi, cwd, [...gitArgs, "rev-list", "--branches", "--tags", "HEAD", "--not", "--remotes", ...known], 30000);
356
+ return [stash, commits].filter(Boolean).join("\n");
357
+ }
358
+
359
+ /**
360
+ * The git dir of the repository whose work tree is `dir`, read from `.git` (a directory, or
361
+ * a `gitdir: <path>` file for a submodule) rather than from `git rev-parse` output, which
362
+ * `pi.exec` can mis-decode when the path is non-ASCII.
363
+ */
364
+ function gitDirOf(dir: string): string {
365
+ const dotGit = join(dir, ".git");
366
+ if (statSync(dotGit).isDirectory()) return dotGit;
367
+ const target = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1];
368
+ if (!target) throw new Error(`${dotGit} is neither a directory nor a gitdir file`);
369
+ return resolve(dir, target.trim());
370
+ }
371
+
372
+ /**
373
+ * Inspect the nested repository at `dir`, then the ones behind its own gitlinks.
374
+ * `recorded` is the sha a trusted baseline pins it at (when any).
375
+ */
376
+ async function inspectRepo(
377
+ pi: ExtensionAPI,
378
+ dir: string,
379
+ label: string,
380
+ recorded: string | undefined,
381
+ trusted: Set<string>,
382
+ seen: Set<string>,
383
+ problems: string[],
384
+ ): Promise<void> {
385
+ seen.add(realpathSync(gitDirOf(dir)));
386
+ const dirty = await dirtyState(pi, dir);
387
+ const lost = await unpublished(pi, [], dir, recorded ? [recorded] : []);
388
+ const detail = [dirty, lost].filter(Boolean).join("\n");
389
+ if (detail) problems.push(`${label}:\n${detail}`);
390
+ const head = await git(pi, dir, ["rev-parse", "HEAD"], 5000);
391
+ await inspectGitlinks(pi, dir, head === recorded ? "HEAD" : undefined, trusted, seen, problems);
392
+ await inspectIgnoredRepos(pi, dir, dir, trusted, seen, problems);
393
+ }
394
+
395
+ /**
396
+ * Inspect nested repositories under gitignored paths of `repo` not already
397
+ * `seen`. Labels are relative to `root`, the worktree.
398
+ */
399
+ async function inspectIgnoredRepos(
400
+ pi: ExtensionAPI,
401
+ repo: string,
402
+ root: string,
403
+ trusted: Set<string>,
404
+ seen: Set<string>,
405
+ problems: string[],
406
+ ): Promise<void> {
407
+ for (const dir of await ignoredRepoDirs(pi, repo)) {
408
+ if (!seen.has(realpathSync(gitDirOf(dir)))) {
409
+ await inspectRepo(pi, dir, relative(root, dir), undefined, trusted, seen, problems);
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Inspect the nested repository behind every gitlink of `repo`, recursively.
416
+ * `baseRev` is a revision whose gitlinks are already trusted as published (the
417
+ * commit the worktree was created from): a nested HEAD at exactly that recorded
418
+ * sha, e.g. fetched by sha during `submodule update`, is not at risk. A gitlink
419
+ * the agent introduced has no baseline, so only remote-tracking refs vouch for it.
420
+ * Every baseline sha seen is added to `trusted`, for repositories no gitlink reaches.
421
+ */
422
+ async function inspectGitlinks(
423
+ pi: ExtensionAPI,
424
+ repo: string,
425
+ baseRev: string | undefined,
426
+ trusted: Set<string>,
427
+ seen: Set<string>,
428
+ problems: string[],
429
+ ): Promise<void> {
430
+ const base = baseRev ? await gitlinks(pi, repo, baseRev) : new Map<string, string>();
431
+ for (const sha of base.values()) trusted.add(sha);
432
+ for (const path of (await gitlinks(pi, repo)).keys()) {
433
+ const dir = join(repo, path);
434
+ if (existsSync(join(dir, ".git"))) await inspectRepo(pi, dir, path, base.get(path), trusted, seen, problems);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Nested repositories under gitignored paths: `add -A` skips them, so no gitlink
440
+ * records them, yet plain removal deletes their ignored content.
441
+ */
442
+ async function ignoredRepoDirs(pi: ExtensionAPI, worktreePath: string): Promise<string[]> {
443
+ const entries = await gitLines(pi, worktreePath, ["status", "--porcelain", "--ignored", "--untracked-files=all", "--ignore-submodules=none"], 30000);
444
+ // With `--untracked-files=all` git lists an ignored nested repository as its own `!! dir/` entry.
445
+ // Only directory entries (`dir/`, or `"dir/"` when quoted) can be a clone: decode just those.
446
+ const dirs = entries.filter(entry => entry.startsWith("!! ") && /\/"?$/.test(entry)).map(entry => unquoteGitPath(entry.slice(3)));
447
+ return dirs.map(dir => join(worktreePath, dir)).filter(dir => existsSync(join(dir, ".git")));
448
+ }
449
+
450
+ /** Git dirs under a `modules` directory, including those nested in other submodule git dirs. */
451
+ function moduleGitDirs(dir: string): string[] {
452
+ if (existsSync(join(dir, "HEAD")) && existsSync(join(dir, "objects"))) {
453
+ const nested = join(dir, "modules");
454
+ return [dir, ...(existsSync(nested) ? moduleGitDirs(nested) : [])];
455
+ }
456
+ return readdirSync(dir, { withFileTypes: true }).flatMap(entry => (entry.isDirectory() ? moduleGitDirs(join(dir, entry.name)) : []));
457
+ }
458
+
459
+ /** Absolute `modules` directory of the worktree's private git dir, when present. */
460
+ function modulesDir(worktreePath: string): string | undefined {
461
+ const dir = join(gitDirOf(worktreePath), "modules");
462
+ return existsSync(dir) ? dir : undefined;
463
+ }
464
+
465
+ /**
466
+ * Throw unless every nested repository the removal would delete holds nothing
467
+ * but already-published state. Git deletes submodule git dirs with the worktree,
468
+ * so unpushed commits or uncommitted files there would be lost even after the
469
+ * outer repo is branched. Covers every gitlink in the index, registered in
470
+ * `.gitmodules` or not, plus any leftover repository in the worktree's `modules`
471
+ * dir (e.g. after the agent deleted `.gitmodules`).
472
+ */
473
+ async function assertSubmodulesDisposable(pi: ExtensionAPI, worktreePath: string, baseSha: string): Promise<void> {
474
+ const seen = new Set<string>();
475
+ const trusted = new Set<string>();
476
+ const problems: string[] = [];
477
+ await inspectGitlinks(pi, worktreePath, baseSha, trusted, seen, problems);
478
+ await inspectIgnoredRepos(pi, worktreePath, worktreePath, trusted, seen, problems);
479
+ const modules = modulesDir(worktreePath);
480
+ for (const gitDir of modules ? moduleGitDirs(modules) : []) {
481
+ if (seen.has(realpathSync(gitDir))) continue;
482
+ const lost = await unpublished(pi, ["--git-dir", gitDir], worktreePath, trusted);
483
+ if (lost) problems.push(`${gitDir}:\n${lost}`);
484
+ }
485
+ if (problems.length) throw new Error(`submodule work would be lost on removal; worktree kept:\n${problems.join("\n")}`);
486
+ }
487
+
488
+ /** Whether git would refuse plain removal for submodule state: any gitlink in the index or a `modules` dir. */
489
+ async function hasSubmoduleState(pi: ExtensionAPI, worktreePath: string): Promise<boolean> {
490
+ return (await gitlinks(pi, worktreePath)).size > 0 || modulesDir(worktreePath) !== undefined;
491
+ }
492
+
493
+ /**
494
+ * Remove only a clean worktree; git refuses newly appeared uncommitted work.
495
+ *
496
+ * Git also refuses any worktree with submodule state, clean or not. Whether that
497
+ * is why removal failed is decided structurally (gitlinks, `modules` dir), never
498
+ * from git's locale-dependent message. Only then is `--force` used, and only after
499
+ * the outer tree and every nested repository re-verify as holding nothing unpublished.
500
+ */
501
+ async function removeWorktree(pi: ExtensionAPI, cwd: string, worktreePath: string, baseSha: string): Promise<void> {
502
+ try {
503
+ // Git's own clean check honors `status.showUntrackedFiles`; pin it so config cannot hide new files.
504
+ await git(pi, cwd, ["-c", "status.showUntrackedFiles=all", "worktree", "remove", worktreePath], 10000);
505
+ } catch (error) {
506
+ if (!(await hasSubmoduleState(pi, worktreePath))) throw error;
507
+ if (await dirtyState(pi, worktreePath)) throw error;
508
+ await assertSubmodulesDisposable(pi, worktreePath, baseSha);
509
+ await git(pi, cwd, ["worktree", "remove", "--force", worktreePath], 10000);
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Prune any orphaned worktrees (crash recovery).
515
+ */
516
+ export async function pruneWorktrees(pi: ExtensionAPI, cwd: string): Promise<void> {
517
+ try {
518
+ await git(pi, cwd, ["worktree", "prune"], 5000);
519
+ } catch { /* ignore */ }
520
+ }
package/src/xml.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * xml.ts — escaping for the `<task-notification>` payloads.
3
+ *
4
+ * A module of its own because both notification builders need it and they sit
5
+ * on opposite sides of a dependency edge: the agent one lives in `index.ts`,
6
+ * which imports `workflow/task.ts`, so the workflow one cannot reach back for
7
+ * it without a cycle.
8
+ */
9
+
10
+ /** Escape XML special characters to prevent injection in structured notifications. */
11
+ export function escapeXml(s: string): string {
12
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
+ }