agy-mcp-bridge 0.1.20 → 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. */
@@ -65,6 +65,18 @@ function parseTable(text) {
65
65
  export async function listProcesses() {
66
66
  return parseTable(await powershell(tableQuery()));
67
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
+ }
68
80
  /** Every row below `root`, nearest first, without the root itself. */
69
81
  export function descendants(table, root) {
70
82
  const childrenOf = new Map();
@@ -126,9 +138,14 @@ export function startTreeWatch(pid, options = {}) {
126
138
  };
127
139
  wait(0);
128
140
  return {
129
- stop() {
141
+ async stop() {
130
142
  stopped = true;
131
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 */ }
132
149
  return [...observed.values()];
133
150
  }
134
151
  };
@@ -220,7 +237,26 @@ export async function reapOwnedTree(pid, options) {
220
237
  const reaped = [];
221
238
  const skipped = [];
222
239
  const kill = options.kill ?? killTree;
223
- for (const child of tops) {
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) {
224
260
  const info = observed.get(child.pid);
225
261
  if (info && recycled(info, child)) {
226
262
  skipped.push({ pid: child.pid, name: child.name, guard: 'pid was recycled for an unrelated process after the task ended' });
@@ -88,6 +88,7 @@ const exists = async (target) => {
88
88
  };
89
89
  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
90
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$/;
91
92
  /**
92
93
  * Drops worktree directories that a previous run could not remove because a process the bridge no longer owned
93
94
  * was holding them open. Only genuinely empty directories go: anything with content is a diff someone may still
@@ -95,7 +96,7 @@ const TASK_ID = /^[0-9a-f]{8}-[0-9a-f-]+$/;
95
96
  */
96
97
  export async function sweepStaleWorktrees(stateDirectory, options = {}) {
97
98
  const directory = path.join(stateDirectory, 'worktrees');
98
- const report = { scanned: 0, removed: [], retained: [] };
99
+ const report = { scanned: 0, removed: [], retained: [], prunable: [] };
99
100
  let names;
100
101
  try {
101
102
  names = await readdir(directory);
@@ -103,16 +104,30 @@ export async function sweepStaleWorktrees(stateDirectory, options = {}) {
103
104
  catch {
104
105
  return report;
105
106
  }
106
- const readStatus = options.readStatus ?? (async (taskId) => {
107
+ const readRecord = options.readRecord ?? (async (taskId) => {
107
108
  try {
108
109
  const parsed = JSON.parse(await readFile(path.join(stateDirectory, 'tasks', `${taskId}.json`), 'utf8'));
109
- return parsed.task?.status ?? parsed.status ?? null;
110
+ return { status: parsed.task?.status ?? parsed.status ?? null, repo: parsed.task?.worktree_toplevel ?? null, worktree: parsed.task?.worktree ?? null };
110
111
  }
111
112
  catch {
112
- return null;
113
+ return { status: null, repo: null, worktree: null };
113
114
  }
114
115
  });
115
- const remove = options.remove ?? ((target) => rmdir(target));
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
+ });
116
131
  for (const name of names) {
117
132
  const candidate = path.join(directory, name);
118
133
  report.scanned += 1;
@@ -120,16 +135,16 @@ export async function sweepStaleWorktrees(stateDirectory, options = {}) {
120
135
  report.retained.push({ path: candidate, reason: 'not a task worktree directory' });
121
136
  continue;
122
137
  }
123
- let status;
138
+ let record;
124
139
  try {
125
- status = await readStatus(name);
140
+ record = await readRecord(name);
126
141
  }
127
142
  catch {
128
143
  report.retained.push({ path: candidate, reason: 'its task record could not be read' });
129
144
  continue;
130
145
  }
131
- if (status === 'queued' || status === 'running') {
132
- report.retained.push({ path: candidate, reason: `task is ${status}` });
146
+ if (record.status === 'queued' || record.status === 'running') {
147
+ report.retained.push({ path: candidate, reason: `task is ${record.status}` });
133
148
  continue;
134
149
  }
135
150
  let entries;
@@ -137,6 +152,12 @@ export async function sweepStaleWorktrees(stateDirectory, options = {}) {
137
152
  entries = await readdir(candidate);
138
153
  }
139
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
+ }
140
161
  report.retained.push({ path: candidate, reason: error instanceof Error ? error.message : 'directory could not be read' });
141
162
  continue;
142
163
  }
@@ -145,11 +166,12 @@ export async function sweepStaleWorktrees(stateDirectory, options = {}) {
145
166
  continue;
146
167
  }
147
168
  let reason = '';
169
+ let how = 'rmdir';
148
170
  for (const delay of options.retryDelayMs ?? [0, 200, 600]) {
149
171
  if (delay)
150
172
  await sleep(delay);
151
173
  try {
152
- await remove(candidate);
174
+ how = await remove(candidate, record.repo);
153
175
  reason = '';
154
176
  break;
155
177
  }
@@ -159,11 +181,72 @@ export async function sweepStaleWorktrees(stateDirectory, options = {}) {
159
181
  }
160
182
  if (reason)
161
183
  report.retained.push({ path: candidate, reason });
162
- else
184
+ else {
163
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);
164
215
  }
165
216
  return report;
166
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
+ }
249
+ }
167
250
  function normalizeOwn(own) {
168
251
  return path.posix.normalize(own.replace(/\\/g, '/')).replace(/^\.\//, '').replace(/\/+$/, '');
169
252
  }
@@ -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,16 +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 } from './isolation/worktree.js';
6
+ import { sweepStaleWorktrees, pruneWorktrees } from './isolation/worktree.js';
7
7
  import { startTreeWatch } from './isolation/orphans.js';
8
8
  import { recoverExecutionLock } from './tasks/execution-lock.js';
9
9
  export async function createRuntime() {
10
10
  const config = await loadConfig();
11
- let cli = { version: 'unavailable', executable: '', supported: false, warnings: [] };
11
+ let cli = { version: 'unavailable', executable: '', prefixArgs: [], supported: false, warnings: [] };
12
12
  let antigravity = { version: 'unavailable', executable: '', supported: false, warnings: [] };
13
13
  try {
14
14
  const command = await resolveCommand(config.executable);
15
- 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');
16
16
  antigravity = { ...cli, executable: cli.executable || command.source };
17
17
  }
18
18
  catch {
@@ -29,12 +29,16 @@ export async function createRuntime() {
29
29
  console.error(`[bridge] Agent ${agent.adapter.id}: ${state}`);
30
30
  }
31
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');
32
36
  const manager = new TaskManager(config, {
33
- executable: antigravity.executable, supported: antigravity.supported, stateDirectory: paths.stateDirectory,
37
+ executable: antigravity.executable, prefixArgs: antigravity.prefixArgs, supported: antigravity.supported, stateDirectory: paths.stateDirectory,
34
38
  agents: registry.agents, defaultAgent: registry.defaultAgent,
35
39
  // Without the watch the release can only name the processes that still hang below the agent at the moment it
36
40
  // exits, which is exactly the case that left the worktree pinned in a live run.
37
- watchTree: startTreeWatch
41
+ watchTree: treeWatchEnabled ? pid => startTreeWatch(pid, { delays: treeWatch.delays_ms }) : undefined
38
42
  });
39
43
  await recoverExecutionLock(paths.stateDirectory);
40
44
  const sweep = await sweepStaleWorktrees(paths.stateDirectory);
@@ -42,6 +46,13 @@ export async function createRuntime() {
42
46
  console.error(`[bridge] Removed a stale empty worktree directory: ${removed}`);
43
47
  for (const kept of sweep.retained)
44
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
+ }
45
56
  const restored = await manager.restore();
46
57
  if (restored)
47
58
  console.error(`[bridge] Restored ${restored} finished task record(s) from .bridge-state/tasks`);
@@ -74,8 +85,8 @@ export async function createRuntime() {
74
85
  'Existing agent permissions, hooks, MCP and credential access remain governed by each CLI configuration.',
75
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).',
76
87
  'No permission bypass flags. Dangerous actions require separate interactive approval.',
77
- '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; POSIX: the exited process group). A bridge killed abruptly can still orphan processes.',
78
- 'Worktree directories of finished tasks are kept for review. Only empty leftovers are removed at startup, and a directory a live process still holds is reported instead of forced.',
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.',
79
90
  'Redaction recognizes known patterns only. Never include credentials in task data.'
80
91
  ]
81
92
  };
@@ -32,6 +32,8 @@ export class TaskManager {
32
32
  queue = [];
33
33
  admission = Promise.resolve();
34
34
  closing = false;
35
+ /** Tree releases still running after their task published its status; shutdown waits for these. */
36
+ pendingRelease = new Set();
35
37
  agents;
36
38
  defaultAgent;
37
39
  constructor(config, runtime) {
@@ -216,6 +218,7 @@ export class TaskManager {
216
218
  execWorkspace = worktree.workspace;
217
219
  task.worktree = worktree.root;
218
220
  task.branch = worktree.branch;
221
+ task.worktree_toplevel = worktree.toplevel;
219
222
  // Keep the regenerated prompt equal to the one the agent received (the worktree workspace, not the original one).
220
223
  if (entry.request) {
221
224
  entry.request.worktree = worktree.workspace;
@@ -491,8 +494,9 @@ export class TaskManager {
491
494
  // worktree, and on Windows they outlive it and pin the directory. Releasing them needs a process query, so
492
495
  // it runs after the status is visible: no client waits on it, and the record is patched when it finishes.
493
496
  const held = { request: entry.request ?? null, stats: entry.stats ?? null };
494
- const observed = watch.stop();
495
- void this.releaseOwnedTree(child.pid ?? null, started, observed).then(async (cleanup) => {
497
+ // The last watch reading is part of the release, not of the publish chain: a wedged process query must not
498
+ // hold the lane that the next queued task is waiting on.
499
+ const release = Promise.resolve().then(() => watch.stop()).then(observed => this.releaseOwnedTree(child.pid ?? null, started, observed)).then(async (cleanup) => {
496
500
  task.cleanup = cleanup;
497
501
  // A report that reaped nothing and failed for no reason says nothing worth a second write: this one runs
498
502
  // detached from the status publish, so a reader of the tasks directory can catch its swap mid-flight.
@@ -503,6 +507,10 @@ export class TaskManager {
503
507
  if (this.entries.has(task.task_id))
504
508
  await this.persist(task, held);
505
509
  }).catch(() => { });
510
+ // Shutdown waits for whatever is still in flight here, so stopping the bridge right after the last agent
511
+ // exits cannot drop the only record of which processes were left running.
512
+ this.pendingRelease.add(release);
513
+ void release.finally(() => { this.pendingRelease.delete(release); });
506
514
  await appendUsage(this.runtime.stateDirectory, {
507
515
  at: task.ended_at ?? new Date().toISOString(), status: task.status, model: entry.request?.model ?? null, effort: entry.request?.effort ?? null,
508
516
  permission: entry.request?.permission ?? null, duration_ms: task.duration_ms, agy_duration_seconds: entry.stats?.agy_duration_seconds ?? null,
@@ -601,7 +609,7 @@ export class TaskManager {
601
609
  }
602
610
  /** Models offered by the installed Antigravity CLI (read on demand, never at startup). */
603
611
  availableModels() {
604
- return this.runtime.models ? this.runtime.models() : listModels(this.runtime.executable, helperDirectory(), this.config.extra_env_passthrough);
612
+ return this.runtime.models ? this.runtime.models() : listModels(this.runtime.executable, helperDirectory(), this.config.extra_env_passthrough, this.runtime.prefixArgs ?? []);
605
613
  }
606
614
  currentModel() { return readModel(this.runtime.stateDirectory); }
607
615
  /** Full record for the web UI: the request, the exact prompt shape sent to the CLI (redacted), and the task with logs. */
@@ -727,6 +735,15 @@ export class TaskManager {
727
735
  async shutdown() {
728
736
  this.stopAccepting();
729
737
  await Promise.all([...this.entries.keys()].map(id => this.cancel(id)));
738
+ const pending = [...this.pendingRelease];
739
+ if (!pending.length)
740
+ return;
741
+ // Bounded by the same budget the release answers with, plus the record write: a wedged process query must turn
742
+ // into a lost report, never into a shutdown that hangs.
743
+ await Promise.race([
744
+ Promise.all(pending),
745
+ new Promise(resolve => { const timer = setTimeout(resolve, REAP_BUDGET_MS + 2000); timer.unref?.(); })
746
+ ]);
730
747
  }
731
748
  stopAccepting() { this.closing = true; }
732
749
  get stateDirectory() { return this.runtime.stateDirectory; }
@@ -52,6 +52,8 @@ Bridge chỉ dùng --version/--help và print/json/print-timeout/disable-slash-c
52
52
  5. Smoke chỉ phản hồi và hủy process thật: `npm run test:live` đạt ngày khảo sát. MCP client nhận `BRIDGE_SMOKE_OK` với completed/exit 0 (khoảng 13.5 giây); sentinel không đổi; task tiếp theo được hủy, status canceled/process_running false. Bằng chứng ở `test-results/live-report.json`. Đọc trường `success`, task.status, response_verified, workspace_unchanged và cancellation.status để biết lần chạy mới nhất.
53
53
  6. Dọn cây tiến trình và sweep worktree (2026-10-26, `agy` 1.4.7, scratch trong `%TEMP%`, repo git giả): bridge khởi động với một thư mục worktree rỗng còn sót và một thư mục còn file → log báo xóa đúng cái rỗng, giữ đúng cái có file. Task worktree thật trả `completed`, `scope.checked=true`, `changed=1`, `out_of_scope=[]`, `cleanup={checked:true, reaped:[30768,24304], skipped:[], tracked:14}` và log `[bridge] Task tree release: 2 process tree(s) reaped, 0 left running`. Probe đọc cwd của mọi process (PEB) trả `HITS 0`, `rm -rf` toàn bộ scratch thành công không cần force, `note.txt` trong worktree là `REAP_OK`, workspace gốc chỉ còn `[.git, a.txt]`, shutdown confirm trả 202 và server exit 0. Lần chạy **trước** bản sửa cho `HITS 3` với đúng chuỗi `agy → cmd (chết) → npx open-figma-mcp → cmd → node`, `cleanup.reaped` chỉ có 1 PID — đó là bằng chứng vì sao phải ghi cây tiến trình trong lúc task chạy. Bằng chứng: `test-results/reap-report.json` (gitignored), cách chạy lại ở `docs/HANDOFF.md`.
54
54
 
55
+ 7. Chạy lại đúng kịch bản trên với bản đã bịt lỗ hổng đợt 2 (2026-10-27, `agy` 1.4.7, scratch `%TEMP%`): task worktree `completed`, `scope={checked:true, changed:1, out_of_scope:[], truncated:false}`, `cleanup={checked:true, reaped:[28256,29124,23416], skipped:[], tracked:15}`, log `3 process tree(s) reaped, 0 left running`, probe cwd `HITS 0`, `note.txt` = `REAP_OK`, workspace gốc `[.git, a.txt]`, home + repo scratch xóa sạch, shutdown 202 và exit 0. `skipped` rỗng nghĩa là phép đọc-lại-pid trước khi giết không đổi quyết định trên đường thường; `tracked: 15` so với 3 gốc bị giết xác nhận phần ghi cây lúc chạy vẫn đang làm việc. Một bridge **riêng** (script `ui-cleanup-check.mjs`, task `2f8a65f8`, `cleanup={reaped:[35600,19716], tracked:10}`) được mở bằng Chrome DevTools thật: trang chi tiết task hiển thị đúng dòng `Tiến trình agent để lại: đã giải phóng 2 cây tiến trình (35600, 19716) · đã ghi danh 10 tiến trình trong lúc agent chạy`, nằm dưới dòng scope và không phải kiểu cảnh báo vì `skipped` rỗng; shutdown lịch sự trả 202, pid biến mất, scratch xóa được ngay. Unit: Windows `npm test` **220 test / 214 pass / 0 fail / 6 skipped** (234,1 s) và Linux (WSL, Node 20.20.2) **220 / 219 / 0 fail / 1 skipped** (195,0 s) — Linux là nền để sáu test nhóm POSIX signal chạy thật, trong đó vòng `SIGTERM → 8 s → SIGKILL` với survivor thật chưa từng chạy trước đây. Mỗi fix kèm một phép thử ngược: tháo `verify` → test pid-cấp-lại đỏ; tháo `Promise.race` trong `shutdown()` → test verdict-khi-tắt đỏ; tháo nhịp cuối của `stop()` → test watch đỏ.
56
+
55
57
  Unit/integration dùng mock nằm ở `tests/bridge.test.ts`, được tách khỏi script live. Bao gồm validation, boundary/traversal/junction, lock, lifecycle, retention, timeout, cancel cây process và giữ process khác, exit 7, JSON lỗi, partial SUCCESS timeout, permission denial, output cap, redaction, unsupported/spawn failure và protocol stdout sạch.
56
58
 
57
59
  ## Giới hạn đã xác minh và phần chưa xác minh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-mcp-bridge",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Local loopback HTTP MCP bridge that delegates bounded coding tasks to multiple agent CLIs (Antigravity, Codex) with optional parallel git worktrees",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -33,6 +33,7 @@
33
33
  "ui/",
34
34
  "skills/",
35
35
  "docs/",
36
+ "!docs/HANDOFF.md",
36
37
  "config/workspaces.example.json",
37
38
  "README.md",
38
39
  "!**/*.map"
package/ui/app.css CHANGED
@@ -59,7 +59,7 @@ button { cursor: pointer; }
59
59
  .page-head p { margin: 2px 0 0; color: var(--muted); }
60
60
 
61
61
  /* Stats */
62
- .stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; }
62
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; margin-bottom: 20px; }
63
63
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
64
64
  .stat { padding: 16px 18px; display: grid; gap: 4px; }
65
65
  .stat .label { color: var(--muted); font-size: 12.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
@@ -137,6 +137,14 @@ details > summary::-webkit-details-marker { display: none; }
137
137
  details > summary::after { content: "+"; color: var(--muted); font-weight: 400; font-size: 18px; line-height: 1; }
138
138
  details[open] > summary::after { content: "–"; }
139
139
  details .body { padding: 0 14px 14px; display: grid; gap: 10px; }
140
+ /* Secondary text: quiet until opened, so the decisions on screen stay the loudest thing. */
141
+ details.more { background: var(--surface-2); }
142
+ details.more > summary { padding: 7px 12px; font-size: 12.5px; font-weight: 650; color: var(--muted); }
143
+ details.more:hover > summary { color: var(--text); }
144
+ details.more[open] > summary { border-bottom: 1px solid var(--border); }
145
+ details.more .body { padding: 10px 12px 12px; }
146
+ .meta-line { font-size: 12.5px; color: var(--muted); overflow-wrap: anywhere; }
147
+ .detail-head .meta-line { margin-top: 2px; }
140
148
  .change-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
141
149
  .change-list { margin: 0; padding: 0; list-style: none; display: grid; max-height: 360px; overflow: auto; border: 1px solid var(--border); border-radius: 10px; }
142
150
  .change-row { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 7px 12px; border-top: 1px solid var(--border); font-size: 13px; }
@@ -198,7 +206,7 @@ pre.log { margin: 0; padding: 12px; border-radius: 10px; background: #0b0e14; co
198
206
  .progress { display: grid; gap: 6px; margin: 4px 0 12px; }
199
207
  .bar { height: 6px; border-radius: 999px; background: var(--surface-2); overflow: hidden; border: 1px solid var(--border); }
200
208
  .bar > span { display: block; height: 100%; width: 0; background: var(--accent); transition: width .9s linear; }
201
- .perm-list { display: grid; gap: 8px; margin: 12px 0; }
209
+ .perm-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin: 12px 0; }
202
210
  .perm-row { display: flex; gap: 12px; align-items: flex-start; padding: 12px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; }
203
211
  .perm-row.on { border-color: var(--accent); background: var(--surface-2); }
204
212
  .perm-row.danger.on { border-color: var(--bad); }