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.
@@ -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).catch(() => { });
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
- async persist(task) {
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 file = path.join(this.tasksDirectory, `${task.task_id}.json`);
695
- await writeFile(`${file}.tmp`, JSON.stringify({ task, request: this.entries.get(task.task_id)?.request ?? null, stats: this.entries.get(task.task_id)?.stats ?? null }), { mode: 0o600 });
696
- await rename(`${file}.tmp`, file);
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).
@@ -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`: `C:\Users\dotha\AppData\Local\agy\bin\agy.exe`.
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 xử lý hậu duệ đã detach, PID reuse cực ngắn giữa kiểm tra và taskkill, hoặc server crash. Nếu cần bảo đảm cleanup khi crash, bước tiếp theo là Windows Job Object hoặc worker trong môi trường OS cách ly.
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.19",
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); }