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/ui/app.js CHANGED
@@ -56,15 +56,20 @@ function duration(ms) {
56
56
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}p ${s % 60}s`;
57
57
  }
58
58
  function agoEl(iso, className) { return h('span', {class: className, 'data-ago': iso, title: clock(iso), text: ago(iso)}); }
59
- /** What Antigravity has shown while the task runs: output, writes to its log file (when the soft deadline is on), and any extension of the deadline. */
59
+ /** Secondary line under the progress bar; the "why" lives in ACTIVITY_HINT, shown as a tooltip. */
60
+ const ACTIVITY_HINT = 'Agy ở chế độ print/json chỉ in khi xong, nên "chưa có output" là bình thường. ' +
61
+ 'Log của agy lớn dần là dấu hiệu tiến trình còn sống; khi còn hoạt động, bridge gia hạn hạn mềm thay vì cắt đúng lúc hết giờ.';
60
62
  function activityText(activity) {
61
63
  if (!activity) return 'Chưa có thông tin hoạt động.';
62
64
  const parts = [];
63
- if (activity.log_updated_at) parts.push(`Log của agy cập nhật ${ago(activity.log_updated_at)} (${bytes(activity.log_bytes ?? 0)})`);
64
- else if (activity.soft_deadline) parts.push('Log của agy: chưa có dữ liệu');
65
- parts.push(activity.last_output_at ? `Output cuối ${ago(activity.last_output_at)}` : 'chưa có output từ agy (bình thường ở chế độ print/json: agy chỉ in khi xong)');
65
+ if (activity.log_updated_at) parts.push(`log agy: ${ago(activity.log_updated_at)} (${bytes(activity.log_bytes ?? 0)})`);
66
+ else if (activity.soft_deadline) parts.push('log agy: chưa có dữ liệu');
67
+ parts.push(activity.last_output_at ? `output cuối ${ago(activity.last_output_at)}` : 'chưa có output');
66
68
  const soft = activity.soft_deadline;
67
- if (soft) parts.push(soft.extensions ? `đã gia hạn ${soft.extensions} lần vì còn hoạt động; kiểm tra tiếp ${soft.next_check_at ? clock(soft.next_check_at) : '—'}` : `chưa cần gia hạn; kiểm tra lúc ${soft.next_check_at ? clock(soft.next_check_at) : '—'}`);
69
+ if (soft) {
70
+ if (soft.extensions) parts.push(`đã gia hạn ${soft.extensions} lần`);
71
+ else parts.push(soft.next_check_at ? `chưa gia hạn, kiểm tra lại sau ${Math.max(0, Math.round((Date.parse(soft.next_check_at) - Date.now()) / 1000))}s` : 'chưa gia hạn');
72
+ }
68
73
  return parts.join(' · ');
69
74
  }
70
75
  /** Steps agy reported (step progress setting): newest first, each with the raw event (redacted) one click away. */
@@ -258,6 +263,11 @@ const USAGE_LABELS = {input_tokens: 'Token vào', output_tokens: 'Token ra', tot
258
263
  const usageName = key => USAGE_LABELS[key] ? `${USAGE_LABELS[key]} (${key})` : key;
259
264
  const number = value => Number(value).toLocaleString('vi-VN', {maximumFractionDigits: 2});
260
265
  const usageLine = usage => usage && Object.keys(usage).length ? Object.entries(usage).map(([key, value]) => `${key} ${number(value)}`).join(' · ') : null;
266
+ /** One number in the row, the whole breakdown one hover away. */
267
+ const usageBrief = usage => {
268
+ if (!usage || !Object.keys(usage).length) return '—';
269
+ return 'total_tokens' in usage ? `${number(usage.total_tokens)} token` : `${Object.keys(usage).length} trường usage`;
270
+ };
261
271
  async function copy(text) {
262
272
  try { await navigator.clipboard.writeText(text); notify('Đã sao chép'); } catch { notify('Không thể sao chép tự động'); }
263
273
  }
@@ -304,7 +314,7 @@ function taskItems(o) {
304
314
  class: 'task-item', role: 'listitem', 'data-task': task.task_id, 'aria-current': String(task.task_id === state.selected), onclick: () => select(task.task_id)
305
315
  }, h('div', {class: 'row'}, pill(task.status), agoEl(task.queued_at || task.started_at, 'meta')),
306
316
  h('div', {class: 'title', text: task.title || '(không có nội dung yêu cầu)'}),
307
- h('div', {class: 'meta mono', text: `${task.agent}${task.isolation === 'worktree' ? ' · worktree' : ''} · ${folder(task.worktree || task.workspace)} · ${task.task_id.slice(0, 8)}`}),
317
+ h('div', {class: 'meta mono', text: `${task.agent}${task.isolation === 'worktree' ? ' · worktree' : ''} · ${folder(task.workspace)}`, title: task.task_id}),
308
318
  h('div', {class: 'meta'}, task.status === 'running' ? elapsedEl(task.started_at, 'Đang chạy ') : task.status === 'queued' ? 'Đang xếp hàng…' : `Mất ${duration(task.duration_ms)}`,
309
319
  task.tokens !== null && task.tokens !== undefined ? h('span', {class: overTask(o, task) ? 'over-budget' : '', text: ` · ${number(task.tokens)} token${overTask(o, task) ? ' (vượt mức)' : ''}`}) : null)));
310
320
  }
@@ -315,6 +325,13 @@ function details(key, title, ...body) {
315
325
  el.addEventListener('toggle', () => { el.open ? state.open.add(key) : state.open.delete(key); });
316
326
  return el;
317
327
  }
328
+ /** Text that answers "why / what if" instead of "what is happening now" gets collapsed until asked for. */
329
+ function collapsible(key, title, ...body) {
330
+ const el = details(key, title, ...body);
331
+ el.classList.add('more');
332
+ return el;
333
+ }
334
+ function more(key, ...body) { return collapsible(key, 'Xem thêm', ...body); }
318
335
 
319
336
  const CHANGE_LABEL = {modified: 'Sửa', deleted: 'Xóa', added: 'Thêm', 'type-changed': 'Đổi kiểu'};
320
337
  function changesBody(task) {
@@ -361,6 +378,21 @@ function scopeBody(task) {
361
378
  return h('div', {class: 'callout', text: `${scope.changed} file thay đổi, tất cả nằm trong owns.`});
362
379
  }
363
380
 
381
+ /**
382
+ * What the bridge did with the processes the agent left behind. Absent while the task runs and while the release
383
+ * query is still open, so a missing line is not a failure to clean up.
384
+ */
385
+ function cleanupBody(task) {
386
+ const c = task.cleanup;
387
+ if (!c) return null;
388
+ if (!c.checked) return h('div', {class: 'callout warn', text: `Không kiểm tra được cây tiến trình agent: ${c.reason || 'lỗi không xác định'}`});
389
+ const parts = [c.reaped.length ? `đã giải phóng ${c.reaped.length} cây tiến trình (${c.reaped.join(', ')})` : 'không còn cây tiến trình nào cần dọn'];
390
+ if (c.skipped.length) parts.push(`${c.skipped.length} nhánh vẫn chạy: ${c.skipped.map(s => `${s.pid} — ${s.guard}`).join('; ')}`);
391
+ if (typeof c.tracked === 'number') parts.push(`đã ghi danh ${c.tracked} tiến trình trong lúc agent chạy`);
392
+ if (c.group_signalled === true) parts.push('đã gửi tín hiệu tới cả nhóm tiến trình');
393
+ return h('div', {class: c.skipped.length ? 'callout warn' : 'callout', text: `Tiến trình agent để lại: ${parts.join(' · ')}`});
394
+ }
395
+
364
396
  function detailView() {
365
397
  const d = state.detail;
366
398
  if (!d) return h('div', {class: 'card empty'}, state.selected ? 'Đang tải…' : 'Chọn một task để xem nội dung trao đổi.');
@@ -368,18 +400,25 @@ function detailView() {
368
400
  const running = task.status === 'running';
369
401
  const queued = task.status === 'queued';
370
402
  const agentName = task.agent || 'agent';
403
+ const place = task.worktree || task.workspace;
404
+ const metaBits = [folder(task.workspace), agentName,
405
+ task.isolation === 'worktree' ? 'worktree' : 'tuần tự',
406
+ request && 'model' in request && request.model ? request.model : null,
407
+ running ? elapsedEl(task.started_at, 'đã chạy ') : queued ? 'đang xếp hàng' : `mất ${duration(task.duration_ms)}`,
408
+ state.detail?.stats?.usage && 'total_tokens' in state.detail.stats.usage ? `${number(state.detail.stats.usage.total_tokens)} token` : null].filter(Boolean);
371
409
  const head = h('div', {class: 'detail-head'},
372
410
  h('div', {}, h('h2', {}, 'Task ', h('span', {class: 'mono', text: task.task_id.slice(0, 8)}), pill(task.status)),
373
- h('div', {class: 'muted mono', text: task.workspace}),
374
- task.worktree ? h('div', {class: 'muted mono', text: `worktree: ${task.worktree}`}) : null),
411
+ h('div', {class: 'meta-line muted mono', title: place},
412
+ metaBits.reduce((acc, value) => acc.length ? [...acc, ' · ', value] : [value], []))),
375
413
  running || queued ? h('button', {class: 'btn danger small', onclick: () => cancel(task.task_id), text: 'Hủy task'}) : null);
376
414
  const activity = state.detail?.activity;
377
415
  const progress = running ? h('div', {class: 'progress', 'aria-label': 'Tiến độ'},
378
416
  h('div', {class: 'bar', 'data-bar-start': task.started_at, 'data-bar-total': String((request?.timeout_seconds ?? 300) * 1000), role: 'presentation'}, h('span', {})),
379
417
  h('div', {class: 'muted'}, elapsedEl(task.started_at, 'Đã chạy '), ` / giới hạn ${request?.timeout_seconds ?? '—'}s`),
380
- h('div', {class: 'muted', id: 'activity-line'}, activityText(activity))) : null;
418
+ h('div', {class: 'muted', id: 'activity-line', title: ACTIVITY_HINT}, activityText(activity))) : null;
381
419
  const facts = h('dl', {class: 'kv'},
382
- ...[['Agent', agentName], ['Cách ly', task.isolation === 'worktree' ? 'worktree (song song)' : 'tuần tự'],
420
+ ...[['Thư mục làm việc', task.workspace], ['Worktree', task.worktree || '—'],
421
+ ['Agent', agentName], ['Cách ly', task.isolation === 'worktree' ? 'worktree (song song)' : 'tuần tự'],
383
422
  ['Nhánh', task.branch || '—'], ['Quyền tự duyệt', task.autonomy || 'auto'],
384
423
  ['Gửi lúc', clock(task.queued_at || task.started_at)], ['Bắt đầu chạy', queued ? 'Chưa chạy' : clock(task.started_at)], ['Kết thúc', clock(task.ended_at)],
385
424
  ['Thời gian chạy', running ? elapsedEl(task.started_at) : queued ? '—' : duration(task.duration_ms)],
@@ -407,6 +446,7 @@ function detailView() {
407
446
  task.denied_actions.length ? h('div', {}, h('div', {class: 'msg-head', text: 'Thao tác bị từ chối quyền'}),
408
447
  h('div', {class: 'chips'}, task.denied_actions.map(a => h('span', {class: 'chip', text: a.display_name || a.action})))) : null,
409
448
  scopeBody(task),
449
+ cleanupBody(task),
410
450
  task.error ? h('div', {class: 'callout bad', text: task.error}) : null,
411
451
  task.status === 'completed' ? h('div', {class: 'callout warn', text: 'CLI báo hoàn tất nhưng chưa được xác minh. Tech Lead cần xem diff thực tế và chạy test.'}) : null
412
452
  ];
@@ -418,10 +458,11 @@ function detailView() {
418
458
  h('div', {id: 'log-note'}, task[`${state.logTab}_truncated`] ? h('div', {class: 'callout warn', text: 'Log đã bị cắt vì vượt giới hạn.'}) : null),
419
459
  h('pre', {class: 'log', id: 'log-pre', text: logText(task)}));
420
460
 
421
- return h('article', {class: 'card detail'}, head, progress, facts, h('div', {class: 'convo'}, asked, replied),
461
+ return h('article', {class: 'card detail'}, head, progress, h('div', {class: 'convo'}, asked, replied),
422
462
  changesPanel(task),
423
463
  details('steps', `Các bước ${agentName} báo cáo`, h('div', {id: 'steps-body'}, stepsBody(state.detail?.activity))),
424
464
  details('logs', `Log của tiến trình ${agentName}`, logs),
465
+ collapsible('more:fact', 'Xem thêm thông số task', facts),
425
466
  prompt ? details('prompt', 'Prompt đầy đủ gửi tới agy (đã lọc secret)', h('pre', {class: 'log', text: prompt})) : null);
426
467
  }
427
468
 
@@ -452,7 +493,7 @@ function modelCard(o) {
452
493
  h('div', {class: 'switch-row'}, h('div', {}, h('strong', {text: o.model || 'Mặc định của Antigravity'}),
453
494
  h('span', {class: 'muted', text: o.model ? 'Bridge truyền --model này cho mọi task mới.' : 'Bridge không truyền --model; Antigravity tự dùng model đã cấu hình.'})), select),
454
495
  loaded?.error ? h('div', {class: 'callout warn', text: `Không lấy được danh sách model: ${loaded.error}`}) : null,
455
- h('p', {text: 'Áp dụng cho task mới, không cần khởi động lại. Task đang chạy hoặc đang chờ giữ model đã chốt lúc nhận. Danh sách lấy từ `agy models`; model được ghi lại trong từng task.'}));
496
+ more('more:model', h('p', {text: 'Áp dụng cho task mới, không cần khởi động lại. Task đang chạy hoặc đang chờ giữ model đã chốt lúc nhận. Danh sách lấy từ `agy models`; model được ghi lại trong từng task.'})));
456
497
  }
457
498
  function progressCard(o) {
458
499
  const enabled = Boolean(o.progress?.enabled);
@@ -460,8 +501,10 @@ function progressCard(o) {
460
501
  return h('section', {class: 'card panel wide'}, h('h2', {text: 'Hiển thị tiến độ từng bước (stream-json)'}),
461
502
  h('label', {class: 'perm-row' + (enabled ? ' on' : '')}, toggle,
462
503
  h('span', {}, h('strong', {text: enabled ? 'Đang bật' : 'Đang tắt'}),
463
- h('span', {class: 'muted', text: 'Bridge chạy agy với --output-format stream-json: ngoài kết quả cuối, agy báo các sự kiện init và step_update khi đang làm việc (đã thấy bằng probe-stream trên agy 1.4.x). Các bước hiện trong chi tiết task, và là bằng chứng "còn sống" thật cho phần gia hạn. Áp dụng cho task mới, không cần khởi động lại.'}))),
464
- h('div', {class: 'callout warn', text: 'Chưa kiểm chứng: nội dung chi tiết bên trong step_update (nên hiển thị là tóm tắt tự động kèm dữ liệu gốc), và việc kết quả cuối ở chế độ này có cùng dạng với chế độ JSON. Nếu task báo lỗi "CLI output is not a valid stream-json final result" hoặc "stream result fields", hãy tắt tùy chọn này để quay về chế độ JSON đã kiểm chứng và gửi tôi thông báo lỗi.'}));
504
+ h('span', {class: 'muted', text: 'Bridge chạy agy ở --output-format stream-json; các bước hiện trong chi tiết task. Áp dụng cho task mới.'}))),
505
+ more('more:progress',
506
+ h('p', {text: 'Ngoài kết quả cuối, agy báo các sự kiện init và step_update khi đang làm việc (đã thấy bằng probe-stream trên agy 1.4.x). Các bước là bằng chứng "còn sống" thật cho phần gia hạn tự động.'}),
507
+ h('div', {class: 'callout warn', text: 'Chưa kiểm chứng: nội dung chi tiết bên trong step_update (nên hiển thị là tóm tắt tự động kèm dữ liệu gốc), và việc kết quả cuối ở chế độ này có cùng dạng với chế độ JSON. Nếu task báo lỗi "CLI output is not a valid stream-json final result" hoặc "stream result fields", hãy tắt tùy chọn này để quay về chế độ JSON đã kiểm chứng và gửi tôi thông báo lỗi.'})));
465
508
  }
466
509
  function budgetCard(o) {
467
510
  const budget = o.budget || {task_tokens: null, day_tokens: null, today_tokens: 0};
@@ -475,7 +518,7 @@ function budgetCard(o) {
475
518
  return h('section', {class: 'card panel wide'}, h('h2', {text: 'Mức token (đèn cảnh báo)'}),
476
519
  h('div', {class: 'switch-row'}, h('div', {}, h('strong', {text: 'Mỗi task'}), h('span', {class: 'muted', text: 'Task nào dùng nhiều hơn mức này sẽ được đánh dấu "vượt mức" trong danh sách.'})), chooser('task_tokens', BUDGET_TASK_PRESETS, 'Mức token mỗi task')),
477
520
  h('div', {class: 'switch-row'}, h('div', {}, h('strong', {text: 'Mỗi ngày'}), h('span', {class: 'muted', text: `Hôm nay đã dùng ${number(budget.today_tokens)} token qua bridge${budget.over_day ? ' — đã vượt mức.' : '.'}`})), chooser('day_tokens', BUDGET_DAY_PRESETS, 'Mức token mỗi ngày')),
478
- h('div', {class: 'callout warn', text: 'Đây chỉ là đèn báo, không phải hạn mức thật và không dừng task: agy không cho bridge đọc hạn mức, và số token của một task chỉ biết khi task kết thúc. Số liệu là tổng token (total_tokens) agy báo cho các task chạy qua bridge, tính theo ngày giờ máy của bạn.'}));
521
+ more('more:budget', h('div', {class: 'callout warn', text: 'Đây chỉ là đèn báo, không phải hạn mức thật và không dừng task: agy không cho bridge đọc hạn mức, và số token của một task chỉ biết khi task kết thúc. Số liệu là tổng token (total_tokens) agy báo cho các task chạy qua bridge, tính theo ngày giờ máy của bạn.'})));
479
522
  }
480
523
  function softTimeoutCard(o) {
481
524
  const soft = o.soft_timeout || {enabled: false, limit_seconds: 7200};
@@ -488,9 +531,11 @@ function softTimeoutCard(o) {
488
531
  return h('section', {class: 'card panel wide'}, h('h2', {text: 'Gia hạn khi Antigravity còn hoạt động'}),
489
532
  h('label', {class: 'perm-row' + (soft.enabled ? ' on' : '')}, toggle,
490
533
  h('span', {}, h('strong', {text: soft.enabled ? 'Đang bật' : 'Đang tắt (task dừng đúng hạn)'}),
491
- h('span', {class: 'muted', text: 'Đến hạn của task, bridge không dừng ngay mà kiểm tra xem agy còn hoạt động không (có byte mới ở output, hoặc file log của agy vừa được ghi). Nếu còn thì gia hạn rồi kiểm tra lại sau 1 phút, rồi 5 phút, rồi mỗi 10 phút; nếu không thấy hoạt động trong khoảng đó thì dừng task. Khi bật, bridge yêu cầu agy ghi log vào .bridge-state/agy-logs.'}))),
534
+ h('span', {class: 'muted', text: 'Đến hạn, bridge kiểm tra agy còn làm việc không và gia hạn thay vì dừng ngay.'}))),
492
535
  h('div', {class: 'switch-row'}, h('div', {}, h('strong', {text: 'Giới hạn tuyệt đối'}), h('span', {class: 'muted', text: 'Dù còn hoạt động, task vẫn dừng khi tổng thời gian chạy tới mức này.'})), select),
493
- h('div', {class: 'callout warn', text: 'Chưa kiểm chứng: tôi chưa biết agy có ghi log trong lúc suy luận ở chế độ --print. Bật rồi giao một task và xem dòng "Log của agy cập nhật…" trong chi tiết task: nếu số KB tăng khi agy đang chạy thì tín hiệu này dùng được. Nếu log không tăng, task sẽ dừng đúng hạn như khi tắt tính năng (không tệ hơn).'}));
536
+ more('more:soft-timeout',
537
+ h('p', {text: 'Cụ thể: khi tới hạn của task, bridge không dừng ngay mà kiểm tra xem agy còn hoạt động không (có byte mới ở output, hoặc file log của agy vừa được ghi). Nếu còn thì gia hạn rồi kiểm tra lại sau 1 phút, rồi 5 phút, rồi mỗi 10 phút; nếu không thấy hoạt động trong khoảng đó thì dừng task. Khi bật, bridge yêu cầu agy ghi log vào .bridge-state/agy-logs.'}),
538
+ h('div', {class: 'callout warn', text: 'Chưa kiểm chứng: tôi chưa biết agy có ghi log trong lúc suy luận ở chế độ --print. Bật rồi giao một task và xem dòng "log agy:" trong chi tiết task: nếu số KB tăng khi agy đang chạy thì tín hiệu này dùng được. Nếu log không tăng, task sẽ dừng đúng hạn như khi tắt tính năng (không tệ hơn).'})));
494
539
  }
495
540
  function waitCard(o) {
496
541
  const wait = o.wait || {seconds: 55, client_tool_timeout_seconds: 115};
@@ -502,9 +547,10 @@ function waitCard(o) {
502
547
  const minutes = Math.round(wait.client_tool_timeout_seconds / 60);
503
548
  return h('section', {class: 'card panel wide'}, h('h2', {text: 'Thời gian chờ mỗi lần Codex / Claude gọi (tiết kiệm token)'}),
504
549
  h('div', {class: 'switch-row'}, h('div', {}, h('strong', {text: `${wait.seconds} giây`}),
505
- h('span', {class: 'muted', text: 'Khi giao task, Codex gọi chờ và bị giữ lại trong một lệnh duy nhất cho đến khi Antigravity xong hoặc hết thời gian này. Trong lúc bị giữ, Codex không tốn token. Càng dài càng ít lần hỏi lại.'})), select),
506
- h('div', {class: 'callout warn'}, `Phải đặt thời hạn tool của client LỚN HƠN mức này, nếu không client sẽ cắt lần chờ và báo timeout. Với mức hiện tại: Codex, trong ~/.codex/config.toml mục [mcp_servers.antigravity] đặt `, h('code', {text: `tool_timeout_sec = ${wait.client_tool_timeout_seconds}`}), ` (khoảng ${minutes} phút), rồi mở lại Codex. Claude Code: biến môi trường MCP_TOOL_TIMEOUT (mili giây) lớn hơn ${wait.seconds * 1000}, theo tài liệu của Claude Code (chưa kiểm chứng ở đây). Lệnh \`agy-mcp-bridge setup codex\` in sẵn dòng này theo mức đang chọn.`),
507
- h('p', {text: 'Có hiệu lực ngay cho lần chờ sau, không cần khởi động lại bridge. Nếu bạn chưa chắc về thời hạn tool của client, giữ mức 55 giây.'}));
550
+ h('span', {class: 'muted', text: 'Client bị giữ trong một lệnh duy nhất tối đa mức này; càng dài càng ít lần hỏi lại. Có hiệu lực ngay, không cần khởi động lại.'})), select),
551
+ more('more:wait',
552
+ h('div', {class: 'callout warn'}, `Phải đặt thời hạn tool của client LỚN HƠN mức này, nếu không client sẽ cắt lần chờ và báo timeout. Với mức hiện tại: Codex, trong ~/.codex/config.toml mục [mcp_servers.antigravity] đặt `, h('code', {text: `tool_timeout_sec = ${wait.client_tool_timeout_seconds}`}), ` (khoảng ${minutes} phút), rồi mở lại Codex. Claude Code: biến môi trường MCP_TOOL_TIMEOUT (mili giây) lớn hơn ${wait.seconds * 1000}, theo tài liệu của Claude Code (chưa kiểm chứng ở đây). Lệnh \`agy-mcp-bridge setup codex\` in sẵn dòng này theo mức đang chọn.`),
553
+ h('p', {text: 'Khi giao task, Codex gọi chờ và bị giữ lại trong một lệnh duy nhất cho đến khi Antigravity xong hoặc hết thời gian này. Trong lúc bị giữ, Codex không tốn token. Nếu bạn chưa chắc về thời hạn tool của client, giữ mức 55 giây.'})));
508
554
  }
