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.
- package/README.md +71 -423
- package/config/workspaces.example.json +4 -0
- package/dist/src/agents/antigravity.js +1 -1
- package/dist/src/agents/registry.js +1 -1
- package/dist/src/antigravity/process.js +5 -3
- package/dist/src/config.js +9 -0
- package/dist/src/isolation/orphans.js +38 -2
- package/dist/src/isolation/worktree.js +94 -11
- package/dist/src/models.js +2 -2
- package/dist/src/runtime.js +18 -7
- package/dist/src/tasks/manager.js +20 -3
- package/docs/VERIFICATION.md +2 -0
- package/package.json +2 -1
- package/ui/app.css +10 -2
- package/ui/app.js +105 -44
- package/docs/HANDOFF.md +0 -152
|
@@ -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
|
-
|
|
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
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
138
|
+
let record;
|
|
124
139
|
try {
|
|
125
|
-
|
|
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
|
}
|
package/dist/src/models.js
CHANGED
|
@@ -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;
|
package/dist/src/runtime.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
78
|
-
'Worktree directories of finished tasks are kept for review. Only empty leftovers are removed at startup,
|
|
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
|
-
|
|
495
|
-
|
|
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; }
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -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.
|
|
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(
|
|
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); }
|