agy-mcp-bridge 0.1.19 → 0.1.21

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.
@@ -13,5 +13,9 @@
13
13
  "agents": {
14
14
  "priority": ["antigravity", "codex"],
15
15
  "enabled": ["antigravity", "codex"]
16
+ },
17
+ "tree_watch": {
18
+ "enabled": true,
19
+ "delays_ms": [500, 1000, 2000, 4000, 10000]
16
20
  }
17
21
  }
@@ -58,6 +58,6 @@ export function legacyAntigravityAgent(runtime, transport) {
58
58
  version: null, executable: runtime.executable || null, capabilities: CAPABILITIES,
59
59
  warnings: runtime.supported ? [] : ['Requires agy 1.4.x and all required print/output/timeout flags']
60
60
  };
61
- return { adapter: antigravityAdapter(runtime.executable, transport), status, enabled: true, launch: runtime.launch };
61
+ return { adapter: antigravityAdapter(runtime.executable, transport), status, enabled: true, prefixArgs: runtime.prefixArgs, launch: runtime.launch };
62
62
  }
63
63
  //# sourceMappingURL=antigravity.js.map
@@ -23,7 +23,7 @@ export async function createAgentRegistry(config, antigravity) {
23
23
  };
24
24
  agents.push({
25
25
  adapter: antigravityAdapter(antigravity.executable, config.prompt_transport),
26
- status: antigravityStatus, enabled: antigravityEnabled
26
+ status: antigravityStatus, enabled: antigravityEnabled, prefixArgs: antigravity.prefixArgs
27
27
  });
28
28
  const codexEnabled = enabled.includes('codex');
29
29
  let codexStatus = {
@@ -129,9 +129,11 @@ async function terminate(child) {
129
129
  }
130
130
  }
131
131
  }
