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.
- package/README.md +71 -421
- 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 +281 -0
- package/dist/src/isolation/worktree.js +195 -5
- package/dist/src/models.js +2 -2
- package/dist/src/runtime.js +27 -5
- package/dist/src/tasks/manager.js +91 -8
- package/docs/HARDENING.md +13 -1
- package/docs/VERIFICATION.md +6 -2
- package/package.json +2 -1
- package/ui/app.css +10 -2
- package/ui/app.js +105 -44
|
@@ -10,6 +10,7 @@ import { delegationState } from '../delegation.js';
|
|
|
10
10
|
import { startProcess, terminateOwned } from '../antigravity/process.js';
|
|
11
11
|
import { legacyAntigravityAgent } from '../agents/antigravity.js';
|
|
12
12
|
import { createWorktree, inspectScope, removeWorktree } from '../isolation/worktree.js';
|
|
13
|
+
import { reapOwnedTree } from '../isolation/orphans.js';
|
|
13
14
|
import { appendUsage, totalTokens } from '../usage.js';
|
|
14
15
|
import { MAX_RECENT_STEPS, rawStep, readProgress, summarizeStep } from '../progress.js';
|
|
15
16
|
import { helperDirectory } from '../paths.js';
|
|
@@ -20,6 +21,8 @@ import { lowerLevel, permissionFlags, permissionState } from '../permissions.js'
|
|
|
20
21
|
import { listModels, readModel } from '../models.js';
|
|
21
22
|
import { pidAlive, recoverExecutionLock } from './execution-lock.js';
|
|
22
23
|
const TASK_FILE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/;
|
|
24
|
+
/** A finished task publishes its status as soon as this budget runs out, even if the process query is wedged. */
|
|
25
|
+
const REAP_BUDGET_MS = 6000;
|
|
23
26
|
export class TaskManager {
|
|
24
27
|
config;
|
|
25
28
|
runtime;
|
|
@@ -29,6 +32,8 @@ export class TaskManager {
|
|
|
29
32
|
queue = [];
|
|
30
33
|
admission = Promise.resolve();
|
|
31
34
|
closing = false;
|
|
35
|
+
/** Tree releases still running after their task published its status; shutdown waits for these. */
|
|
36
|
+
pendingRelease = new Set();
|
|
32
37
|
agents;
|
|
33
38
|
defaultAgent;
|
|
34
39
|
constructor(config, runtime) {
|
|
@@ -213,6 +218,7 @@ export class TaskManager {
|
|
|
213
218
|
execWorkspace = worktree.workspace;
|
|
214
219
|
task.worktree = worktree.root;
|
|
215
220
|
task.branch = worktree.branch;
|
|
221
|
+
task.worktree_toplevel = worktree.toplevel;
|
|
216
222
|
// Keep the regenerated prompt equal to the one the agent received (the worktree workspace, not the original one).
|
|
217
223
|
if (entry.request) {
|
|
218
224
|
entry.request.worktree = worktree.workspace;
|
|
@@ -285,6 +291,10 @@ export class TaskManager {
|
|
|
285
291
|
task.pid = child.pid ?? null;
|
|
286
292
|
task.process_running = Boolean(child.pid);
|
|
287
293
|
const started = Date.now();
|
|
294
|
+
// Windows keeps a exited process's children addressed by their parent's pid, and that parent is often a shell
|
|
295
|
+
// that dies before the agent does, so the tree has to be recorded while the agent runs (500–10000 ms polls). The runtime owns the
|
|
296
|
+
// watcher: a task manager built without one releases only what a final process query can still attribute to it.
|
|
297
|
+
const watch = this.runtime.watchTree?.(child.pid ?? 0) ?? { stop: () => [] };
|
|
288
298
|
const buffers = { stdout: [], stderr: [] };
|
|
289
299
|
const lengths = { stdout: 0, stderr: 0 };
|
|
290
300
|
const dirty = { stdout: false, stderr: false };
|
|
@@ -480,6 +490,27 @@ export class TaskManager {
|
|
|
480
490
|
// "completed" while the owns-scope check on the worktree is still outstanding.
|
|
481
491
|
task.status = finalStatus;
|
|
482
492
|
await this.persist(task);
|
|
493
|
+
// The agent launches its own children (MCP servers, shells, tool helpers) whose working directory is this
|
|
494
|
+
// worktree, and on Windows they outlive it and pin the directory. Releasing them needs a process query, so
|
|
495
|
+
// it runs after the status is visible: no client waits on it, and the record is patched when it finishes.
|
|
496
|
+
const held = { request: entry.request ?? null, stats: entry.stats ?? null };
|
|
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) => {
|
|
500
|
+
task.cleanup = cleanup;
|
|
501
|
+
// A report that reaped nothing and failed for no reason says nothing worth a second write: this one runs
|
|
502
|
+
// detached from the status publish, so a reader of the tasks directory can catch its swap mid-flight.
|
|
503
|
+
if (!cleanup.reaped.length && !cleanup.skipped.length && !cleanup.reason)
|
|
504
|
+
return;
|
|
505
|
+
// Retention can evict this task while the query is still running, and writing then would bring the record
|
|
506
|
+
// back from the dead; only a task the manager still keeps has a record to patch.
|
|
507
|
+
if (this.entries.has(task.task_id))
|
|
508
|
+
await this.persist(task, held);
|
|
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); });
|
|
483
514
|
await appendUsage(this.runtime.stateDirectory, {
|
|
484
515
|
at: task.ended_at ?? new Date().toISOString(), status: task.status, model: entry.request?.model ?? null, effort: entry.request?.effort ?? null,
|
|
485
516
|
permission: entry.request?.permission ?? null, duration_ms: task.duration_ms, agy_duration_seconds: entry.stats?.agy_duration_seconds ?? null,
|
|
@@ -493,11 +524,34 @@ export class TaskManager {
|
|
|
493
524
|
catch (error) {
|
|
494
525
|
if (ownsLock)
|
|
495
526
|
await rmdir(lock).catch(() => { });
|
|
496
|
-
if (worktree && !entry.child)
|
|
497
|
-
await removeWorktree(worktree)
|
|
527
|
+
if (worktree && !entry.child) {
|
|
528
|
+
const removal = await removeWorktree(worktree);
|
|
529
|
+
if (!removal.removed)
|
|
530
|
+
console.error(`[bridge] Could not remove the worktree of a launch that never started: ${removal.reason}`);
|
|
531
|
+
}
|
|
498
532
|
throw error;
|
|
499
533
|
}
|
|
500
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* Releases the process tree an agent CLI left behind after the owned process exited, using the descendants that
|
|
537
|
+
* were recorded while it ran. Bounded by REAP_BUDGET_MS: a wedged process query must never keep a finished task
|
|
538
|
+
* from publishing its status.
|
|
539
|
+
*/
|
|
540
|
+
async releaseOwnedTree(pid, startedAtMs, observed) {
|
|
541
|
+
if (pid === null)
|
|
542
|
+
return { checked: false, reason: 'No process id was recorded for this task', reaped: [], skipped: [] };
|
|
543
|
+
const failure = (error) => ({ checked: false, reason: error instanceof Error ? redact(error.message) : 'Process tree release failed', reaped: [], skipped: [] });
|
|
544
|
+
const run = Promise.resolve().then(() => (this.runtime.reapTree ?? reapOwnedTree)(pid, { started_at_ms: startedAtMs, observed })).catch(failure);
|
|
545
|
+
const budget = new Promise(resolve => {
|
|
546
|
+
const timer = setTimeout(() => resolve({ checked: false, reason: `Process tree release did not finish within ${REAP_BUDGET_MS}ms`, reaped: [], skipped: [] }), REAP_BUDGET_MS);
|
|
547
|
+
timer.unref?.();
|
|
548
|
+
});
|
|
549
|
+
const report = await Promise.race([run, budget]);
|
|
550
|
+
if (report.reaped.length || report.skipped.length || report.reason) {
|
|
551
|
+
console.error(`[bridge] Task tree release: ${report.reaped.length} process tree(s) reaped, ${report.skipped.length} left running${report.reason ? `; ${report.reason}` : ''}`);
|
|
552
|
+
}
|
|
553
|
+
return report;
|
|
554
|
+
}
|
|
501
555
|
/** Hands free lanes to the oldest waiting tasks that fit. A task that cannot start is failed here rather than thrown at a caller that has gone. */
|
|
502
556
|
async startNext() {
|
|
503
557
|
while (!this.closing && this.queue.length) {
|
|
@@ -555,7 +609,7 @@ export class TaskManager {
|
|
|
555
609
|
}
|
|
556
610
|
/** Models offered by the installed Antigravity CLI (read on demand, never at startup). */
|
|
557
611
|
availableModels() {
|
|
558
|
-
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 ?? []);
|
|
559
613
|
}
|
|
560
614
|
currentModel() { return readModel(this.runtime.stateDirectory); }
|
|
561
615
|
/** Full record for the web UI: the request, the exact prompt shape sent to the CLI (redacted), and the task with logs. */
|
|
@@ -681,21 +735,50 @@ export class TaskManager {
|
|
|
681
735
|
async shutdown() {
|
|
682
736
|
this.stopAccepting();
|
|
683
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
|
+
]);
|
|
684
747
|
}
|
|
685
748
|
stopAccepting() { this.closing = true; }
|
|
686
749
|
get stateDirectory() { return this.runtime.stateDirectory; }
|
|
687
750
|
get tasksDirectory() { return path.join(this.runtime.stateDirectory, 'tasks'); }
|
|
688
751
|
// Best effort: a failed write must never fail or hide a task.
|
|
689
|
-
|
|
752
|
+
// `held` carries the request and stats for a write that happens after the entry was released, so patching the
|
|
753
|
+
// record afterwards cannot blank them.
|
|
754
|
+
async persist(task, held) {
|
|
690
755
|
if (!this.runtime.supported)
|
|
691
756
|
return;
|
|
757
|
+
const file = path.join(this.tasksDirectory, `${task.task_id}.json`);
|
|
692
758
|
try {
|
|
693
759
|
await mkdir(this.tasksDirectory, { recursive: true, mode: 0o700 });
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
760
|
+
const entry = this.entries.get(task.task_id);
|
|
761
|
+
const request = held ? held.request : entry?.request ?? null;
|
|
762
|
+
const stats = held ? held.stats : entry?.stats ?? null;
|
|
763
|
+
await writeFile(`${file}.tmp`, JSON.stringify({ task, request, stats }), { mode: 0o600 });
|
|
764
|
+
// Windows refuses to rename onto a record that another process holds open for reading, and both the web UI and
|
|
765
|
+
// a polling client do that. Retry the swap instead of dropping the update.
|
|
766
|
+
for (let attempt = 0;; attempt++) {
|
|
767
|
+
try {
|
|
768
|
+
await rename(`${file}.tmp`, file);
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
const code = error.code;
|
|
773
|
+
if (attempt >= 2 || (code !== 'EPERM' && code !== 'EACCES' && code !== 'EBUSY'))
|
|
774
|
+
throw error;
|
|
775
|
+
await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1)));
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
await unlink(`${file}.tmp`).catch(() => { }); // history is a convenience, not a correctness requirement
|
|
697
781
|
}
|
|
698
|
-
catch { /* history is a convenience, not a correctness requirement */ }
|
|
699
782
|
}
|
|
700
783
|
async forget(id) {
|
|
701
784
|
if (this.runtime.supported)
|
package/docs/HARDENING.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
1. Workspace bỏ qua **nội dung** `node_modules` và `.git` khi quét. Link ngoài hai vùng này chỉ được chấp nhận nếu realpath nằm trong workspace; link hỏng bị từ chối. Link/junction trong đường dẫn input vẫn bị từ chối. Đây là preflight, không phải OS sandbox; vùng bỏ qua và thay link sau preflight vẫn là giới hạn.
|
|
8
8
|
2. Task queued được validate lại trước launch. Chuyển sang running được ghi trước spawn để restore không kết luận sai rằng task chưa từng sửa file. Recheck trạng thái ngay trước spawn ngăn launch tiếp sau cancel/shutdown trong lúc chờ I/O.
|
|
9
|
-
3. POSIX SIGTERM cả nhóm, tối đa 8 giây rồi SIGKILL. Windows vẫn taskkill theo PID/cây thuộc server. Bridge watchdog chạy sau CLI timeout thêm 10 giây; cancellation có thể chờ thêm thời gian cleanup.
|
|
9
|
+
3. POSIX SIGTERM cả nhóm, tối đa 8 giây rồi SIGKILL. Windows vẫn taskkill theo PID/cây thuộc server khi hủy; khi agent tự thoát, bridge còn giải phóng các cây con mà agent để lại (mục "Dọn cây tiến trình agent" dưới đây). Bridge watchdog chạy sau CLI timeout thêm 10 giây; cancellation có thể chờ thêm thời gian cleanup.
|
|
10
10
|
4. Startup và acquisition tự thu hồi lock chỉ khi cả hai PID trong owner đều hợp lệ và không còn sống. Record thiếu/hỏng, PID còn sống, ownership đổi hoặc owner cũ hơn lock thì giữ nguyên. `agy-mcp-bridge unlock` dùng cùng logic và không kill process. PID reuse và server crash vẫn cần xử lý thận trọng; chưa có Windows Job Object.
|
|
11
11
|
5. Family 1.4.x phải có đủ cờ bắt buộc trong help. Patch 1.4.3 và 1.4.7 đã được xác minh thực thi AI thật; patch 1.4.x khác có cảnh báo log/environment; minor/major khác không chạy.
|
|
12
12
|
6. Môi trường child mặc định chỉ có PATH, HOME, USERPROFILE, APPDATA, LOCALAPPDATA, TEMP, TMP, TMPDIR, SystemRoot, ComSpec, LANG, LC_*, XDG_*, HTTP_PROXY, HTTPS_PROXY, NO_PROXY; tên không phân biệt hoa thường. Tên chứa TOKEN/SECRET/PASSWORD/API_KEY chỉ được truyền khi có trong `extra_env_passthrough`. Các thiết lập auth/tool phụ thuộc env khác có thể cần opt-in.
|
|
@@ -35,3 +35,15 @@ Chỉ thêm env khi cơ chế xác thực/tool của chính bạn cần nó. `"p
|
|
|
35
35
|
- `agy --help` xác nhận `--input-format stream-json` đọc NDJSON từ stdin và cần output stream-json. Đối chiếu [tài liệu Google chính thức](https://antigravity.google/docs/cli/headless/) về user/message.content, result envelope và EOF.
|
|
36
36
|
- Probe CLI thật dùng **control_request cố ý không hỗ trợ**, không gửi user prompt: exit 2, result ERROR, num_turns 0, tất cả usage counters 0. Xác minh parser/đường lỗi; thành công của AI qua stdin chỉ được test bằng executable giả trong đợt này. Không tuyên bố đã kiểm thử agent thật cho patch khác 1.4.3 và 1.4.7 hoặc transport mới.
|
|
37
37
|
- Không thay bind 127.0.0.1 hoặc Host/Origin; không mở LAN. Bridge cố ý không có xác thực — chỉ dùng trên máy do chủ máy kiểm soát.
|
|
38
|
+
|
|
39
|
+
## Dọn cây tiến trình agent (nhánh deepseek, 2026-10-26)
|
|
40
|
+
|
|
41
|
+
Hậu quả thực tế trên Windows: process con do `agy` tự khởi động (MCP server qua `cmd /c npx -y open-figma-mcp@latest`, chrome-devtools-mcp) lấy worktree làm thư mục làm việc và sống lâu hơn agent. Windows không cho xóa hoặc đổi tên một thư mục đang được process khác giữ làm cwd, nên worktree bị ghim: `git worktree remove`, `rm -rf`, `Remove-Item` đều fail dù thư mục trống; chính con đường này cũng chặn `npm install -g` trên cùng cây.
|
|
42
|
+
|
|
43
|
+
- `startTreeWatch` ghi danh toàn bộ cây tiến trình của agent **trong lúc task chạy** (chụp bảng tiến trình bằng `Get-CimInstance Win32_Process`, nhịp 500→10000 ms). Bắt buộc phải chụp trong lúc chạy vì mắt xích shell trung gian chết trước agent, và `ParentProcessId` của Windows chỉ là một con số: query "con của agent" lúc exit không còn thấy nhánh dưới shell đó.
|
|
44
|
+
- `reapOwnedTree` lúc agent đã thoát dùng bảng tiến trình mới nhất + bản ghi để giết **gốc** của từng nhánh còn sống bằng `taskkill /T /F` (`/T` phủ các con phía dưới); POSIX signal process group của child đã thoát (SIGTERM, tối đa `TERMINATION_GRACE_MS` rồi SIGKILL). Kết quả là `{checked, reaped, skipped[{pid,name,guard}], tracked}` trong field `cleanup` của task.
|
|
45
|
+
- Guard: PID hợp lệ, không phải pid của agent đã thoát, không phải chính bridge, không nằm trong denylist ảnh hệ thống (`reapGuard`), creation time nằm trong cửa sổ task (slack 5s), và PID đã bị tái sử dụng (khác tên hoặc khác thời điểm khởi tạo so với bản ghi) thì **không** giết và cũng không coi là cha hợp lệ.
|
|
46
|
+
- Giải phóng chạy **sau** khi trạng thái cuối đã công bố và bị chặn bởi `REAP_BUDGET_MS = 6000`; client không bao giờ chờ nó. `persist` được vá để giữ `request`/`stats` khi ghi bản vá sau này, và retry rename khi `EPERM/EACCES/EBUSY` vì Windows từ chối rename đè lên record mà UI hoặc client đang mở để đọc (đây là bug thật, do test mới lộ ra).
|
|
47
|
+
- `removeWorktree` trả `{removed, reason}` với ba lần thử `[0, 200, 600]` ms và không còn nuốt lỗi; chỉ được gọi cho launch chưa kịp spawn. `sweepStaleWorktrees` lúc khởi động xóa **chỉ** thư mục rỗng mang tên task-id, bỏ lại task `queued`/`running`, bỏ lại mọi thư mục còn file và in lý do.
|
|
48
|
+
- `git worktree prune` chưa từng được gọi; bản ghi admin trong `<toplevel>/.git/worktrees/*` vẫn còn sau khi xóa tay hoặc sau sweep (`git worktree list` sẽ báo prunable).
|
|
49
|
+
- Chưa có Windows Job Object: bridge bị giết đột ngột giữa lúc task chạy thì vẫn orphan tree và worktree bị ghim tới khi process giữ nó chết. `agy mcp disable …` (tắt MCP server nguồn gốc vấn đề) là thao tác toàn cục trên cấu hình cá nhân của người dùng nên **không** tự chạy. Chi tiết và việc còn lại: [docs/HANDOFF.md](HANDOFF.md).
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -23,7 +23,7 @@ Chính sách shutdown: ngừng nhận task mới, hủy task đang chạy, dọn
|
|
|
23
23
|
## Máy thực tế
|
|
24
24
|
|
|
25
25
|
- Windows, PowerShell; Node `v24.15.0`, npm `11.12.1`, Codex CLI `0.163.0`.
|
|
26
|
-
- `Get-Command agy`:
|
|
26
|
+
- `Get-Command agy`: `%LOCALAPPDATA%\agy\bin\agy.exe` (đường dẫn ứng dụng của user đang chạy lệnh, không phải đường dẫn cố định).
|
|
27
27
|
- `agy --version`: `1.4.3`.
|
|
28
28
|
- `agy --help` có print không tương tác, JSON và stream-json; help của executable được ghi qua stderr, nên probe thu cả hai stream.
|
|
29
29
|
- Tài khoản cached dùng được với prompt chỉ phản hồi; không đọc hoặc xuất credential.
|
|
@@ -50,6 +50,9 @@ Bridge chỉ dùng --version/--help và print/json/print-timeout/disable-slash-c
|
|
|
50
50
|
3. `--print-timeout 1ms`: CLI trả JSON SUCCESS, response rỗng, num_turns 0, exit **0**, stderr báo print timeout. Bridge nhận diện đây là timeout, không thành công.
|
|
51
51
|
4. MCP client → server → agy trong scratch workspace, yêu cầu tạo `bridge-smoke.txt`: CLI trả SUCCESS/exit 0 nhưng `denied_actions` chứa `write_file`, response rỗng. Bridge trả failed. Bằng chứng đầy đủ ở `test-results/write-permission-probe.json` (local, gitignored). Chưa chứng minh khả năng chỉnh sửa code qua bridge vì chưa được cấp quyền.
|
|
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
|
+
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
|
+
|
|
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 đỏ.
|
|
53
56
|
|
|
54
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.
|
|
55
58
|
|
|
@@ -61,7 +64,8 @@ Unit/integration dùng mock nằm ở `tests/bridge.test.ts`, được tách kh
|
|
|
61
64
|
- Timeout/error có thể bị CLI biểu diễn là SUCCESS/exit 0. Không dùng exit code đơn lẻ để quyết định.
|
|
62
65
|
- Không bật stream-json trong triển khai hiện tại: tài liệu mô tả cơ chế nhưng chưa cần kiểm thử để phục vụ print/json. Status chỉ có stdout/stderr đang nhận, không có phần trăm tiến độ. JSON cuối thường chỉ tới khi agent kết thúc.
|
|
63
66
|
- --sandbox xuất hiện trong help nhưng chưa xác minh khả năng cách ly Windows cho luồng này, nên không bật hay tuyên bố có OS confinement. Allowlist/preflight không thay thế sandbox.
|
|
64
|
-
- Hủy Windows dựa vào taskkill cây của child còn sống. Không bảo đảm
|
|
67
|
+
- Hủy Windows vẫn dựa vào taskkill cây của child còn sống; khi child tự thoát, bridge giải phóng các nhánh đã được ghi danh trong lúc chạy (mục 6 ở trên). Không bảo đảm: process sinh ra **và** mất cha giữa hai nhịp poll, PID reuse trong cửa sổ rất ngắn giữa bảng tiến trình và `taskkill`, hay server bị giết đột ngột/crash — ba trường hợp này vẫn để lại tree ghim thư mục. Nếu cần bảo đảm cleanup kể cả khi crash, bước tiếp theo là Windows Job Object (`KILL_ON_JOB_CLOSE`) hoặc worker trong môi trường OS cách ly; chưa làm.
|
|
68
|
+
- Nguồn gốc tiến trình giữ cwd nằm ở MCP server mà `agy` tự khởi động (`open-figma-mcp`, `chrome-devtools-mcp`) theo cấu hình **toàn cục** `~/.gemini/config/mcp_config.json`. Bridge dọn sau khi agent thoát, không ngăn chúng khởi động; tắt chúng là thay đổi cấu hình cá nhân của người dùng nên chưa tự làm.
|
|
65
69
|
- Lock bảo vệ các process dùng cùng bản cài bridge; không quản lý editor/agent khác hoặc bản cài khác.
|
|
66
70
|
- Chưa đăng ký vào cấu hình Codex thật; MCP SDK client handshake thành công không tương đương tool đã có trong phiên Codex hiện tại.
|
|
67
71
|
|
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); }
|