@ikarmic/forge-core 5.3.0 → 5.4.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 (113) hide show
  1. package/dist/agent/agent.js +132 -70
  2. package/dist/agent/cache-hints.d.ts +2 -0
  3. package/dist/agent/cache-hints.js +18 -3
  4. package/dist/agent/history-adapt.d.ts +23 -0
  5. package/dist/agent/history-adapt.js +114 -0
  6. package/dist/agent/prompts.d.ts +4 -2
  7. package/dist/agent/prompts.js +10 -2
  8. package/dist/agent/tool-repair.d.ts +8 -0
  9. package/dist/agent/tool-repair.js +16 -0
  10. package/dist/catalog/models-dev.d.ts +10 -0
  11. package/dist/catalog/models-dev.js +12 -0
  12. package/dist/checkpoints/store.js +3 -1
  13. package/dist/context/compaction.d.ts +21 -6
  14. package/dist/context/compaction.js +86 -26
  15. package/dist/context/mask.d.ts +9 -9
  16. package/dist/context/mask.js +30 -1
  17. package/dist/format/auto-format.d.ts +22 -0
  18. package/dist/format/auto-format.js +39 -0
  19. package/dist/github/github-tools.d.ts +1 -0
  20. package/dist/github/github-tools.js +9 -9
  21. package/dist/github/pr.d.ts +34 -0
  22. package/dist/github/pr.js +70 -0
  23. package/dist/human-text.d.ts +6 -0
  24. package/dist/human-text.js +37 -0
  25. package/dist/index.d.ts +13 -8
  26. package/dist/index.js +10 -6
  27. package/dist/journey/answers-block.d.ts +2 -4
  28. package/dist/lsp/diagnostics.d.ts +4 -0
  29. package/dist/lsp/diagnostics.js +8 -2
  30. package/dist/lsp/index.d.ts +1 -1
  31. package/dist/lsp/index.js +1 -1
  32. package/dist/mcp/index.d.ts +1 -1
  33. package/dist/mcp/index.js +1 -1
  34. package/dist/mcp/json-schema-to-zod.js +2 -4
  35. package/dist/mcp/mcp-client.d.ts +14 -0
  36. package/dist/mcp/mcp-client.js +102 -18
  37. package/dist/orchestrator/specialists-registry.js +14 -1
  38. package/dist/orchestrator/specialists.js +3 -3
  39. package/dist/semantic/embedder.d.ts +11 -0
  40. package/dist/semantic/embedder.js +16 -0
  41. package/dist/semantic/semantic-index.d.ts +39 -1
  42. package/dist/semantic/semantic-index.js +43 -13
  43. package/dist/semantic/vector-store.d.ts +2 -0
  44. package/dist/semantic/vector-store.js +13 -1
  45. package/dist/sessions/index.d.ts +2 -2
  46. package/dist/sessions/index.js +1 -1
  47. package/dist/sessions/search.js +2 -2
  48. package/dist/sessions/session-export.d.ts +1 -1
  49. package/dist/sessions/session-export.js +41 -33
  50. package/dist/sessions/session-store.d.ts +20 -1
  51. package/dist/sessions/session-store.js +7 -0
  52. package/dist/sessions/watch.js +4 -4
  53. package/dist/skills/activator.d.ts +6 -0
  54. package/dist/skills/activator.js +17 -0
  55. package/dist/skills/skill-tool.d.ts +12 -0
  56. package/dist/skills/skill-tool.js +28 -0
  57. package/dist/tools/background-process.d.ts +63 -0
  58. package/dist/tools/background-process.js +150 -0
  59. package/dist/tools/edit-file.js +2 -27
  60. package/dist/tools/edit-match.d.ts +16 -0
  61. package/dist/tools/edit-match.js +115 -0
  62. package/dist/tools/fetch-url.d.ts +15 -1
  63. package/dist/tools/fetch-url.js +7 -47
  64. package/dist/tools/get-diagnostics.d.ts +14 -0
  65. package/dist/tools/get-diagnostics.js +44 -0
  66. package/dist/tools/glob-filter.d.ts +7 -0
  67. package/dist/tools/glob-filter.js +12 -0
  68. package/dist/tools/html-to-markdown.d.ts +13 -0
  69. package/dist/tools/html-to-markdown.js +507 -0
  70. package/dist/tools/index.d.ts +5 -1
  71. package/dist/tools/index.js +11 -1
  72. package/dist/tools/list-files.d.ts +1 -0
  73. package/dist/tools/list-files.js +17 -5
  74. package/dist/tools/list-symbols.js +2 -2
  75. package/dist/tools/network-policy.d.ts +5 -0
  76. package/dist/tools/network-policy.js +77 -0
  77. package/dist/tools/office-docs.d.ts +6 -0
  78. package/dist/tools/office-docs.js +393 -0
  79. package/dist/tools/permission-rules.js +55 -5
  80. package/dist/tools/read-file.d.ts +2 -0
  81. package/dist/tools/read-file.js +47 -10
  82. package/dist/tools/read-image.js +2 -2
  83. package/dist/tools/read-only.d.ts +1 -1
  84. package/dist/tools/read-only.js +4 -0
  85. package/dist/tools/repo-clone.d.ts +40 -0
  86. package/dist/tools/repo-clone.js +218 -0
  87. package/dist/tools/result-cap.d.ts +8 -0
  88. package/dist/tools/result-cap.js +44 -0
  89. package/dist/tools/run-command.d.ts +18 -1
  90. package/dist/tools/run-command.js +41 -16
  91. package/dist/tools/search-files.js +5 -5
  92. package/dist/tools/search-worker.js +11 -2
  93. package/dist/tools/shared.d.ts +14 -1
  94. package/dist/tools/shared.js +76 -4
  95. package/dist/tools/shell.d.ts +27 -0
  96. package/dist/tools/shell.js +80 -0
  97. package/dist/tools/sub-agent.js +3 -1
  98. package/dist/tools/suggest-plan.d.ts +18 -0
  99. package/dist/tools/suggest-plan.js +28 -0
  100. package/dist/types.d.ts +74 -0
  101. package/dist/wakeup/wakeup-tools.d.ts +22 -0
  102. package/dist/wakeup/wakeup-tools.js +34 -0
  103. package/dist/wakeup/wakeup.d.ts +75 -0
  104. package/dist/wakeup/wakeup.js +151 -0
  105. package/dist/worktree/health.d.ts +51 -0
  106. package/dist/worktree/health.js +186 -0
  107. package/dist/worktree/index.d.ts +4 -0
  108. package/dist/worktree/index.js +2 -0
  109. package/dist/worktree/setup.d.ts +51 -0
  110. package/dist/worktree/setup.js +173 -0
  111. package/dist/worktree/worktree.d.ts +7 -0
  112. package/dist/worktree/worktree.js +9 -4
  113. package/package.json +1 -1
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Scheduled wakeups: the model asks to be woken later ("check the build in 10 minutes") and the chat delivers an
3
+ * automatic message when the time comes. The scheduler belongs to the chat, not to a run, and works only while the
4
+ * editor is open. Caps follow Kilo's wakeup/schema.ts; recurring (cron) wakeups are not offered.
5
+ */
6
+ /** A delay under this is raised to it; an absolute time is honoured as given. */
7
+ export const MIN_DELAY_MS = 10_000;
8
+ /** A wakeup never fires further out than seven days. */
9
+ export const MAX_HORIZON_MS = 7 * 24 * 60 * 60 * 1000;
10
+ /** One chat holds at most this many pending wakeups. */
11
+ export const MAX_PER_SESSION = 10;
12
+ /** How often a due wakeup is offered again while the chat is busy. */
13
+ const DEFAULT_RETRY_MS = 15_000;
14
+ // ISO-8601 date-time; without an offset the wall clock is read in the host's timezone.
15
+ const WHEN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?$/;
16
+ // `30`, `30s`, `5m`, `2h`, `1d`; a bare number is seconds.
17
+ const DELAY = /^\s*(\d+)\s*([smhd])?\s*$/i;
18
+ const UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
19
+ /** When a wakeup is due, with the clamp that applied (as a sentence for the model), or an error naming the problem. */
20
+ export function resolveWakeupTime(input, now = Date.now()) {
21
+ const delay = input.delay?.trim() || undefined;
22
+ const when = input.when?.trim() || undefined;
23
+ if ((delay !== undefined) === (when !== undefined))
24
+ throw new Error('Provide exactly one of delay or when.');
25
+ if (when !== undefined) {
26
+ const target = WHEN.test(when) ? Date.parse(when) : NaN;
27
+ if (!Number.isFinite(target))
28
+ throw new Error(`Invalid time: ${when}. Use an ISO date and time such as 2026-10-27T15:30:00.`);
29
+ if (target <= now)
30
+ throw new Error(`Wakeup time is not in the future: ${when}`);
31
+ if (target > now + MAX_HORIZON_MS)
32
+ return { dueAt: now + MAX_HORIZON_MS, notice: `Requested time "${when}" is beyond the 7-day limit and was pulled back to it.` };
33
+ return { dueAt: target };
34
+ }
35
+ const m = DELAY.exec(delay);
36
+ if (!m)
37
+ throw new Error(`Invalid delay: ${delay}. Use a number of seconds, or 30s, 5m, 2h, 1d.`);
38
+ const span = Number(m[1]) * UNIT_MS[(m[2]?.toLowerCase() ?? 's')];
39
+ if (span <= 0)
40
+ throw new Error(`Wakeup delay is not in the future: ${delay}`);
41
+ if (span < MIN_DELAY_MS)
42
+ return { dueAt: now + MIN_DELAY_MS, notice: `Requested delay "${delay}" is under the 10-second minimum and was raised to it.` };
43
+ if (span > MAX_HORIZON_MS)
44
+ return { dueAt: now + MAX_HORIZON_MS, notice: `Requested delay "${delay}" is beyond the 7-day limit and was pulled back to it.` };
45
+ return { dueAt: now + span };
46
+ }
47
+ export class WakeupScheduler {
48
+ options;
49
+ pending = new Map();
50
+ timers = new Map();
51
+ nextId = 1;
52
+ disposed = false;
53
+ retryMs;
54
+ constructor(options) {
55
+ this.options = options;
56
+ this.retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
57
+ }
58
+ list() {
59
+ return [...this.pending.values()].sort((a, b) => a.dueAt - b.dueAt);
60
+ }
61
+ async schedule(input) {
62
+ if (this.pending.size >= MAX_PER_SESSION)
63
+ throw new Error(`A chat can hold at most ${MAX_PER_SESSION} pending wakeups. Cancel one first.`);
64
+ const now = Date.now();
65
+ const { dueAt, notice } = resolveWakeupTime(input, now);
66
+ const wakeup = { id: `wk${this.nextId++}-${now.toString(36)}`, reason: input.reason, ...(input.prompt ? { prompt: input.prompt } : {}), dueAt, created: now };
67
+ this.pending.set(wakeup.id, wakeup);
68
+ this.arm(wakeup);
69
+ await this.save();
70
+ return notice ? { ...wakeup, notice } : wakeup;
71
+ }
72
+ async cancel(id) {
73
+ if (!this.pending.delete(id))
74
+ return false;
75
+ this.disarm(id);
76
+ await this.save();
77
+ return true;
78
+ }
79
+ async cancelAll() {
80
+ for (const id of [...this.pending.keys()])
81
+ this.disarm(id);
82
+ this.pending.clear();
83
+ await this.save();
84
+ }
85
+ /** Load what a previous window left; anything already due fires on the next tick. */
86
+ async restore() {
87
+ const saved = (await this.options.store?.load().catch(() => [])) ?? [];
88
+ for (const w of saved) {
89
+ if (this.pending.has(w.id))
90
+ continue;
91
+ this.pending.set(w.id, w);
92
+ this.arm(w);
93
+ }
94
+ }
95
+ /** Stop every timer without forgetting what is stored (the editor is closing, or the chat was deleted elsewhere). */
96
+ dispose() {
97
+ this.disposed = true;
98
+ for (const id of [...this.timers.keys()])
99
+ this.disarm(id);
100
+ }
101
+ arm(w, delayMs = Math.max(0, w.dueAt - Date.now())) {
102
+ this.disarm(w.id);
103
+ if (this.disposed)
104
+ return;
105
+ const timer = setTimeout(() => void this.fire(w), delayMs);
106
+ timer.unref?.();
107
+ this.timers.set(w.id, timer);
108
+ }
109
+ disarm(id) {
110
+ const t = this.timers.get(id);
111
+ if (t)
112
+ clearTimeout(t);
113
+ this.timers.delete(id);
114
+ }
115
+ async fire(w) {
116
+ this.timers.delete(w.id);
117
+ if (!this.pending.has(w.id))
118
+ return;
119
+ let delivered = false;
120
+ try {
121
+ delivered = await this.options.onDue(w);
122
+ }
123
+ catch {
124
+ delivered = false;
125
+ }
126
+ if (!this.pending.has(w.id))
127
+ return;
128
+ if (!delivered) {
129
+ this.arm(w, this.retryMs);
130
+ return;
131
+ }
132
+ this.pending.delete(w.id);
133
+ await this.save();
134
+ }
135
+ async save() {
136
+ await this.options.store?.save(this.list()).catch(() => undefined);
137
+ }
138
+ }
139
+ /**
140
+ * The automatic message a due wakeup becomes. It is a user-role message, so it says what it is and that the person's
141
+ * own words win: the first wording of the todo nudge ("carry on now") overrode "wait for my go-ahead" in live runs (RB-44).
142
+ */
143
+ export function wakeupMessage(w) {
144
+ const minutes = Math.max(1, Math.round((w.dueAt - w.created) / 60_000));
145
+ return [
146
+ `This is an automatic message, not a message from the user. Earlier in this chat you asked to be woken after about ${minutes} minute${minutes === 1 ? '' : 's'}.`,
147
+ `Reason: ${w.reason}`,
148
+ ...(w.prompt ? [`What you planned to do: ${w.prompt}`] : []),
149
+ 'Carry on with that now, unless the user has asked you to stop or wait since then. If they have, leave it and say nothing about this message.',
150
+ ].join('\n');
151
+ }
@@ -0,0 +1,51 @@
1
+ export interface ListedWorktree {
2
+ path: string;
3
+ head: string;
4
+ /** Short branch name, absent when detached. */
5
+ branch?: string;
6
+ detached: boolean;
7
+ locked: boolean;
8
+ lockReason?: string;
9
+ /** Git says its folder is gone and it can be pruned. */
10
+ prunable: boolean;
11
+ }
12
+ /** `git worktree list --porcelain` into entries. */
13
+ export declare function parseWorktreeList(text: string): ListedWorktree[];
14
+ export interface WorktreeInspection {
15
+ /** Worktrees git lists under `.forge/worktrees`. */
16
+ registered: ListedWorktree[];
17
+ /** Folders under `.forge/worktrees` that git does not list. */
18
+ strayFolders: string[];
19
+ /** Listed worktrees whose folder is gone. */
20
+ missing: ListedWorktree[];
21
+ }
22
+ export declare function inspectWorktrees(repoRoot: string): Promise<WorktreeInspection>;
23
+ export interface HealthFinding {
24
+ path: string;
25
+ /** What is wrong, in a sentence. */
26
+ problem: string;
27
+ /** What to do about it, in a sentence. */
28
+ advice?: string;
29
+ /** A fix Forge can make itself, after the person confirms: forget a listing, or delete a stray folder. */
30
+ fix?: 'prune' | 'delete-folder';
31
+ }
32
+ /** Read-only checks of every Agent Manager worktree. An empty list means all is well. */
33
+ export declare function doctorWorktrees(repoRoot: string): Promise<HealthFinding[]>;
34
+ export interface WorktreeStats {
35
+ /** Commits on the session branch that the base does not have. */
36
+ ahead: number;
37
+ /** Commits on the base that the session branch does not have. */
38
+ behind: number;
39
+ /** Files changed against the base, committed or not. */
40
+ changed: number;
41
+ /** Lines added against the base in tracked files, committed or not. A new file git has not been told about is not counted. */
42
+ added: number;
43
+ /** Lines removed against the base, counted the same way. */
44
+ removed: number;
45
+ }
46
+ /** Numbers for one session's row. Never throws; a folder or branch that cannot be read gives zeros. */
47
+ export declare function worktreeStats(repoRoot: string, worktreePath: string, branch: string, base: string): Promise<WorktreeStats>;
48
+ /** Forget listings whose folder is gone. Returns how many of the Agent Manager's were forgotten. */
49
+ export declare function pruneMissingWorktrees(repoRoot: string): Promise<number>;
50
+ /** Delete a folder under `.forge/worktrees` that git does not know. Refuses anything else. */
51
+ export declare function removeStrayFolder(repoRoot: string, dir: string): Promise<void>;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Worktree health for the Agent Manager (F9): what git knows against what is on disk, a doctor that names what is wrong
3
+ * in words, ahead/behind and changed-file counts for a poller, and the two fixes.
4
+ *
5
+ * The readers change nothing and take no git lock (`--no-optional-locks`), so a poller can run beside a person's own git.
6
+ * The fixes change repository metadata, so they go through the repo queue like create and remove (RB-25).
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { existsSync, lstatSync, realpathSync } from 'node:fs';
10
+ import { readdir, rm } from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { promisify } from 'node:util';
13
+ import { canonicalPath, onRepoQueue } from './worktree.js';
14
+ const execFileAsync = promisify(execFile);
15
+ async function git(cwd, args) {
16
+ const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 16 * 1024 * 1024, windowsHide: true });
17
+ return stdout;
18
+ }
19
+ /** `git worktree list --porcelain` into entries. */
20
+ export function parseWorktreeList(text) {
21
+ const out = [];
22
+ for (const block of text.split(/\r?\n\r?\n/)) {
23
+ const lines = block.split(/\r?\n/).filter(Boolean);
24
+ const pathLine = lines.find((l) => l.startsWith('worktree '));
25
+ if (!pathLine)
26
+ continue;
27
+ const branchLine = lines.find((l) => l.startsWith('branch '));
28
+ const lock = lines.find((l) => l === 'locked' || l.startsWith('locked '));
29
+ out.push({
30
+ path: pathLine.slice('worktree '.length).trim(),
31
+ head: (lines.find((l) => l.startsWith('HEAD ')) ?? '').slice('HEAD '.length).trim(),
32
+ ...(branchLine ? { branch: branchLine.slice('branch '.length).trim().replace(/^refs\/heads\//, '') } : {}),
33
+ detached: lines.includes('detached'),
34
+ locked: lock !== undefined,
35
+ ...(lock && lock.length > 'locked'.length ? { lockReason: lock.slice('locked '.length).trim() } : {}),
36
+ prunable: lines.some((l) => l === 'prunable' || l.startsWith('prunable ')),
37
+ });
38
+ }
39
+ return out;
40
+ }
41
+ const same = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
42
+ const normal = (p) => path.resolve(p);
43
+ function worktreesRootOf(repoRoot) {
44
+ return path.join(canonicalPath(repoRoot), '.forge', 'worktrees');
45
+ }
46
+ function isUnder(root, p) {
47
+ const rel = path.relative(root, p);
48
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
49
+ }
50
+ export async function inspectWorktrees(repoRoot) {
51
+ const root = canonicalPath(repoRoot);
52
+ const wtRoot = worktreesRootOf(root);
53
+ let listed = [];
54
+ try {
55
+ listed = parseWorktreeList(await git(root, ['--no-optional-locks', 'worktree', 'list', '--porcelain']));
56
+ }
57
+ catch {
58
+ // not a repository, or git is missing: nothing to report
59
+ }
60
+ const registered = listed.map((w) => ({ ...w, path: normal(w.path) })).filter((w) => isUnder(wtRoot, w.path));
61
+ let strayFolders = [];
62
+ try {
63
+ const entries = await readdir(wtRoot, { withFileTypes: true });
64
+ strayFolders = entries
65
+ .filter((e) => e.isDirectory())
66
+ .map((e) => path.join(wtRoot, e.name))
67
+ .filter((dir) => !registered.some((w) => same(w.path, dir)));
68
+ }
69
+ catch {
70
+ // no folder yet
71
+ }
72
+ const missing = registered.filter((w) => !existsSync(w.path));
73
+ return { registered, strayFolders, missing };
74
+ }
75
+ async function branchExists(repoRoot, branch) {
76
+ try {
77
+ await git(repoRoot, ['--no-optional-locks', 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
78
+ return true;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ /** Read-only checks of every Agent Manager worktree. An empty list means all is well. */
85
+ export async function doctorWorktrees(repoRoot) {
86
+ const root = canonicalPath(repoRoot);
87
+ const { registered, strayFolders, missing } = await inspectWorktrees(root);
88
+ const found = [];
89
+ for (const w of registered) {
90
+ if (missing.includes(w)) {
91
+ found.push({ path: w.path, problem: 'Its folder is gone, but git still lists it.', advice: 'Prune it to forget the listing.', fix: 'prune' });
92
+ continue;
93
+ }
94
+ if (w.detached || !w.branch) {
95
+ found.push({ path: w.path, problem: 'It is on a detached HEAD, so commits made here belong to no branch.', advice: 'Create a branch before you leave it, or the commits can be lost.' });
96
+ }
97
+ else if (!(await branchExists(root, w.branch))) {
98
+ found.push({ path: w.path, problem: `Its branch "${w.branch}" no longer exists.`, advice: 'Create the branch again from this folder, or discard the session.' });
99
+ }
100
+ if (w.locked)
101
+ found.push({ path: w.path, problem: `Git has it locked${w.lockReason ? ` (${w.lockReason})` : ''}.`, advice: 'Unlock it with git worktree unlock if nothing is using it.' });
102
+ try {
103
+ const gitDir = (await git(w.path, ['--no-optional-locks', 'rev-parse', '--absolute-git-dir'])).trim();
104
+ for (const name of ['index.lock', 'HEAD.lock']) {
105
+ if (existsSync(path.join(gitDir, name))) {
106
+ found.push({ path: w.path, problem: `A leftover lock file (${name}) stops git commands here.`, advice: 'If no git command is running, delete that file.' });
107
+ }
108
+ }
109
+ }
110
+ catch {
111
+ // not readable as a git folder: another finding already covers a broken listing
112
+ }
113
+ }
114
+ for (const dir of strayFolders) {
115
+ found.push({ path: dir, problem: 'This folder is not a worktree that git knows.', advice: 'Look inside, and delete it if nothing there matters.', fix: 'delete-folder' });
116
+ }
117
+ return found;
118
+ }
119
+ const ZERO = { ahead: 0, behind: 0, changed: 0, added: 0, removed: 0 };
120
+ /** Numbers for one session's row. Never throws; a folder or branch that cannot be read gives zeros. */
121
+ export async function worktreeStats(repoRoot, worktreePath, branch, base) {
122
+ if (!existsSync(worktreePath))
123
+ return ZERO;
124
+ const root = canonicalPath(repoRoot);
125
+ try {
126
+ const counts = (await git(root, ['--no-optional-locks', 'rev-list', '--left-right', '--count', `${base}...${branch}`])).trim().split(/\s+/).map(Number);
127
+ const files = new Set();
128
+ const committed = await git(root, ['--no-optional-locks', 'diff', '--name-only', `${base}...${branch}`]);
129
+ for (const l of committed.split(/\r?\n/))
130
+ if (l.trim())
131
+ files.add(l.trim());
132
+ const status = await git(worktreePath, ['--no-optional-locks', 'status', '--porcelain=v1']);
133
+ for (const l of status.split(/\r?\n/))
134
+ if (l.trim())
135
+ files.add(l.slice(3).trim());
136
+ // One diff of the working tree against where the branch left the base, so a line edited twice is one change. A binary file shows "-" and adds no lines.
137
+ const mergeBase = (await git(worktreePath, ['merge-base', base, 'HEAD'])).trim();
138
+ let added = 0;
139
+ let removed = 0;
140
+ for (const l of (await git(worktreePath, ['--no-optional-locks', 'diff', '--numstat', mergeBase])).split(/\r?\n/)) {
141
+ const [a, r] = l.split('\t');
142
+ if (a && r && /^\d+$/.test(a) && /^\d+$/.test(r)) {
143
+ added += Number(a);
144
+ removed += Number(r);
145
+ }
146
+ }
147
+ return { behind: counts[0] ?? 0, ahead: counts[1] ?? 0, changed: files.size, added, removed };
148
+ }
149
+ catch {
150
+ return ZERO;
151
+ }
152
+ }
153
+ /** Forget listings whose folder is gone. Returns how many of the Agent Manager's were forgotten. */
154
+ export function pruneMissingWorktrees(repoRoot) {
155
+ const root = canonicalPath(repoRoot);
156
+ return onRepoQueue(root, async () => {
157
+ const before = (await inspectWorktrees(root)).missing.length;
158
+ if (before === 0)
159
+ return 0;
160
+ await git(root, ['worktree', 'prune']);
161
+ return before - (await inspectWorktrees(root)).missing.length;
162
+ });
163
+ }
164
+ /** Delete a folder under `.forge/worktrees` that git does not know. Refuses anything else. */
165
+ export function removeStrayFolder(repoRoot, dir) {
166
+ const root = canonicalPath(repoRoot);
167
+ return onRepoQueue(root, async () => {
168
+ const wtRoot = worktreesRootOf(root);
169
+ const target = path.resolve(dir);
170
+ if (same(target, wtRoot))
171
+ throw new Error('That is the worktrees folder itself; only a folder inside it can be removed.');
172
+ if (!isUnder(wtRoot, target))
173
+ throw new Error('Only a folder inside .forge/worktrees can be removed; this one is outside it.');
174
+ const { registered } = await inspectWorktrees(root);
175
+ if (registered.some((w) => same(w.path, target)))
176
+ throw new Error('That folder is a worktree git knows. Discard its session instead.');
177
+ if (existsSync(target)) {
178
+ if (lstatSync(target).isSymbolicLink())
179
+ throw new Error('That is a link, not a folder, so it was left alone.');
180
+ // Where the folder really is, after any link on the way (RB-20).
181
+ if (!isUnder(realpathSync.native(wtRoot), realpathSync.native(target)))
182
+ throw new Error('Only a folder inside .forge/worktrees can be removed; this one leads outside it.');
183
+ }
184
+ await rm(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 150 });
185
+ });
186
+ }
@@ -15,3 +15,7 @@ export { compareWorktreeContexts, snapshotWorktreeContext, WorktreeManager, Work
15
15
  export type { CreateOptions, RemoveOptions, RemoveResult, WorktreeContextSnapshot, WorktreeInfo, WorktreeMismatchKind, WorktreeMismatchResult, } from './worktree.js';
16
16
  export { branchChangedFiles, branchDiff, commitAllInWorktree, deleteBranch, mergeBranch, } from './branch-ops.js';
17
17
  export type { MergeResult, WorktreeCommit } from './branch-ops.js';
18
+ export { doctorWorktrees, inspectWorktrees, parseWorktreeList, pruneMissingWorktrees, removeStrayFolder, worktreeStats } from './health.js';
19
+ export type { HealthFinding, ListedWorktree, WorktreeInspection, WorktreeStats } from './health.js';
20
+ export { copyIntoWorktree, runSetupScript, setupWorktree } from './setup.js';
21
+ export type { CopyOptions, CopyResult, ScriptOptions, ScriptResult, SetupOptions, SetupOutcome } from './setup.js';
@@ -13,3 +13,5 @@
13
13
  */
14
14
  export { compareWorktreeContexts, snapshotWorktreeContext, WorktreeManager, WorktreeError, } from './worktree.js';
15
15
  export { branchChangedFiles, branchDiff, commitAllInWorktree, deleteBranch, mergeBranch, } from './branch-ops.js';
16
+ export { doctorWorktrees, inspectWorktrees, parseWorktreeList, pruneMissingWorktrees, removeStrayFolder, worktreeStats } from './health.js';
17
+ export { copyIntoWorktree, runSetupScript, setupWorktree } from './setup.js';
@@ -0,0 +1,51 @@
1
+ import { type ShellPreference } from '../tools/shell.js';
2
+ export interface CopyResult {
3
+ /** Paths copied, relative to the repository, with `/`. */
4
+ copied: string[];
5
+ skipped: {
6
+ path: string;
7
+ reason: string;
8
+ }[];
9
+ }
10
+ export interface CopyOptions {
11
+ maxFiles?: number;
12
+ maxFileBytes?: number;
13
+ }
14
+ /**
15
+ * Copy the files matching `patterns` (a name such as `.env` matches at any depth, `config/*.json` is a path) from the
16
+ * repository into the worktree. Never overwrites, never follows a link, never enters `.git` or a build folder, and stops
17
+ * at a count and a size.
18
+ */
19
+ export declare function copyIntoWorktree(repoRoot: string, worktreePath: string, patterns: readonly string[], options?: CopyOptions): Promise<CopyResult>;
20
+ export interface ScriptOptions {
21
+ timeoutMs?: number;
22
+ tailChars?: number;
23
+ shell?: ShellPreference;
24
+ }
25
+ export interface ScriptResult {
26
+ ok: boolean;
27
+ exitCode: number | null;
28
+ timedOut: boolean;
29
+ /** The end of what the script printed (both streams). */
30
+ output: string;
31
+ }
32
+ /** Run `script` in a shell, in the worktree. Never throws: a script that cannot start is a failed result. */
33
+ export declare function runSetupScript(worktreePath: string, script: string, options?: ScriptOptions): Promise<ScriptResult>;
34
+ export interface SetupOptions extends CopyOptions {
35
+ copyFiles?: readonly string[];
36
+ setupScript?: string;
37
+ scriptTimeoutMs?: number;
38
+ shell?: ShellPreference;
39
+ }
40
+ export interface SetupOutcome {
41
+ ok: boolean;
42
+ copied: string[];
43
+ skipped: {
44
+ path: string;
45
+ reason: string;
46
+ }[];
47
+ /** Why it stopped, in words, with the end of the script's output. */
48
+ message?: string;
49
+ }
50
+ /** Copy, then run the script. The first failure ends it. */
51
+ export declare function setupWorktree(repoRoot: string, worktreePath: string, options: SetupOptions): Promise<SetupOutcome>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Getting a new worktree ready before an agent works in it (F7): copy a few files from the repository (an `.env`), then run
3
+ * a setup script (`npm ci`). Both are the person's own settings, never read from a repository (RB-12). A failed step ends
4
+ * the session before the agent runs in a half-made tree.
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { constants } from 'node:fs';
8
+ import { copyFile, lstat, mkdir, readdir } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { fileGlobMatcher } from '../tools/glob-filter.js';
11
+ import { killTree } from '../tools/run-command.js';
12
+ import { runnerShell, shellInvocation } from '../tools/shell.js';
13
+ import { shellEnv } from '../tools/shell-env.js';
14
+ import { DEFAULT_IGNORED_DIRS } from '../tools/shared.js';
15
+ const DEFAULT_MAX_FILES = 200;
16
+ const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
17
+ const DEFAULT_TIMEOUT_MS = 5 * 60_000;
18
+ const DEFAULT_TAIL_CHARS = 2000;
19
+ /** Output kept while a script runs; only the end is ever shown. */
20
+ const KEEP_CHARS = 64 * 1024;
21
+ function checkPattern(p) {
22
+ if (!p.trim() || path.isAbsolute(p) || /^[A-Za-z]:/.test(p) || p.split(/[\\/]/).includes('..')) {
23
+ throw new Error(`Copy pattern "${p}" must be relative to the repository and stay inside it.`);
24
+ }
25
+ }
26
+ /**
27
+ * Copy the files matching `patterns` (a name such as `.env` matches at any depth, `config/*.json` is a path) from the
28
+ * repository into the worktree. Never overwrites, never follows a link, never enters `.git` or a build folder, and stops
29
+ * at a count and a size.
30
+ */
31
+ export async function copyIntoWorktree(repoRoot, worktreePath, patterns, options = {}) {
32
+ for (const p of patterns)
33
+ checkPattern(p);
34
+ const matchers = patterns.map((p) => fileGlobMatcher(p.replace(/\\/g, '/')));
35
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
36
+ const maxBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
37
+ const result = { copied: [], skipped: [] };
38
+ async function walk(dir) {
39
+ let entries;
40
+ try {
41
+ entries = await readdir(dir, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ for (const entry of entries) {
47
+ // A link could lead anywhere on disk (RB-20): it is not followed and not copied.
48
+ if (entry.isSymbolicLink())
49
+ continue;
50
+ const abs = path.join(dir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ if (entry.name === '.git' || DEFAULT_IGNORED_DIRS.has(entry.name))
53
+ continue;
54
+ await walk(abs);
55
+ continue;
56
+ }
57
+ if (!entry.isFile())
58
+ continue;
59
+ const rel = path.relative(repoRoot, abs).split(path.sep).join('/');
60
+ if (!matchers.some((m) => m(rel, entry.name)))
61
+ continue;
62
+ if (result.copied.length >= maxFiles) {
63
+ result.skipped.push({ path: rel, reason: 'file limit' });
64
+ continue;
65
+ }
66
+ const size = (await lstat(abs)).size;
67
+ if (size > maxBytes) {
68
+ result.skipped.push({ path: rel, reason: 'too large' });
69
+ continue;
70
+ }
71
+ const dest = path.join(worktreePath, ...rel.split('/'));
72
+ // The destination must stay inside the worktree whatever the name was.
73
+ if (path.relative(worktreePath, dest).startsWith('..'))
74
+ continue;
75
+ await mkdir(path.dirname(dest), { recursive: true });
76
+ try {
77
+ await copyFile(abs, dest, constants.COPYFILE_EXCL);
78
+ result.copied.push(rel);
79
+ }
80
+ catch (err) {
81
+ if (err.code === 'EEXIST')
82
+ result.skipped.push({ path: rel, reason: 'already exists' });
83
+ else
84
+ throw err;
85
+ }
86
+ }
87
+ }
88
+ await walk(repoRoot);
89
+ return result;
90
+ }
91
+ /** Run `script` in a shell, in the worktree. Never throws: a script that cannot start is a failed result. */
92
+ export function runSetupScript(worktreePath, script, options = {}) {
93
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
94
+ const tail = options.tailChars ?? DEFAULT_TAIL_CHARS;
95
+ return new Promise((resolve) => {
96
+ let output = '';
97
+ let timedOut = false;
98
+ const take = (chunk) => {
99
+ output += chunk;
100
+ if (output.length > KEEP_CHARS)
101
+ output = output.slice(output.length - KEEP_CHARS);
102
+ };
103
+ const inv = shellInvocation(runnerShell(options.shell), script);
104
+ const spawnOptions = {
105
+ cwd: worktreePath,
106
+ // Windows: never run a same-named program the repository ships in its root (RB-24).
107
+ env: shellEnv(process.env),
108
+ stdio: ['ignore', 'pipe', 'pipe'],
109
+ windowsHide: true,
110
+ detached: process.platform !== 'win32',
111
+ };
112
+ let child;
113
+ try {
114
+ child = inv.file ? spawn(inv.file, inv.args, spawnOptions) : spawn(script, { ...spawnOptions, shell: true });
115
+ }
116
+ catch (err) {
117
+ resolve({ ok: false, exitCode: null, timedOut: false, output: err.message });
118
+ return;
119
+ }
120
+ child.stdout.setEncoding('utf8').on('data', take);
121
+ child.stderr.setEncoding('utf8').on('data', take);
122
+ const timer = setTimeout(() => {
123
+ timedOut = true;
124
+ killTree(child);
125
+ }, timeoutMs);
126
+ let finished = false;
127
+ const finish = (code) => {
128
+ if (finished)
129
+ return;
130
+ finished = true;
131
+ clearTimeout(timer);
132
+ resolve({ ok: code === 0 && !timedOut, exitCode: code, timedOut, output: output.trim().slice(-tail) });
133
+ };
134
+ child.on('error', (err) => {
135
+ take(`${err.message}\n`);
136
+ finish(null);
137
+ });
138
+ // A grandchild that escaped the tree kill must not hold the pipes open and hang the session (RB-70).
139
+ child.on('exit', (code) => {
140
+ setTimeout(() => {
141
+ child.stdout.destroy();
142
+ child.stderr.destroy();
143
+ finish(code);
144
+ }, 500).unref();
145
+ });
146
+ child.on('close', (code) => finish(code));
147
+ });
148
+ }
149
+ /** Copy, then run the script. The first failure ends it. */
150
+ export async function setupWorktree(repoRoot, worktreePath, options) {
151
+ let copied = [];
152
+ let skipped = [];
153
+ const patterns = options.copyFiles ?? [];
154
+ if (patterns.length > 0) {
155
+ try {
156
+ const r = await copyIntoWorktree(repoRoot, worktreePath, patterns, options);
157
+ copied = r.copied;
158
+ skipped = r.skipped;
159
+ }
160
+ catch (err) {
161
+ return { ok: false, copied, skipped, message: `Copying files into the worktree failed: ${err.message}` };
162
+ }
163
+ }
164
+ const script = options.setupScript?.trim();
165
+ if (script) {
166
+ const r = await runSetupScript(worktreePath, script, { ...(options.scriptTimeoutMs ? { timeoutMs: options.scriptTimeoutMs } : {}), ...(options.shell ? { shell: options.shell } : {}) });
167
+ if (!r.ok) {
168
+ const why = r.timedOut ? 'ran too long and was stopped' : `failed${r.exitCode === null ? '' : ` (exit code ${r.exitCode})`}`;
169
+ return { ok: false, copied, skipped, message: `The setup script ${why}.${r.output ? `\n${r.output}` : ''}` };
170
+ }
171
+ }
172
+ return { ok: true, copied, skipped };
173
+ }
@@ -335,3 +335,10 @@ export declare class WorktreeManager {
335
335
  * canonical too. Falls back to `path.resolve` when the path does not exist.
336
336
  */
337
337
  export declare function canonicalPath(p: string): string;
338
+ /**
339
+ * Run `work` after every earlier queued worktree change of the same repository has finished. Parallel steps create
340
+ * and remove worktrees at the same moment, and two `git worktree add` / `remove` calls on one repository fight over
341
+ * git's lock files; one of them then fails at random, mostly under load. The agents still run in parallel: only these
342
+ * short git changes take turns.
343
+ */
344
+ export declare function onRepoQueue<T>(repoRoot: string, work: () => Promise<T>): Promise<T>;
@@ -296,9 +296,14 @@ export class WorktreeManager {
296
296
  throw new WorktreeError(`Worktree path already exists: ${worktreePath}. ` +
297
297
  `Pick a unique label or remove the existing worktree.`);
298
298
  }
299
- const args = ['worktree', 'add', '--no-track'];
300
- if (!opts.detached) {
301
- args.push('-b', branch);
299
+ const args = ['worktree', 'add'];
300
+ if (opts.detached) {
301
+ // Without --detach git makes a branch named after the folder, so the worktree was never detached (RB-74).
302
+ // --no-track is only allowed when a branch is created.
303
+ args.push('--detach');
304
+ }
305
+ else {
306
+ args.push('--no-track', '-b', branch);
302
307
  }
303
308
  if (opts.baseRef) {
304
309
  args.push(worktreePath, opts.baseRef);
@@ -642,7 +647,7 @@ const repoQueues = new Map();
642
647
  * git's lock files; one of them then fails at random, mostly under load. The agents still run in parallel: only these
643
648
  * short git changes take turns.
644
649
  */
645
- function onRepoQueue(repoRoot, work) {
650
+ export function onRepoQueue(repoRoot, work) {
646
651
  const key = canonicalPath(repoRoot);
647
652
  const next = (repoQueues.get(key) ?? Promise.resolve()).then(work, work);
648
653
  repoQueues.set(key, next.catch(() => undefined));