509
555
  function effortCard(o) {
510
556
  const current = EFFORT_LEVELS.find(level => level.id === (o.effort || 'default')) || EFFORT_LEVELS[0];
@@ -517,14 +563,21 @@ function effortCard(o) {
517
563
  }
518
564
  function permissionCard(o) {
519
565
  const p = o.permission || {level: 'default', controllable: true};
566
+ const current = PERMISSION_LEVELS.find(level => level.id === p.level) || PERMISSION_LEVELS[0];
567
+ const sandbox = h('div', {class: 'callout warn', text: 'Danh sách "Workspace được phép" chỉ kiểm tra đường dẫn của task, không phải sandbox. Với mức Tự động hoặc Toàn quyền, Antigravity có thể tác động ra ngoài thư mục đó.'});
568
+ const risky = p.level === 'auto' || p.level === 'full';
520
569
  return h('section', {class: 'card panel wide'}, h('h2', {text: 'Quyền của Antigravity'}),
521
- h('p', {text: 'Mức quyền Antigravity có khi làm task. Chỉ bạn đổi được ở đây; Codex / Claude không đổi được. Áp dụng cho task mới, không cần khởi động lại. Hạ mức xuống thì áp dụng cả cho task đang chờ; nâng mức lên thì không.'}),
522
570
  h('div', {class: 'perm-list', role: 'radiogroup', 'aria-label': 'Quyền của Antigravity'}, PERMISSION_LEVELS.map(level =>
523
571
  h('label', {class: `perm-row${level.id === 'full' ? ' danger' : ''}${p.level === level.id ? ' on' : ''}`},
524
572
  h('input', {type: 'radio', name: 'perm', value: level.id, checked: p.level === level.id, disabled: !p.controllable, onchange: () => setPermission(level.id)}),
525
- h('span', {}, h('strong', {text: level.title}), h('span', {class: 'muted', text: level.text}))))),
573
+ h('span', {}, h('strong', {text: level.title}))))),
574
+ h('p', {text: current.text}),
575
+ risky ? sandbox : null,
526
576
  p.controllable ? null : h('div', {class: 'callout warn', text: `Đang bị khóa bởi biến môi trường BRIDGE_AGY_PERMISSIONS${p.reason && p.reason !== 'BRIDGE_AGY_PERMISSIONS' ? ` (${p.reason})` : ''}. Đổi biến rồi khởi động lại server để mở.`}),