132
- export async function inspectCli(executable, cwd, run = startProcess, requireStdin = false) {
132
+ /** Probes a resolved command. The whole command goes in, not just its executable: a Windows shim runs as
133
+ * `node <script>` and asking `node --version` would report Node's version instead of the CLI's. */
134
+ export async function inspectCli(command, cwd, run = startProcess, requireStdin = false) {
133
135
  const capture = (args) => new Promise((resolve, reject) => {
134
- const child = run(executable, args, cwd);
136
+ const child = run(command.executable, [...command.prefixArgs, ...args], cwd);
135
137
  let text = '';
136
138
  let exceeded = false;
137
139
  const timer = setTimeout(() => { exceeded = true; void terminateOwned(child).catch(reject); }, 10000);
@@ -150,6 +152,6 @@ export async function inspectCli(executable, cwd, run = startProcess, requireStd
150
152
  ['--print', '--output-format', '--print-timeout', '--disable-slash-commands', ...(requireStdin ? ['--input-format'] : [])].every(flag => new RegExp(`(?:^|\\s)${flag}(?=\\s|$)`).test(help.text));
151
153
  const verified = VERIFIED_AGY_VERSIONS.includes(actual);
152
154
  const warnings = supported && !verified ? [`agy ${actual} is compatible by 1.4.x version/help checks, but only ${VERIFIED_AGY_VERSIONS.join(' and ')} have been verified with real agent execution`] : [];
153
- return { version: actual, supported, executable, warnings };
155
+ return { version: actual, supported, executable: command.executable, prefixArgs: command.prefixArgs, warnings };
154
156
  }
155
157
  //# sourceMappingURL=process.js.map
@@ -19,6 +19,15 @@ const schema = z.object({
19
19
  max_parallel_worktrees: z.number().int().min(1).max(8).optional(),
20
20
  default_autonomy: z.enum(['suggest', 'auto', 'full']).optional(),
21
21
  agents: agentsSchema.optional(),
22
+ /**
23
+ * The process-tree watcher that runs while a task is live. One poll costs a full process-table query (about half a
24
+ * second on the reference machine), so the cadence is owner-tunable and the whole watch can be turned off; without
25
+ * it, a subtree whose intermediate shell exited before the agent did becomes invisible to the release.
26
+ */
27
+ tree_watch: z.object({
28
+ enabled: z.boolean().optional(),
29
+ delays_ms: z.array(z.number().int().min(100).max(60000)).min(1).max(20).optional()
30
+ }).strict().optional(),
22
31
  extra_env_passthrough: z.array(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)).max(100).default([]),
23
32
  prompt_transport: z.enum(['stdin', 'argv']).default('argv'),
24
33
  /** Longest single antigravity_task_wait call (default 55). Keep it below the MCP client's tool timeout. */
@@ -0,0 +1,281 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { TERMINATION_GRACE_MS } from '../antigravity/process.js';
3
+ /**
4
+ * OS singletons that must never be reaped even if a recycled pid claims them as children of our exited agent.
5
+ * `cmd.exe` and `powershell.exe` are absent on purpose: they are genuine members of a real agent tree.
6
+ */
7
+ export const PROTECTED_IMAGES = [
8
+ 'system', 'registry', 'smss.exe', 'csrss.exe', 'wininit.exe', 'winlogon.exe', 'services.exe', 'lsass.exe',
9
+ 'svchost.exe', 'fontdrvhost.exe', 'dwm.exe', 'explorer.exe', 'taskhostw.exe', 'sihost.exe', 'ctfmon.exe'
10
+ ];
11
+ /** Clock granularity plus scheduling slack, so a child started microseconds before the recorded start is still ours. */
12
+ const CREATION_SLACK_MS = 5000;
13
+ /** Pure so the safety rules are testable without touching the process table. */
14
+ export function reapGuard(child, guard, protectedImages = PROTECTED_IMAGES) {
15
+ if (!Number.isInteger(child.pid) || child.pid <= 1)
16
+ return 'pid is not a reapable process id';
17
+ if (child.pid === (guard.self_pid ?? process.pid))
18
+ return 'pid is this bridge process';
19
+ const name = child.name.toLowerCase();
20
+ if (protectedImages.includes(name))
21
+ return `${child.name} is a protected system process`;
22
+ if (child.created_ms !== null && child.created_ms < guard.started_at_ms - CREATION_SLACK_MS)
23
+ return 'process existed before this task started';
24
+ return null;
25
+ }
26
+ function powershell(command, timeout = 8000) {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
29
+ let text = '';
30
+ const timer = setTimeout(() => { try {
31
+ child.kill('SIGKILL');
32
+ }
33
+ catch { /* already gone */ } }, timeout);
34
+ child.stdout.on('data', (data) => { if (text.length < 262144)
35
+ text += data.toString('utf8'); });
36
+ child.once('error', error => { clearTimeout(timer); reject(error); });
37
+ child.once('close', () => { clearTimeout(timer); resolve(text); });
38
+ });
39
+ }
40
+ /**
41
+ * Tab-separated so a name containing spaces cannot be mis-split. The whole process table is cheaper than one
42
+ * query per pid, and CommandLine is left out because it roughly doubles the cost of a query that repeats.
43
+ */
44
+ function tableQuery() {
45
+ return 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,CreationDate | ForEach-Object { ' +
46
+ '$d = if ($_.CreationDate) { [int64]([System.DateTimeOffset]::new([System.TimeZoneInfo]::ToUniversalTime($_.CreationDate)).ToUnixTimeMilliseconds()) } else { -1 }; ' +
47
+ '"{0}`t{1}`t{2}`t{3}" -f $_.ProcessId, $_.ParentProcessId, $_.Name, $d }';
48
+ }
49
+ function parseTable(text) {
50
+ const rows = [];
51
+ for (const line of text.split(/\r?\n/)) {
52
+ const columns = line.split('\t');
53
+ if (columns.length < 3)
54
+ continue;
55
+ const pid = Number.parseInt(columns[0], 10);
56
+ const parent_pid = Number.parseInt(columns[1], 10);
57
+ if (!Number.isInteger(pid) || !Number.isInteger(parent_pid))
58
+ continue;
59
+ const created = Number.parseInt(columns[3] ?? '', 10);
60
+ rows.push({ pid, parent_pid, name: columns[2] ?? '',
61
+ created_ms: Number.isInteger(created) && created >= 0 ? created : null });
62
+ }
63
+ return rows;
64
+ }
65
+ export async function listProcesses() {
66
+ return parseTable(await powershell(tableQuery()));
67
+ }
68
+ /**
69
+ * The same rows, but only for the pids about to be killed. Every id is validated first because it is spliced into a
70
+ * WQL filter string.
71
+ */
72
+ export async function listProcessesById(pids) {
73
+ if (!pids.length)
74
+ return [];
75
+ if (!pids.every(pid => Number.isInteger(pid) && pid > 1))
76
+ throw new Error('A process id to verify must be a positive integer');
77
+ const filter = pids.map(pid => `ProcessId=${pid}`).join(' OR ');
78
+ return parseTable(await powershell(tableQuery().replace('Win32_Process -Property', `Win32_Process -Filter '${filter}' -Property`)));
79
+ }
80
+ /** Every row below `root`, nearest first, without the root itself. */
81
+ export function descendants(table, root) {
82
+ const childrenOf = new Map();
83
+ for (const row of table) {
84
+ const siblings = childrenOf.get(row.parent_pid);
85
+ if (siblings)
86
+ siblings.push(row);
87
+ else
88
+ childrenOf.set(row.parent_pid, [row]);
89
+ }
90
+ const found = [];
91
+ const seen = new Set([root]);
92
+ for (let frontier = [root]; frontier.length;) {
93
+ const next = [];
94
+ for (const pid of frontier) {
95
+ for (const child of childrenOf.get(pid) ?? []) {
96
+ if (seen.has(child.pid))
97
+ continue;
98
+ seen.add(child.pid);
99
+ found.push(child);
100
+ next.push(child.pid);
101
+ }
102
+ }
103
+ frontier = next;
104
+ }
105
+ return found;
106
+ }
107
+ /**
108
+ * Denser while the CLI is booting its servers, then steady: a missed short-lived shell loses a whole subtree.
109
+ * Each gap is measured after the previous query finished, and one query costs about half a second, so the tail
110
+ * stays long on purpose to keep the watcher well under a tenth of a core.
111
+ */
112
+ export const WATCH_DELAYS_MS = [500, 1000, 2000, 4000, 10000];
113
+ /**
114
+ * Records the agent's descendant tree while it runs, because the shells that hold the deepest children exit before
115
+ * the agent does. Best effort on every axis: a failed query is skipped, the next tick can still see the tree.
116
+ */
117
+ export function startTreeWatch(pid, options = {}) {
118
+ const delays = options.delays ?? WATCH_DELAYS_MS;
119
+ if ((options.platform ?? process.platform) !== 'win32' || !Number.isInteger(pid) || pid <= 1)
120
+ return { stop: () => [] };
121
+ const list = options.list ?? listProcesses;
122
+ const observed = new Map();
123
+ let stopped = false;
124
+ let cancel = null;
125
+ const poll = async (index) => {
126
+ try {
127
+ for (const info of descendants(await list(), pid))
128
+ observed.set(info.pid, info);
129
+ }
130
+ catch { /* the next tick gets another chance; the release is a convenience, not a guarantee */ }
131
+ if (!stopped)
132
+ wait(index + 1);
133
+ };
134
+ const wait = (index) => {
135
+ const at = Math.min(index, delays.length - 1);
136
+ const schedule = options.schedule ?? ((callback, ms) => { const timer = setTimeout(callback, ms); timer.unref(); return () => { clearTimeout(timer); }; });
137
+ cancel = schedule(() => poll(at), delays[at]);
138
+ };
139
+ wait(0);
140
+ return {
141
+ async stop() {
142
+ stopped = true;
143
+ cancel?.();
144
+ try {
145
+ for (const info of descendants(await list(), pid))
146
+ observed.set(info.pid, info);
147
+ }
148
+ catch { /* the release still has everything the polls recorded */ }
149
+ return [...observed.values()];
150
+ }
151
+ };
152
+ }
153
+ async function killTree(pid) {
154
+ await new Promise((resolve, reject) => {
155
+ const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
156
+ const killer = spawn(`${systemRoot}\\System32\\taskkill.exe`, ['/PID', String(pid), '/T', '/F'], { shell: false, windowsHide: true, stdio: 'ignore' });
157
+ killer.once('error', reject);
158
+ killer.once('close', code => code === 0 ? resolve() : reject(new Error(`taskkill exited ${code}`)));
159
+ });
160
+ }
161
+ const groupAlive = (pid) => {
162
+ try {
163
+ process.kill(-pid, 0);
164
+ return true;
165
+ }
166
+ catch {
167
+ return false;
168
+ }
169
+ };
170
+ /**
171
+ * Releases whatever the agent left behind after it exited. Candidates are the processes still hanging below the
172
+ * agent or below any process the watch recorded while it ran, and each subtree root must pass reapGuard, so this
173
+ * cannot wander into an unrelated tree. `taskkill /T` covers the children of a root, so only roots are killed.
174
+ */
175
+ export async function reapOwnedTree(pid, options) {
176
+ const platform = options.platform ?? process.platform;
177
+ if (!Number.isInteger(pid) || pid <= 1)
178
+ return { checked: false, reason: 'No usable process id was recorded for this task', reaped: [], skipped: [] };
179
+ const alive = options.isAlive ?? (value => {
180
+ try {
181
+ process.kill(value, 0);
182
+ return true;
183
+ }
184
+ catch (error) {
185
+ return error.code !== 'ESRCH';
186
+ }
187
+ });
188
+ if (alive(pid))
189
+ return { checked: false, reason: 'Owned process is still running; its tree is terminated by cancel or timeout instead', reaped: [], skipped: [] };
190
+ if (platform !== 'win32') {
191
+ // POSIX spawns the agent detached, so it led its own group and every descendant inherited that group id.
192
+ const inGroup = options.group_alive ?? groupAlive;
193
+ const signal = options.signal_group ?? ((target, value) => { process.kill(-target, value); });
194
+ if (!inGroup(pid))
195
+ return { checked: true, reaped: [], skipped: [], group_signalled: false };
196
+ try {
197
+ signal(pid, 'SIGTERM');
198
+ }
199
+ catch {
200
+ return { checked: true, reaped: [], skipped: [], group_signalled: false };
201
+ }
202
+ const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
203
+ const deadline = Date.now() + TERMINATION_GRACE_MS;
204
+ while (inGroup(pid)) {
205
+ if (Date.now() >= deadline) {
206
+ try {
207
+ signal(pid, 'SIGKILL');
208
+ }
209
+ catch { /* the group exited in the race */ }
210
+ break;
211
+ }
212
+ await sleep(50);
213
+ }
214
+ return { checked: true, reaped: [], skipped: [], group_signalled: true };
215
+ }
216
+ const observed = new Map(options.observed?.map(info => [info.pid, info]) ?? []);
217
+ let table;
218
+ try {
219
+ table = await (options.list ?? listProcesses)();
220
+ }
221
+ catch (error) {
222
+ return { checked: false, reason: `Could not enumerate the agent process tree: ${error instanceof Error ? error.message : 'query failed'}`, reaped: [], skipped: [], tracked: observed.size };
223
+ }
224
+ const tableByPid = new Map(table.map(row => [row.pid, row]));
225
+ const recycled = (info, current) => info.name.toLowerCase() !== current.name.toLowerCase()
226
+ || (info.created_ms !== null && current.created_ms !== null && info.created_ms !== current.created_ms);
227
+ // A recorded parent that is gone still names its children, which is exactly what a child query cannot recover.
228
+ // A recorded parent that is alive again under the same number but is a different process is not a parent of ours.
229
+ const roots = new Set([pid]);
230
+ for (const [recorded, info] of observed) {
231
+ const current = tableByPid.get(recorded);
232
+ if (!current || !recycled(info, current))
233
+ roots.add(recorded);
234
+ }
235
+ const candidates = table.filter(row => row.pid !== pid && roots.has(row.parent_pid));
236
+ const tops = candidates.filter(row => !candidates.some(other => other.pid === row.parent_pid));
237
+ const reaped = [];
238
+ const skipped = [];
239
+ const kill = options.kill ?? killTree;
240
+ // Enumeration and termination are not one atomic act: every kill is awaited, so a pid can be reissued while the
241
+ // loop runs. Re-reading just the targets costs one filtered query instead of a second full table.
242
+ let targets = tops;
243
+ if (tops.length) {
244
+ try {
245
+ const fresh = new Map((await (options.verify ?? listProcessesById)(tops.map(row => row.pid))).map(row => [row.pid, row]));
246
+ targets = [];
247
+ for (const candidate of tops) {
248
+ const now = fresh.get(candidate.pid);
249
+ if (!now)
250
+ continue;
251
+ if (recycled(candidate, now))
252
+ skipped.push({ pid: candidate.pid, name: candidate.name, guard: 'pid was reissued to another process after the tree query' });
253
+ else
254
+ targets.push(now);
255
+ }
256
+ }
257
+ catch { /* the second read is an extra guard; the checks already made against the tree query still stand */ }
258
+ }
259
+ for (const child of targets) {
260
+ const info = observed.get(child.pid);
261
+ if (info && recycled(info, child)) {
262
+ skipped.push({ pid: child.pid, name: child.name, guard: 'pid was recycled for an unrelated process after the task ended' });
263
+ continue;
264
+ }
265
+ const guard = reapGuard(child, { started_at_ms: options.started_at_ms, self_pid: options.self_pid });
266
+ if (guard) {
267
+ if (child.pid !== (options.self_pid ?? process.pid))
268
+ skipped.push({ pid: child.pid, name: child.name, guard });
269
+ continue;
270
+ }
271
+ try {
272
+ await kill(child.pid);
273
+ reaped.push(child.pid);
274
+ }
275
+ catch {
276
+ skipped.push({ pid: child.pid, name: child.name, guard: 'termination request failed (it may have exited on its own)' });
277
+ }
278
+ }
279
+ return { checked: true, reaped, skipped, tracked: observed.size };
280
+ }
281
+ //# sourceMappingURL=orphans.js.map
@@ -1,5 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { mkdir, realpath } from 'node:fs/promises';
2
+ import { mkdir, readFile, readdir, realpath, rmdir, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { workspaceChanges } from '../changes.js';
5
5
  import { inside } from '../security/workspace.js';
@@ -52,10 +52,200 @@ export async function createWorktree(options) {
52
52
  throw new Error('Worktree workspace resolves outside the worktree');
53
53
  return { root: realRoot, workspace: realWorkspace, toplevel, branch, prefix, base };
54
54
  }
55
- /** Best effort and only for a launch that never started a process: no files were touched by the agent. */
56
- export async function removeWorktree(worktree) {
57
- await git(worktree.toplevel, ['worktree', 'remove', '--force', worktree.root]).catch(() => { });
58
- await git(worktree.toplevel, ['branch', '-D', worktree.branch]).catch(() => { });
55
+ /**
56
+ * Best effort and only for a launch that never started a process: no files were touched by the agent.
57
+ * A Windows process that still holds the directory makes `git worktree remove` fail, so the attempt is retried
58
+ * briefly and the outcome is returned instead of dropped.
59
+ */
60
+ export async function removeWorktree(worktree, options = {}) {
61
+ const delays = options.retryDelayMs ?? [0, 200, 600];
62
+ let reason = '';
63
+ for (const delay of delays) {
64
+ if (delay)
65
+ await sleep(delay);
66
+ try {
67
+ await git(worktree.toplevel, ['worktree', 'remove', '--force', worktree.root]);
68
+ }
69
+ catch (error) {
70
+ reason = error instanceof Error ? error.message : 'git worktree remove failed';
71
+ }
72
+ if (!await exists(worktree.root)) {
73
+ await git(worktree.toplevel, ['branch', '-D', worktree.branch]).catch(() => { });
74
+ return { removed: true };
75
+ }
76
+ reason = `The worktree directory is still held by a running process${reason ? ` (last error: ${reason})` : ''}`;
77
+ }
78
+ return { removed: false, reason };
79
+ }
80
+ const exists = async (target) => {
81
+ try {
82
+ await stat(target);
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ };
89
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
90
+ const TASK_ID = /^[0-9a-f]{8}-[0-9a-f-]+$/;
91
+ const TASK_RECORD = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/;
92
+ /**
93
+ * Drops worktree directories that a previous run could not remove because a process the bridge no longer owned
94
+ * was holding them open. Only genuinely empty directories go: anything with content is a diff someone may still
95
+ * be reviewing, and stale locks on real work are left for a human.
96
+ */
97
+ export async function sweepStaleWorktrees(stateDirectory, options = {}) {
98
+ const directory = path.join(stateDirectory, 'worktrees');
99
+ const report = { scanned: 0, removed: [], retained: [], prunable: [] };
100
+ let names;
101
+ try {
102
+ names = await readdir(directory);
103
+ }
104
+ catch {
105
+ return report;
106
+ }
107
+ const readRecord = options.readRecord ?? (async (taskId) => {
108
+ try {
109
+ const parsed = JSON.parse(await readFile(path.join(stateDirectory, 'tasks', `${taskId}.json`), 'utf8'));
110
+ return { status: parsed.task?.status ?? parsed.status ?? null, repo: parsed.task?.worktree_toplevel ?? null, worktree: parsed.task?.worktree ?? null };
111
+ }
112
+ catch {
113
+ return { status: null, repo: null, worktree: null };
114
+ }
115
+ });
116
+ const queuePrune = (repo) => { if (!report.prunable.includes(repo))
117
+ report.prunable.push(repo); };
118
+ // Removing through git also drops the admin record under <repo>/.git/worktrees, which a plain rmdir leaves behind as
119
+ // a stale entry that then makes every later `git worktree` command in that repository complain.
120
+ const remove = options.remove ?? (async (target, repo) => {
121
+ if (repo) {
122
+ try {
123
+ await git(repo, ['worktree', 'remove', '--force', target]);
124
+ return 'git';
125
+ }
126
+ catch { /* the directory may be gone already */ }
127
+ }
128
+ await rmdir(target);
129
+ return 'rmdir';
130
+ });
131
+ for (const name of names) {
132
+ const candidate = path.join(directory, name);
133
+ report.scanned += 1;
134
+ if (!TASK_ID.test(name)) {
135
+ report.retained.push({ path: candidate, reason: 'not a task worktree directory' });
136
+ continue;
137
+ }
138
+ let record;
139
+ try {
140
+ record = await readRecord(name);
141
+ }
142
+ catch {
143
+ report.retained.push({ path: candidate, reason: 'its task record could not be read' });
144
+ continue;
145
+ }
146
+ if (record.status === 'queued' || record.status === 'running') {
147
+ report.retained.push({ path: candidate, reason: `task is ${record.status}` });
148
+ continue;
149
+ }
150
+ let entries;
151
+ try {
152
+ entries = await readdir(candidate);
153
+ }
154
+ catch (error) {
155
+ // The directory is already gone — deleted without git, which is exactly what leaves a stale admin record behind.
156
+ if (error.code === 'ENOENT') {
157
+ if (record.repo)
158
+ queuePrune(record.repo);
159
+ continue;
160
+ }
161
+ report.retained.push({ path: candidate, reason: error instanceof Error ? error.message : 'directory could not be read' });
162
+ continue;
163
+ }
164
+ if (entries.length) {
165
+ report.retained.push({ path: candidate, reason: 'it still holds files for review' });
166
+ continue;
167
+ }
168
+ let reason = '';
169
+ let how = 'rmdir';
170
+ for (const delay of options.retryDelayMs ?? [0, 200, 600]) {
171
+ if (delay)
172
+ await sleep(delay);
173
+ try {
174
+ how = await remove(candidate, record.repo);
175
+ reason = '';
176
+ break;
177
+ }
178
+ catch (error) {
179
+ reason = error instanceof Error ? error.message : 'directory could not be removed';
180
+ }
181
+ }
182
+ if (reason)
183
+ report.retained.push({ path: candidate, reason });
184
+ else {
185
+ report.removed.push(candidate);
186
+ // A git removal took the admin record with it; anything else that cleared the directory left the record behind.
187
+ if (how === 'rmdir' && record.repo)
188
+ queuePrune(record.repo);
189
+ }
190
+ }
191
+ // A directory deleted by hand never shows up in the listing above, so the task records are read as well: a finished
192
+ // task naming a worktree that is no longer on disk is the other way a repository ends up holding a dead entry.
193
+ let records;
194
+ try {
195
+ records = await readdir(path.join(stateDirectory, 'tasks'));
196
+ }
197
+ catch {
198
+ return report;
199
+ }
200
+ for (const file of records) {
201
+ if (!TASK_RECORD.test(file))
202
+ continue;
203
+ let record;
204
+ try {
205
+ record = await readRecord(file.slice(0, -'.json'.length));
206
+ }
207
+ catch {
208
+ continue;
209
+ }
210
+ if (record.status === 'queued' || record.status === 'running')
211
+ continue;
212
+ if (!record.repo || !record.worktree || await exists(record.worktree))
213
+ continue;
214
+ queuePrune(record.repo);
215
+ }
216
+ return report;
217
+ }
218
+ /** Worktree paths the repository still has a record for, including the ones whose directory is already gone. */
219
+ async function listedWorktrees(repo) {
220
+ const text = await git(repo, ['worktree', 'list', '--porcelain']);
221
+ return text.split('\n').filter(line => line.startsWith('worktree ')).map(line => line.slice('worktree '.length).trim()).filter(Boolean);
222
+ }
223
+ /**
224
+ * Clears the `<repo>/.git/worktrees/<name>` entries left behind when a worktree directory went away without git.
225
+ * Git only prunes records whose directory is already missing, so this is called for the repositories whose
226
+ * directories the sweep itself deleted — a worktree someone is still using is never in scope. `--expire=now` is
227
+ * required because the default grace is three months, which would leave a record the bridge just orphaned in place.
228
+ * Any other missing-directory record in that repository goes with it; that costs an admin entry, not files, and a
229
+ * checkout that is merely unmounted is restored with `git worktree repair`.
230
+ */
231
+ export async function pruneWorktrees(repo) {
232
+ try {
233
+ const missing = [];
234
+ for (const target of await listedWorktrees(repo)) {
235
+ if (!await exists(target))
236
+ missing.push(target);
237
+ }
238
+ if (!missing.length)
239
+ return { pruned: 0, reason: '' };
240
+ // The count comes from the record list, not from what `prune -v` prints: that goes to stderr, and a report of
241
+ // "0 pruned" after a record was in fact cleared would be the worse kind of wrong.
242
+ await git(repo, ['worktree', 'prune', '-v', '--expire=now']);
243
+ const remaining = new Set(await listedWorktrees(repo));
244
+ return { pruned: missing.filter(target => !remaining.has(target)).length, reason: '' };
245
+ }
246
+ catch (error) {
247
+ return { pruned: 0, reason: error instanceof Error ? error.message : 'git worktree prune failed' };
248
+ }
59
249
  }
60
250
  function normalizeOwn(own) {
61
251
  return path.posix.normalize(own.replace(/\\/g, '/')).replace(/^\.\//, '').replace(/\/+$/, '');
@@ -21,9 +21,9 @@ export function parseModels(text) {
21
21
  return models;
22
22
  }
23
23
  /** Runs `agy models`; rejects when the command fails, times out or prints nothing recognizable. */
24
- export function listModels(executable, cwd, extraEnvPassthrough = []) {
24
+ export function listModels(executable, cwd, extraEnvPassthrough = [], prefixArgs = []) {
25
25
  return new Promise((resolve, reject) => {
26
- const child = startProcess(executable, ['models'], cwd, { extraEnvPassthrough });
26
+ const child = startProcess(executable, [...prefixArgs, 'models'], cwd, { extraEnvPassthrough });
27
27
  let text = '';
28
28
  let errors = '';
29
29
  let timedOut = false;
@@ -3,14 +3,16 @@ import { helperDirectory, paths } from './paths.js';
3
3
  import { inspectCli, resolveCommand, startProcess, VERIFIED_AGY_VERSIONS } from './antigravity/process.js';
4
4
  import { createAgentRegistry, publicAgents } from './agents/registry.js';
5
5
  import { TaskManager } from './tasks/manager.js';
6
+ import { sweepStaleWorktrees, pruneWorktrees } from './isolation/worktree.js';
7
+ import { startTreeWatch } from './isolation/orphans.js';
6
8
  import { recoverExecutionLock } from './tasks/execution-lock.js';
7
9
  export async function createRuntime() {
8
10
  const config = await loadConfig();
9
- let cli = { version: 'unavailable', executable: '', supported: false, warnings: [] };
11
+ let cli = { version: 'unavailable', executable: '', prefixArgs: [], supported: false, warnings: [] };
10
12
  let antigravity = { version: 'unavailable', executable: '', supported: false, warnings: [] };
11
13
  try {
12
14
  const command = await resolveCommand(config.executable);
13
- cli = await inspectCli(command.executable, helperDirectory(), (executable, args, cwd) => startProcess(executable, args, cwd, { extraEnvPassthrough: config.extra_env_passthrough }), config.prompt_transport === 'stdin');
15
+ cli = await inspectCli(command, helperDirectory(), (executable, args, cwd) => startProcess(executable, args, cwd, { extraEnvPassthrough: config.extra_env_passthrough }), config.prompt_transport === 'stdin');
14
16
  antigravity = { ...cli, executable: cli.executable || command.source };
15
17
  }
16
18
  catch {
@@ -27,11 +29,30 @@ export async function createRuntime() {
27
29
  console.error(`[bridge] Agent ${agent.adapter.id}: ${state}`);
28
30
  }
29
31
  const maxParallelWorktrees = config.max_parallel_worktrees ?? 2;
32
+ const treeWatch = config.tree_watch ?? {};
33
+ const treeWatchEnabled = treeWatch.enabled !== false;
34
+ if (!treeWatchEnabled)
35
+ console.error('[bridge] WARNING: tree_watch is off; a process tree the agent leaves behind will not be released');
30
36
  const manager = new TaskManager(config, {
31
- executable: antigravity.executable, supported: antigravity.supported, stateDirectory: paths.stateDirectory,
32
- agents: registry.agents, defaultAgent: registry.defaultAgent
37
+ executable: antigravity.executable, prefixArgs: antigravity.prefixArgs, supported: antigravity.supported, stateDirectory: paths.stateDirectory,
38
+ agents: registry.agents, defaultAgent: registry.defaultAgent,
39
+ // Without the watch the release can only name the processes that still hang below the agent at the moment it
40
+ // exits, which is exactly the case that left the worktree pinned in a live run.
41
+ watchTree: treeWatchEnabled ? pid => startTreeWatch(pid, { delays: treeWatch.delays_ms }) : undefined
33
42
  });
34
43
  await recoverExecutionLock(paths.stateDirectory);
44
+ const sweep = await sweepStaleWorktrees(paths.stateDirectory);
45
+ for (const removed of sweep.removed)
46
+ console.error(`[bridge] Removed a stale empty worktree directory: ${removed}`);
47
+ for (const kept of sweep.retained)
48
+ console.error(`[bridge] Kept worktree directory ${kept.path}: ${kept.reason}`);
49
+ for (const repo of sweep.prunable) {
50
+ const prune = await pruneWorktrees(repo);
51
+ if (prune.pruned)
52
+ console.error(`[bridge] Pruned ${prune.pruned} stale worktree record(s) in ${repo}`);
53
+ else if (prune.reason)
54
+ console.error(`[bridge] WARNING: stale worktree records in ${repo} could not be pruned: ${prune.reason}`);
55
+ }
35
56
  const restored = await manager.restore();
36
57
  if (restored)
37
58
  console.error(`[bridge] Restored ${restored} finished task record(s) from .bridge-state/tasks`);
@@ -64,7 +85,8 @@ export async function createRuntime() {
64
85
  'Existing agent permissions, hooks, MCP and credential access remain governed by each CLI configuration.',
65
86
  'Antigravity permission flags are off unless the bridge owner raises the level in the web UI (a client cannot change it). At the default level, actions that need approval are denied (see denied_actions).',
66
87
  'No permission bypass flags. Dangerous actions require separate interactive approval.',
67
- 'Windows uses taskkill on the live owned child tree; abrupt server termination may orphan processes.',
88
+ `After an agent process exits, the bridge releases the process trees it left behind (Windows: taskkill on every subtree still hanging below the agent or below a process recorded while it ran, after re-checking that each pid still names the same process; POSIX: the exited process group). The outcome is published as the task's cleanup field, and a graceful shutdown waits for it. A bridge killed abruptly, or tree_watch disabled in config, can still orphan processes.`,
89
+ 'Worktree directories of finished tasks are kept for review. Only empty leftovers are removed at startup, a directory a live process still holds is reported instead of forced, and a repository left holding a record whose directory is gone is pruned.',
68
90
  'Redaction recognizes known patterns only. Never include credentials in task data.'
69
91
  ]
70
92
  };