527
- h('div', {class: 'callout warn', text: 'Danh sách "Workspace được phép" chỉ kiểm tra đường dẫn của task, không phải sandbox. Với mức Tự động hoặc Toàn quyền, Antigravity có thể tác động ra ngoài thư mục đó.'}));
577
+ more('more:permission',
578
+ h('p', {text: 'Mức quyền Antigravity có khi làm task. Chỉ bạn đổi được ở đây; Codex / Claude không đổi được. Áp dụng cho task mới, không cần khởi động lại. Hạ mức xuống thì áp dụng cả cho task đang chờ; nâng mức lên thì không.'}),
579
+ h('ul', {class: 'list'}, PERMISSION_LEVELS.map(level => h('li', {}, h('strong', {text: `${level.title}: `}), level.text))),
580
+ risky ? null : sandbox));
528
581
  }
529
582
  function usageView(u) {
530
583
  const head = h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Sử dụng'}), h('p', {text: 'Số liệu bridge ghi lại từ các task đã chạy qua nó.'})),
@@ -541,33 +594,38 @@ function usageView(u) {
541
594
  const sums = Object.entries(t.usage);
542
595
  const usageCard = h('section', {class: 'card panel wide'}, h('h2', {text: 'Usage do agy báo'}),
543
596
  sums.length ? [h('dl', {class: 'rows'}, sums.map(([key, value]) => row(usageName(key), number(value)))),
544
- h('p', {class: 'muted', text: `Cộng dồn từ ${number(t.with_usage)} task có trường usage trong kết quả của agy. Tên trường do agy quyết định; bridge cộng mọi giá trị số.`})]
597
+ more('more:usage-sums', h('p', {class: 'muted', text: `Cộng dồn từ ${number(t.with_usage)} task có trường usage trong kết quả của agy. Tên trường do agy quyết định; bridge cộng mọi giá trị số.`}))]
545
598
  : h('p', {class: 'muted', text: 'Chưa có task nào mà kết quả của agy kèm số liệu usage. Với task chạy ở chế độ JSON hoặc stream, nếu agy trả trường usage thì tổng sẽ hiện ở đây.'}));
546
599
  const peak = Math.max(1, ...u.by_day.map(d => d.tasks));
547
- const bars = h('div', {class: 'bars'}, u.by_day.map(d => {
600
+ const bar = d => {
548
601
  const fill = h('div', {class: 'bar-fill'}); fill.style.width = `${(d.tasks / peak * 100).toFixed(1)}%`;
549
602
  return h('div', {class: 'bar-row'}, h('span', {class: 'muted mono', text: `${d.day.slice(8)}/${d.day.slice(5, 7)}`}), h('div', {class: 'bar-track'}, fill),
550
- h('span', {class: 'mono', text: d.tasks ? `${d.tasks} · ${duration(d.duration_ms)}` : '0'}));
551
- }));
552
- const dayCard = h('section', {class: 'card panel wide'}, h('h2', {text: '14 ngày gần nhất'}), bars);
603
+ h('span', {class: 'mono', text: `${d.tasks} · ${duration(d.duration_ms)}`}));
604
+ };
605
+ const active = u.by_day.filter(d => d.tasks);
606
+ const dayCard = h('section', {class: 'card panel wide'}, h('h2', {text: '14 ngày gần nhất'}),
607
+ active.length ? h('div', {class: 'bars'}, active.map(bar)) : h('p', {class: 'muted', text: 'Không có task nào trong 14 ngày gần nhất.'}),
608
+ active.length && active.length < u.by_day.length ? h('p', {class: 'muted', text: `${u.by_day.length - active.length} ngày còn lại không có task.`}) : null);
553
609
  const groupTable = (title, groups, firstColumn) => h('section', {class: 'card panel wide'}, h('h2', {text: title}),
554
610
  groups.length ? h('table', {class: 'usage-table'}, h('thead', {}, h('tr', {}, ...[firstColumn, 'Task', 'Hoàn tất', 'Thời gian', 'Usage'].map(c => h('th', {text: c})))),
555
- h('tbody', {}, groups.map(g => h('tr', {}, h('td', {class: 'mono', text: g.key}), h('td', {text: number(g.tasks)}), h('td', {text: number(g.completed)}), h('td', {text: duration(g.duration_ms)}), h('td', {class: 'muted', text: usageLine(g.usage) ?? '—'})))))
611
+ h('tbody', {}, groups.map(g => h('tr', {}, h('td', {class: 'mono', text: g.key}), h('td', {text: number(g.tasks)}), h('td', {text: number(g.completed)}), h('td', {text: duration(g.duration_ms)}),
612
+ h('td', {class: 'muted', title: usageLine(g.usage) ?? '', text: usageBrief(g.usage)})))))
556
613
  : h('p', {class: 'muted', text: 'Chưa có dữ liệu.'}));
557
614
  const recent = h('section', {class: 'card panel wide'}, h('h2', {text: 'Task gần nhất'}),
558
615
  u.recent.length ? h('ul', {class: 'path-list'}, u.recent.map(r => h('li', {}, `${clock(r.at)} · ${STATUS[r.status] || r.status} · ${r.model || 'model mặc định'} · effort ${r.effort || 'mặc định'} · ${duration(r.duration_ms)}`,
559
- usageLine(r.usage) ? h('span', {class: 'tag', text: usageLine(r.usage)}) : null))) : h('p', {class: 'muted', text: 'Chưa có task nào.'}));
560
- return [head, callout, cards, usageCard, dayCard, groupTable('Theo model', u.by_model, 'Model'), groupTable('Theo mức suy luận', u.by_effort, 'Effort'), recent];
616
+ r.usage && Object.keys(r.usage).length ? h('span', {class: 'tag', title: usageLine(r.usage), text: usageBrief(r.usage)}) : null))) : h('p', {class: 'muted', text: 'Chưa có task nào.'}));
617
+ return [head, cards, usageCard, dayCard, groupTable('Theo model', u.by_model, 'Model'), groupTable('Theo mức suy luận', u.by_effort, 'Effort'), recent,
618
+ more('more:usage', callout)];
561
619
  }
562
620
  function settingsView(o) {
563
621
  const delegation = o.delegation;
564
- return [h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Cài đặt'}), h('p', {text: 'Trạng thái và giới hạn của bridge. Phần lớn chỉ đọc; chỉnh bằng file cấu hình rồi khởi động lại.'}))),
622
+ return [h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Cài đặt'}), h('p', {text: 'Mọi thứ đổi được ở đây áp dụng cho task mới, không cần khởi động lại. Giới hạn cứng vẫn nằm trong config/workspaces.json.'}))),
565
623
  h('div', {class: 'settings'},
566
624
  h('section', {class: 'card panel wide'}, h('h2', {text: 'Giao việc cho agent'}),
567
625
  h('div', {class: 'switch-row'}, h('div', {}, h('strong', {text: delegation.enabled ? 'Đang bật' : 'Đã tắt'}),
568
626
  h('span', {class: 'muted', text: delegation.enabled ? 'Codex / Claude có thể giao task mới.' : `Task mới bị từ chối. ${delegation.reason || ''}`})),
569
627
  switchControl(delegation.enabled, delegation.controllable, 'Cho phép giao việc cho Antigravity')),
570
- delegation.controllable ? h('p', {text: 'Tắt để buộc Codex / Claude tự làm. Task đang chạy hoặc đang chờ không bị dừng, nhưng task đang chờ sẽ bị hủy khi đến lượt. Có hiệu lực ngay, không cần khởi động lại.'})
628
+ delegation.controllable ? more('more:delegation', h('p', {text: 'Tắt để buộc Codex / Claude tự làm. Task đang chạy hoặc đang chờ không bị dừng, nhưng task đang chờ sẽ bị hủy khi đến lượt. Có hiệu lực ngay, không cần khởi động lại.'}))
571
629
  : h('div', {class: 'callout warn', text: 'Đang bị khóa bởi biến môi trường BRIDGE_DELEGATION. Đổi biến rồi khởi động lại server để mở.'})),
572
630
  modelCard(o),
573
631
  effortCard(o),
@@ -578,30 +636,33 @@ function settingsView(o) {
578
636
  permissionCard(o),
579
637
  h('section', {class: 'card panel'}, h('h2', {text: 'Kết nối'}),
580
638
  h('div', {class: 'copy-row'}, h('code', {text: o.server.mcp_endpoint}), h('button', {class: 'btn small', onclick: () => copy(o.server.mcp_endpoint), text: 'Sao chép'})),
581
- h('dl', {class: 'rows'}, row('Giao thức', 'MCP Streamable HTTP'), row('Xác thực', 'Không — chỉ 127.0.0.1'), row('Phiên bản bridge', o.server.version),
582
- row('Khởi động', clock(o.server.started_at))),
583
- h('p', {class: 'callout warn', text: 'Không có xác thực: bất kỳ tiến trình nào chạy trên máy này với cùng user đều có thể điều khiển bridge. Không mở cổng ra ngoài.'})),
639
+ h('dl', {class: 'rows'}, row('Phiên bản bridge', o.server.version)),
640
+ h('p', {class: 'callout warn', text: 'Không có xác thực: bất kỳ tiến trình nào chạy trên máy này với cùng user đều có thể điều khiển bridge. Không mở cổng ra ngoài.'}),
641
+ more('more:connect', h('dl', {class: 'rows'}, row('Giao thức', 'MCP Streamable HTTP'), row('Xác thực', 'Không — chỉ 127.0.0.1'), row('Khởi động', clock(o.server.started_at))))),
584
642
  h('section', {class: 'card panel wide'}, h('h2', {text: 'Agent CLI'}),
585
643
  h('div', {class: 'agent-list'}, (o.agents || []).length ? (o.agents || []).map(agent => h('div', {class: 'switch-row'},
586
644
  h('div', {}, h('strong', {text: `${agent.label} · ${agent.id}`}),
587
- h('span', {class: 'muted', text: [agent.enabled ? 'đang bật' : 'đã tắt', agent.version || 'không rõ phiên bản', (agent.capabilities || []).join(', ')].filter(Boolean).join(' · ')})),
645
+ h('span', {class: 'muted', text: [agent.enabled ? 'đang bật' : 'đã tắt', agent.version || 'không rõ phiên bản'].filter(Boolean).join(' · ')})),
588
646
  h('span', {class: `pill ${agent.enabled && agent.available ? 'ok' : agent.enabled ? 'bad' : 'warn'}`, text: !agent.enabled ? 'Tắt' : agent.available ? 'Sẵn sàng' : 'Không có'}))) : h('p', {class: 'muted', text: 'Chưa có agent nào được cấu hình.'})),
589
647
  h('p', {class: 'muted', text: `Agent mặc định: ${o.default_agent || 'không có'}. Thứ tự ưu tiên đặt trong config/workspaces.json.`}),
590
- (o.agents || []).some(agent => (agent.warnings || []).length) ? h('div', {class: 'callout warn', text: (o.agents || []).flatMap(agent => agent.warnings || []).join(' · ')}) : null),
648
+ (o.agents || []).some(agent => (agent.warnings || []).length) ? h('div', {class: 'callout warn', text: (o.agents || []).flatMap(agent => agent.warnings || []).join(' · ')}) : null,
649
+ more('more:agents', h('ul', {class: 'list'}, (o.agents || []).map(agent => h('li', {},
650
+ h('strong', {text: `${agent.label}: `}), (agent.capabilities || []).join(', ') || 'không có capability nào'))))),
591
651
  h('section', {class: 'card panel'}, h('h2', {text: 'Antigravity CLI'}),
592
- h('dl', {class: 'rows'}, row('Trạng thái', o.cli.bridge_status), row('Phiên bản', o.cli.version), row('Đường dẫn', o.cli.executable || '—'),
593
- row('Cách chạy', o.cli.execution_method || '—')),
594
- h('p', {text: o.cli.authentication})),
652
+ h('dl', {class: 'rows'}, row('Trạng thái', o.cli.bridge_status), row('Phiên bản', o.cli.version)),
653
+ h('p', {text: o.cli.authentication}),
654
+ more('more:cli', h('dl', {class: 'rows'}, row('Đường dẫn', o.cli.executable || '—'), row('Cách chạy', o.cli.execution_method || '—')))),
595
655
  h('section', {class: 'card panel'}, h('h2', {text: 'Workspace được phép'}),
596
656
  o.allowed_workspaces.length ? h('ul', {class: 'path-list'}, o.allowed_workspaces.map(p => h('li', {}, p, (o.session_workspaces || []).includes(p) ? h('span', {class: 'tag', text: 'chỉ lần chạy này'}) : null)))
597
657
  : h('div', {class: 'callout warn', text: 'Chưa có workspace nào. Thêm đường dẫn tuyệt đối vào config/workspaces.json rồi khởi động lại.'})),
598
658
  h('section', {class: 'card panel'}, h('h2', {text: 'Giới hạn'}),
599
- h('dl', {class: 'rows'}, row('Timeout tối đa', `${o.limits.max_timeout_seconds ?? '—'}s`), row('Output mỗi luồng', bytes(o.limits.max_output_bytes_per_stream ?? 0)),
659
+ h('p', {text: `${o.limits.concurrent_normal_tasks ?? 1} task thường + ${o.limits.max_parallel_worktrees ?? o.concurrency?.max_parallel_worktrees ?? '—'} worktree cùng lúc, hàng đợi tối đa ${o.limits.max_queued_tasks ?? '—'}, timeout tối đa ${o.limits.max_timeout_seconds ?? '—'}s.`}),
660
+ more('more:limits', h('dl', {class: 'rows'}, row('Timeout tối đa', `${o.limits.max_timeout_seconds ?? '—'}s`), row('Output mỗi luồng', bytes(o.limits.max_output_bytes_per_stream ?? 0)),
600
661
  row('Task lưu giữ', o.limits.max_retained_tasks ?? '—'), row('Task thường chạy cùng lúc', o.limits.concurrent_normal_tasks ?? 1),
601
662
  row('Worktree chạy song song', o.limits.max_parallel_worktrees ?? o.concurrency?.max_parallel_worktrees ?? '—'),
602
663
  row('Quyền tự duyệt mặc định', o.default_autonomy ?? 'auto'),
603
- row('Hàng đợi tối đa', o.limits.max_queued_tasks ?? '—'))),
604
- h('section', {class: 'card panel wide'}, h('h2', {text: 'Lưu ý khi dùng'}), h('ul', {class: 'list'}, o.limitations.map(item => h('li', {text: item})))),
664
+ row('Hàng đợi tối đa', o.limits.max_queued_tasks ?? '—')))),
665
+ h('section', {class: 'card panel wide'}, collapsible('more:limitations', 'Lưu ý khi dùng', h('ul', {class: 'list'}, o.limitations.map(item => h('li', {text: item}))))),
605
666
  h('section', {class: 'card panel wide'}, h('h2', {text: 'Dừng bridge'}),
606
667
  h('p', {text: 'Đóng tab không dừng server. Nút này tắt HTTP server, hủy task đang chạy hoặc đang chờ và dọn các tiến trình agent do bridge khởi tạo. Không thể hoàn tác.'}),
607
668
  h('button', {class: 'btn danger', onclick: shutdownBridge, text: 'Tắt bridge'})))];
package/docs/HANDOFF.md DELETED
@@ -1,152 +0,0 @@
1
- # Handoff — dọn cây tiến trình agent và thư mục worktree (B + C)
2
-
3
- Cập nhật: 2026-10-26. Nhánh làm việc: `deepseek` (chưa commit, chưa push, chưa release).
4
- Người tiếp theo: đọc mục "Chưa làm" trước khi sửa thêm; mọi mục có cách kiểm lại độc lập.
5
-
6
- ## 0. Vấn đề gốc (bằng chứng thật, không suy đoán)
7
-
8
- Agent CLI không chỉ là một process. `agy` tự khởi động MCP server của nó
9
- (`cmd /c npx -y open-figma-mcp@latest`, `npx -y chrome-devtools-mcp@latest`) với **thư mục làm việc = worktree**
10
- được giao việc. Trên Windows, một process giữ thư mục làm việc đang mở thì thư mục đó **không xóa được, không
11
- đổi tên được**: `git worktree remove`, `rm -rf`, `Remove-Item`, `MoveFile` đều fail (EBUSY / ERROR_ACCESS_DENIED)
12
- dù thư mục trông trống hoàn toàn. Không log nào, không command line nào của process cho thấy điều đó; cách duy
13
- nhất để tìm là đọc CWD của process khác qua PEB
14
- (`OpenProcess(0x0410)` → `NtQueryInformationProcess(class 0)` → PEB+0x20 → +0x38 length, +0x40 buffer) — xem
15
- `test-results/probe-cwd.ps1`.
16
-
17
- Chuỗi tiến trình thực đo được từ một lần chạy cầu hình:
18
-
19
- ```
20
- agy (pid 3300, đã thoát)
21
- └─ cmd.exe (pid 12848, ĐÃ THOÁT) ← mắt xích chết
22
- └─ node.exe npx -y open-figma-mcp@latest (pid 29124, còn sống, cwd = worktree)
23
- └─ cmd.exe /d /s /c open-figma-mcp (pid 4836, còn sống)
24
- └─ node.exe …\open-figma-mcp\server\dist\index.js (pid 15532, còn sống)
25
- ```
26
-
27
- `ParentProcessId` của Windows là hằng do process ghi khai khi sinh; nó trỏ vào pid chứ không trỏ vào object, nên
28
- khi mắt xích giữa chết thì cả nhánh con **vô hình** với mọi query "con của agent". Ba process đó ghim worktree cho
29
- tới khi bị giết. `taskkill /T` từ gốc 29124 giết cả ba và `rm -rf` thành công ngay sau đó.
30
-
31
- ## 1. Đã làm (code đã build, đã test đơn vị, đã live-test bằng `agy` thật)
32
-
33
- | # | File | Nội dung |
34
- |---|------|----------|
35
- | 1 | `src/isolation/orphans.ts` (mới) | `reapGuard` (PID hợp lệ, không phải bridge, denylist process hệ thống, cửa sổ thời điểm khởi tạo ±5s), `listProcesses` (một query CIM cho toàn bảng), `descendants` (BFS thuần, chống chu cha con), `startTreeWatch` (ghi danh cây tiến trình **trong lúc task chạy**), `reapOwnedTree` (Windows: chỉ giết gốc của từng nhánh còn sống, đối chiếu name+creation để loại PID bị tái sử dụng; POSIX: signal process group của child đã thoát) |
36
- | 2 | `src/isolation/worktree.ts` | `removeWorktree` trả `{removed, reason}` với retry `[0, 200, 600]` ms thay vì nuốt lỗi; `sweepStaleWorktrees` dọn lúc khởi động: chỉ thư mục **rỗng** mang tên task-id, bỏ lại thư mục còn file, bỏ qua task `queued`/`running`, đọc cả hai shape record |
37
- | 3 | `src/tasks/manager.ts` | Nối watcher vào vòng đời task (`watchTree` seam, stop khi child đóng, `observed` đưa vào reap); `task.cleanup` là `TreeReport`; giải phóng chạy **sau** khi trạng thái cuối đã công bố và được patch vào record bằng `persist(task, held)`; `REAP_BUDGET_MS = 6000` với timer `unref`; `persist` retry rename khi `EPERM/EACCES/EBUSY` (Windows từ chối rename đè lên record mà UI/client đang mở để đọc); đường launch fail ghi rõ reason khi không xóa được worktree |
38
- | 4 | `src/runtime.ts` | `watchTree: startTreeWatch` (manager không tự chọn cơ chế OS); gọi `sweepStaleWorktrees` sau `recoverExecutionLock` và log từng thư mục xóa/giữ; hai dòng `limitations` mô tả đúng hành vi mới |
39
- | 5 | `tests/isolation-orphans.test.ts`, `tests/isolation-worktree.test.ts`, `tests/agents.test.ts`, `tests/hardening-output.test.ts` | Guard rules; giết đúng gốc nhánh; subtree dưới shell đã chết vẫn được giải phóng; PID tái sử dụng không biến cây lạ thành cây của mình; `descendants`; watch poll/accumulate/stop; worktree thật bằng git thật; busy simulation; sweep; thứ tự "status trước, cleanup sau" và request/stats không bị null sau patch; bound snapshot theo `duration_ms` thay vì hằng số 3 |
40
-
41
- Hành vi không đổi, cố ý giữ: bind `127.0.0.1`, kiểm tra Host/Origin, **không** xác thực token, không cờ bypass
42
- permission, không tự merge worktree, hard-deny deploy/publish/xóa dữ liệu/push nhánh bảo vệ/đọc credential/thay
43
- đổi hệ thống ngoài workspace, và **trạng thái cuối chỉ xuất bản sau khi `inspectScope` xong**.
44
-
45
- ## 2. Chưa làm (mỗi dòng là một việc, kèm cách kiểm)
46
-
47
- 1. **Nguyên nhân gốc vẫn còn**: `open-figma-mcp` và `chrome-devtools-mcp` vẫn bật **toàn cục** trong
48
- `~/.gemini/config/mcp_config.json` (thư mục cấu hình của user chạy bridge). bridge chỉ dọn *sau khi* agent thoát, không ngăn chúng khởi
49
- động. Chưa tắt bằng `agy mcp disable open-figma-mcp` (lựa chọn A) vì lệnh đó đổi cấu hình cá nhân của người
50
- dùng và `agy mcp` chỉ có tác dụng global — **không** có `--config-dir`/`--mcp-config` per-task (đã kiểm
51
- `agy help` ở phiên trước). Hệ quả còn lại: một phiên `agy` chạy *ngoài* bridge vẫn để lại cây ghim thư mục.
52
- 2. **Không có Windows Job Object** (lựa chọn D). Bridge bị giết đột ngột (`taskkill` chính nó, SIGKILL, OS crash,
53
- mất điện) giữa lúc task chạy thì không có lần giải phóng nào; worktree bị ghim cho tới khi process giữ nó chết.
54
- `KILL_ON_JOB_CLOSE` mới là bảo đảm thật. Node không có API job, cần native addon hoặc P/Invoke `CreateJobObject`
55
- + `AssignProcessToJobObject` lúc spawn — chưa làm, chưa đánh giá chi phí/rủi ro.
56
- 3. **Lỗ hổng của watcher** (có ghi trong mã, chưa bịt): process sinh ra **và** mắt xích cha của nó chết *giữa hai
57
- nhịp poll* sẽ không bao giờ vào `observed`, nên không thành gốc lúc reap. Nhịp hiện tại `WATCH_DELAYS_MS =
58
- [500, 1000, 2000, 4000, 10000]` ms (dày lúc `agy` boot MCP server, thưa dần về sau); chưa có bằng chứng cho CLI
59
- khác (codex, OpenCode…) với mô hình spawn khác — nhịp là suy đoán từ `agy` 1.4.7.
60
- 4. **PID reuse mới chỉ giảm rủi ro, chưa loại bỏ**: cửa sổ ±5s + denylist ảnh hệ thống + đối chiếu name/creation
61
- giữa record và bảng lúc reap + chạy ngay sau khi child exit. Chưa so image path/handle. Trường hợp xấu nhất vẫn
62
- là "PID được cấp cho process lạ trùng cửa sổ" — cần quyết định: siết thêm hay chấp nhận và tài liệu hóa.
63
- 5. **Chi phí poll đã đo một lần, chưa tuning và không tắt được**: trên máy dev ngày 2026-10-26, một nhịp
64
- `Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,CreationDate` (toàn bảng, ~337 dòng) tốn
65
- **555 ms**; bản lọc `-Filter "ParentProcessId=X"` tốn 172 ms nhưng chỉ tìm được con trực tiếp — không đủ cho mục
66
- 0. Thêm `CommandLine` làm gần gấp đôi chi phí nên bảng không lấy cột đó. Dùng toàn bảng vì một bảng trả được cả
67
- cây và các cha đã chết. Với nhịp hiện tại, task 300 s ≈ 40 nhịp ≈ 6% chu kỳ làm việc của một `powershell.exe`
68
- riêng; số đo lấy lúc máy rảnh, chưa đo lúc tải cao hoặc máy chậm, và **chưa có config để tắt watcher hay đổi nhịp**.
69
- 6. **Nhánh group-kill POSIX chưa bao giờ gặp một process group còn sống**: `group_alive`/`signal_group` vẫn chỉ được
70
- inject trong unit test; trên Linux thật (WSL, Node 20.20.2 — xem mục 3) mock child đã thoát nên `inGroup(pid)` trả
71
- `false` và release về báo cáo `{checked:true, reaped:[], skipped:[], group_signalled:false}` tức thì — vòng
72
- `SIGTERM → grace → SIGKILL` chưa chạy lần nào. Chưa có E2E Linux có CLI thật. CI matrix Ubuntu đã chứng minh một
73
- điều khác và quan trọng hơn: nó bắt được lỗi thật mà Windows không lộ (mục 4).
74
- 7. **Báo cáo giải phóng không có ở UI**: `task.cleanup` tồn tại trong `antigravity_task_status` và
75
- `.bridge-state/tasks/<id>.json`, nhưng `src/ui` không render (grep `cleanup` trong `src/ui` = 0 kết quả). Nếu
76
- muốn hiển thị, làm chung với gate 6 trong `docs/MULTI-AGENT-PLAN.md` (UI hiển thị worktree/branch).
77
- 8. **`cleanup` có thể mất**: reap chạy nền sau khi lane đã nhả; nếu server thoát trong ~6 s sau khi agent exit thì
78
- bản vá record không kịp ghi (chưa có flush-on-shutdown cho reap đang chờ).
79
- 9. **Sweep chỉ xóa thư mục rỗng**: worktree còn file mà không ai review thì còn mãi; branch `bridge/*` không bao giờ
80
- bị xóa tự động (chính sách "không tự merge/xóa"). Chưa có TTL, chưa có nút/CLI dọn, và **`git worktree prune`
81
- chưa từng được gọi** → sau khi xóa tay/`sweep`, bản ghi admin trong `<toplevel>/.git/worktrees/*` vẫn còn và
82
- `git worktree list` báo prunable.
83
- 10. **`removeWorktree` thực tế chỉ chạy ở một nhánh hẹp** (launch chưa kịp spawn). Worktree của task hoàn tất được
84
- giữ lại có chủ đích, nên đường EBUSY của nó chỉ được chứng minh bằng test mô phỏng, chưa bằng production.
85
- 11. **Chưa commit/push/release**; chưa xin xác nhận của người dùng cho lần thay đổi này (lần chấp nhận trước chỉ
86
- covering thay đổi đã merge). Scripts kiểm chứng đang ở `test-results/` và **bị gitignore**:
87
- `verify-reap.mjs` + `probe-cwd.ps1` là đường tái hiện lỗi (đừng xóa); `trace-ancestry.ps1`,
88
- `list-test-runners.ps1`, `inspect-child.ps1` chỉ là trợ giúp một lần của phiên này — `inspect-child.ps1` còn
89
- chưa chạy lần nào. Cần quyết: nâng thành `scripts/live-reap.ts` có gate, hay xóa sau khi xong.
90
-
91
- ## 3. Bằng chứng
92
-
93
- Unit (`npm test` = build + `node dist/scripts/run-tests.js`, 210 test):
94
-
95
- - Sequential (`node --test --test-concurrency=1 dist/tests/*.test.js`): **207 pass, 0 fail, 3 skipped**, 164,9 s. Đã
96
- chạy lại lần hai sau bản sửa cuối (nhịp poll thưa hơn) và vẫn 207/0/3 — hai lần độc lập, cùng số.
97
- - Parallel (`npm test`) trên Windows, chạy liên tiếp 3 lần: `206/1 fail`, `205/2 fail`, `207/0 fail`. Đứng một mình, từng test lỗi đều xanh (progress 9/9 hai lần, bridge.test 27/27).
98
- - Nhà lỗi luôn là nhóm nhạy cảm timing: `progress` (…signal counts as activity), `http` (startup warns about the local trust model), `soft-timeout` (last activity Ns ago), `hardening-output` (số snapshot). Chúng flake theo tải của runner, không theo code; ba phiên liên tiếp cho ba test khác nhau. Đứng riêng, `progress.test.js` xanh 3/3 lần (9 pass), `bridge.test.js` 27/27. Không nới assertion để né. **Đã chốt ngày 2026-10-27**: `scripts/run-tests.ts` truyền `--test-concurrency=1`, nên từ nay `npm test` — và CI — chạy tuần tự; thời gian trên máy dev Windows là ~158 s thay vì ~47 s. Hai gạch đầu dòng trên là số liệu lịch sử của chế độ song song, không còn tái hiện được bằng `npm test`. Xác nhận sau khi đổi: `npm test` cho **207 pass, 0 fail, 3 skipped** trong 161,7 s (ngày 2026-10-27). Cờ `--test-concurrency` chỉ tồn tại từ Node 20.10 / 18.19 / 21.0 trở đi; CI đặt `node: [20, 22]` nên luôn nhận cờ, còn dev dùng Node 20.0–20.9 sẽ gặp `bad option: --test-concurrency` — cố ý không feature-detect trong script và không nâng `engines.node`, vì đây chỉ là đường chạy test của người phát triển, không phải đường chạy của sản phẩm đã publish.
99
- - Bộ mới viết xanh tuyệt đối khi chạy riêng: `tests/isolation-orphans.test.js` 9/9, `tests/isolation-worktree.test.js` và `tests/agents.test.js` chạy kèm (`23 pass, 0 fail`).
100
- - Linux (WSL, Node **20.20.2**, `node --test --test-concurrency=1 dist/tests/*.test.js`) ngày 2026-10-27: **211 test, 210 pass, 0 fail, 1 skipped**, exit 0. Đây là nền mà CI Ubuntu báo đỏ trước đó; chỉ khác Windows ở chỗ nhóm test POSIX signal được chạy thật thay vì bỏ qua.
101
- - Windows (`npm test`, Node 24.15.0) cùng ngày, sau bản sửa: **211 test, 208 pass, 0 fail, 3 skipped**, 157,8 s. Hai con số lệch nhau đúng 3 = ba test nhóm POSIX signal bỏ qua trên Windows.
102
- - CI của PR #28 (ubuntu-20/ubuntu-22 **fail**, windows-20/windows-22 pass) hỏng đúng một test `evicted tasks lose their persisted record`, và `publish.yml` trên `main` cũng fail nên **không có bản npm nào được phát hành** (registry vẫn `0.1.19`). Nguyên nhân và cách tái hiện: mục 4; script đo `test-results/diag-evict.mjs`, `test-results/diag-gate.mjs`.
103
-
104
- Live, `agy` 1.4.7 thật, MCP client `streamable-http`, scratch trong `%TEMP%` (không đụng repo), hai lần:
105
-
106
- ```
107
- sweep_log : Removed a stale empty worktree directory: …\worktrees\a52a2e42-…
108
- : Kept worktree directory …\worktrees\0cfa0399-… : it still holds files for review
109
- task : completed, pid 30288, scope {checked: true, changed: 1, out_of_scope: []}
110
- cleanup : {checked: true, reaped: [30768, 24304], skipped: [], tracked: 14} (lần 1)
111
- cleanup : {checked: true, reaped: [33960, 30004, …], skipped: [], tracked: 13} (lần 2, nhịp đã thưa hơn)
112
- release log : [bridge] Task tree release: 2 process tree(s) reaped, 0 left running
113
- cwd probe : HITS 0 ← không còn process nào giữ cwd trong scratch
114
- note.txt : REAP_OK ; original workspace: [.git, a.txt] ← worktree không rò sang repo gốc
115
- shutdown : POST /ui/api/shutdown → 202, exit code 0
116
- scratch cleanup : cả hai cây %TEMP% "removed" (không cần force)
117
- ```
118
-
119
- Báo cáo đầy đủ: `test-results/reap-report.json` (gitignored). Chạy lại:
120
-
121
- ```bash
122
- npm run build && node test-results/verify-reap.mjs # cần agy đã đăng nhập; tạo scratch trong %TEMP%
123
- powershell -NoProfile -ExecutionPolicy Bypass -File test-results/probe-cwd.ps1 -Match bridge-reap
124
- ```
125
-
126
- Lưu ý: run **trước** bản sửa cho thấy `HITS 3` đúng với chuỗi tiến trình ở mục 0 (`cleanup.reaped` chỉ có 1 PID)
127
- — đó là lý do thiết kế chuyển từ "query con trực tiếp lúc exit" sang "ghi cây trong lúc chạy + một bảng tiến trình
128
- lúc reap".
129
-
130
- ## 4. Cách làm việc cho người tiếp theo
131
-
132
- - Không tự nới quyền CLI khi gặp permission denial; không thêm cờ bypass; không mở bridge ra LAN/proxy công khai.
133
- - Bridge cố ý không có token: chỉ dùng trên máy do chủ máy kiểm soát; bất kỳ tiến trình cục bộ cùng user đều điều
134
- khiển được agent.
135
- - Trước khi đụng `manager.ts`: giữ nguyên thứ tự `flushOutput → parseResult → inspectScope → release owns lock →
136
- publish status → persist → appendUsage → release lane → finish → startNext`. Test
137
- `a worktree task never reports a final status before its owns scope is attached`
138
- và `a finished task records the agent process trees it released without delaying its status`
139
- (`tests/agents.test.ts`) chèn cả hai chiều của ràng buộc đó.
140
- - Test không được phép gọi `powershell.exe`: `TaskManager` chỉ dùng `runtime.watchTree` khi được nối, mặc định inert.
141
- `createRuntime` (`src/runtime.ts`) là nơi nối `startTreeWatch`.
142
- - Không xóa `test-results/` trước khi mục 2.11 có quyết định; các file đó là cách tái hiện lỗi.
143
- - Bản ghi task swap bằng `<id>.json.tmp` rồi `rename`, nên `readdir(state/tasks)` **có thể** trả cả tên `.tmp` khi một
144
- lần ghi đang chạy. Muốn đếm số bản ghi thì lọc `.json` — `restore()` đã làm đúng như vậy. Đây chính là cách
145
- `evicted tasks lose their persisted record` hỏng trên Linux mà không hỏng trên Windows.
146
- - Bản vá muộn sau khi publish status (ghi `task.cleanup`) chỉ được viết khi manager vẫn còn giữ task đó. Retention có
147
- thể đuổi bản ghi trong lúc query tiến trình chưa về, và ghi tiếp sẽ **hồi sinh** record đã xóa. Test
148
- `a tree report that lands after eviction patches the record it still owns` (`tests/bridge.test.ts`) dựng đúng cửa sổ
149
- đó bằng cách cho `reapTree` chờ một gate; đã kiểm chứng là nó đỏ khi bỏ guard, trên cả Windows lẫn Linux.
150
- - `gh pr merge --auto` merge **ngay lập tức** khi repo không có branch protection: nó đã gộp PR #28 khi 4 job CI còn
151
- đang chạy. Muốn "gộp khi CI xanh" thì chờ `gh pr checks --watch` rồi merge thủ công, hoặc bật ruleset yêu cầu
152
- `test (ubuntu/windows × 20/22)` — hiện repo chưa có ruleset nào.