agy-mcp-bridge 0.1.30 → 0.1.31

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.
@@ -12,7 +12,7 @@ import { isOwned, normalizeOwn } from './worktree.js';
12
12
  */
13
13
  const MAX_PATCH_BYTES = 8 * 1024 * 1024;
14
14
  const MAX_FILES = 500;
15
- const gitEnvironment = () => ({
15
+ export const gitEnvironment = () => ({
16
16
  PATH: process.env.PATH, PATHEXT: process.env.PATHEXT, SystemRoot: process.env.SystemRoot, COMSPEC: process.env.COMSPEC,
17
17
  TEMP: process.env.TEMP, TMP: process.env.TMP, TMPDIR: process.env.TMPDIR,
18
18
  GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
@@ -0,0 +1,44 @@
1
+ import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { redact } from '../security/redact.js';
4
+ import { gitEnvironment } from './apply.js';
5
+ /**
6
+ * The content diff of one file in a task's worktree, for the owner to read before applying it. Read only: nothing is staged or written
7
+ * (`git add` would race with an agent that is still running), and the worktree is bridge-owned, so its files are not the owner's.
8
+ * Secrets are filtered the way task logs are.
9
+ */
10
+ const MAX_DIFF_CHARS = 200_000;
11
+ const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null';
12
+ /** A path inside the worktree, as the changes list reports it: relative, no drive, no `..`, no empty segment. */
13
+ export function isRelativeFilePath(file) {
14
+ if (typeof file !== 'string' || !file || file.length > 512 || file.includes('\0'))
15
+ return false;
16
+ if (path.isAbsolute(file) || /^[A-Za-z]:/.test(file) || file.startsWith('/') || file.startsWith('\\'))
17
+ return false;
18
+ return !file.replace(/\\/g, '/').split('/').some(part => part === '' || part === '..');
19
+ }
20
+ function capture(cwd, args) {
21
+ return new Promise(resolve => {
22
+ execFile('git', ['-c', 'core.fsmonitor=false', ...args], { cwd, env: gitEnvironment(), timeout: 20000, maxBuffer: 1024 * 1024, encoding: 'utf8', windowsHide: true }, (error, stdout) => {
23
+ const code = error ? (typeof error.code === 'number' ? error.code : null) : 0;
24
+ resolve({ stdout: stdout ?? '', code, overflow: Boolean(error) && /maxBuffer/i.test(error.message) });
25
+ });
26
+ });
27
+ }
28
+ export async function worktreeFileDiff(worktree, file) {
29
+ if (!isRelativeFilePath(file))
30
+ throw new Error('path must be a relative path inside the worktree');
31
+ const literal = `:(literal)${file}`;
32
+ // Tracked files diff against the base commit; a file the agent created is not in the index, so it is shown as all added lines.
33
+ const tracked = (await capture(worktree, ['ls-files', '--error-unmatch', '-z', '--', literal])).code === 0;
34
+ const result = tracked
35
+ ? await capture(worktree, ['diff', 'HEAD', '--no-ext-diff', '--no-textconv', '--no-color', '--', literal])
36
+ : await capture(worktree, ['diff', '--no-index', '--no-ext-diff', '--no-textconv', '--no-color', '--', NULL_DEVICE, file]);
37
+ // `git diff --no-index` exits 1 when the files differ; 128 means the file is not there at all.
38
+ if (!result.stdout && !result.overflow)
39
+ return { path: file, diff: '', truncated: false, binary: false, changed: false };
40
+ const truncated = result.overflow || result.stdout.length > MAX_DIFF_CHARS;
41
+ const text = redact(result.stdout.slice(0, MAX_DIFF_CHARS));
42
+ return { path: file, diff: text, truncated, binary: /^Binary files .* differ$/m.test(text), changed: true };
43
+ }
44
+ //# sourceMappingURL=diff.js.map
@@ -7,6 +7,7 @@ import { DEFAULT_MAX_WAIT_SECONDS, delegateSchema, promptFor } from '../security
7
7
  import { inside, validateWorkspace } from '../security/workspace.js';
8
8
  import { appendDecision } from '../decisions.js';
9
9
  import { applyWorktreeChanges, repositoryRoot } from '../isolation/apply.js';
10
+ import { worktreeFileDiff } from '../isolation/diff.js';
10
11
  import { redact } from '../security/redact.js';
11
12
  import { delegationState } from '../delegation.js';
12
13
  import { startProcess, terminateOwned } from '../antigravity/process.js';
@@ -744,14 +745,14 @@ export class TaskManager {
744
745
  this.decisionQueue = run.catch(() => undefined);
745
746
  return run;
746
747
  }
747
- async reviewableTask(id) {
748
+ async reviewableTask(id, allowRunning = false) {
748
749
  const entry = this.entries.get(id);
749
750
  if (!entry)
750
751
  throw new Error('Unknown or expired task_id');
751
752
  const task = entry.task;
752
753
  if (task.isolation !== 'worktree' || !task.worktree)
753
754
  throw new Error('This task did not run in a worktree');
754
- if (task.status === 'queued' || task.status === 'running')
755
+ if (!allowRunning && (task.status === 'queued' || task.status === 'running'))
755
756
  throw new Error('The task is still running');
756
757
  if (task.discarded_at)
757
758
  throw new Error('This task worktree was already discarded');
@@ -768,6 +769,11 @@ export class TaskManager {
768
769
  recordDecision(id, action, ok, detail) {
769
770
  return appendDecision(this.runtime.stateDirectory, { at: new Date().toISOString(), task_id: id, action, ok, detail });
770
771
  }
772
+ /** One file's content diff from a worktree task. Read only, so it is allowed while the task still runs. */
773
+ async fileDiff(id, file) {
774
+ const task = await this.reviewableTask(id, true);
775
+ return worktreeFileDiff(task.worktree, file);
776
+ }
771
777
  applyTask(id) {
772
778
  return this.enqueueDecision(async () => {
773
779
  const task = await this.reviewableTask(id);
@@ -6,6 +6,7 @@ import { paths } from '../paths.js';
6
6
  import { knownUpdate, readPackageInfo } from '../update.js';
7
7
  import { workspaceChanges } from '../changes.js';
8
8
  import { readDecisions } from '../decisions.js';
9
+ import { isRelativeFilePath } from '../isolation/diff.js';
9
10
  import { validModelId, writeModel } from '../models.js';
10
11
  import { readProgress, writeProgress } from '../progress.js';
11
12
  import { readUsage, summarizeUsage } from '../usage.js';
@@ -193,6 +194,16 @@ export async function handleUi(req, res, ctx) {
193
194
  json(res, 200, { task: await ctx.manager.discardTask(discard[1]) });
194
195
  return true;
195
196
  }
197
+ const fileDiff = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})/diff$`));
198
+ if (fileDiff && method === 'GET') {
199
+ const file = new URL(req.url ?? '', 'http://localhost').searchParams.get('path');
200
+ if (!isRelativeFilePath(file)) {
201
+ json(res, 400, { error: 'path must be a relative path inside the worktree' });
202
+ return true;
203
+ }
204
+ json(res, 200, await ctx.manager.fileDiff(fileDiff[1], file));
205
+ return true;
206
+ }
196
207
  if (pathname === '/ui/api/decisions' && method === 'GET') {
197
208
  json(res, 200, { decisions: await readDecisions(ctx.manager.stateDirectory) });
198
209
  return true;
@@ -245,12 +245,15 @@ ngân sách token theo mission — usage vẫn chỉ được ghi và tổng h
245
245
  không đổi nhánh). Bị từ chối, kèm lý do, khi: task chưa `completed`, có file ngoài `owns`, workspace không còn trong allowlist,
246
246
  workspace đang có sửa đổi chưa commit trên cùng file, patch không còn khớp (workspace đã đi tiếp), hoặc thay đổi có symlink/submodule.
247
247
  - `Bỏ worktree` xóa thư mục và nhánh `bridge/<id>` của task (giữ nguyên workspace).
248
+ - Diff nội dung từng file (lát cắt 2): bấm một file ở "Thay đổi trong workspace" của task worktree để đọc diff so với commit gốc
249
+ (`GET /ui/api/tasks/<id>/diff?path=<file>`). Chỉ đọc, không stage gì (an toàn khi task còn chạy), secret được lọc như log, chỉ nhận đường dẫn
250
+ tương đối trong worktree. Task không dùng worktree chưa có diff nội dung.
248
251
  - Nhật ký quyết định ở `<state>/decisions.jsonl` (mỗi dòng: thời điểm, task, apply/discard, ok, chi tiết), hiện trong panel và ở `GET /ui/api/decisions`.
249
252
  - Chỉ chủ bridge làm được việc này qua web UI (`POST /ui/api/tasks/<id>/apply|discard` với `confirm: true`). Không có tool MCP tương ứng, nên một
250
253
  agent hay client không tự áp dụng kết quả của chính nó.
251
254
 
252
255
  Chưa làm của Phase 4: trang Settings cho agent ưu tiên / số task song song / autonomy mặc định (hiện là file cấu hình), hàng đợi duyệt hành động
253
- ngoài chính sách, nút hợp nhất theo nhánh (commit) và diff nội dung từng file.
256
+ ngoài chính sách và nút hợp nhất theo nhánh (commit).
254
257
 
255
258
  ### Phase 5 — Tích hợp và hardening
256
259
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-mcp-bridge",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
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": [
package/ui/app.css CHANGED
@@ -150,6 +150,15 @@ details.more .body { padding: 10px 12px 12px; }
150
150
  .change-list { margin: 0; padding: 0; list-style: none; display: grid; max-height: 360px; overflow: auto; border: 1px solid var(--border); border-radius: 10px; }
151
151
  .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; }
152
152
  .change-row:first-child { border-top: 0; }
153
+ .change-row.clickable { cursor: pointer; }
154
+ .change-row.clickable:hover, .change-row.clickable:focus-visible { background: var(--surface-2); outline: none; }
155
+ .diff-box { margin-top: 12px; display: grid; gap: 8px; }
156
+ .diff { white-space: pre; overflow: auto; max-height: 460px; }
157
+ .diff span { display: block; min-height: 1.3em; }
158
+ .diff .add { color: var(--ok); background: var(--ok-soft); }
159
+ .diff .del { color: var(--bad); background: var(--bad-soft); }
160
+ .diff .hunk { color: var(--warn); }
161
+ .diff .meta { color: var(--muted); }
153
162
  .change-path { overflow-wrap: anywhere; }
154
163
  .change-count { color: var(--muted); white-space: nowrap; font-size: 12.5px; }
155
164
  .change-kind { text-align: center; padding: 1px 6px; border-radius: 6px; font-size: 11.5px; font-weight: 650; background: var(--idle-soft); color: var(--idle); }
package/ui/app.js CHANGED
@@ -375,6 +375,36 @@ function collapsible(key, title, ...body) {
375
375
  function more(key, ...body) { return collapsible(key, 'Xem thêm', ...body); }
376
376
 
377
377
  const CHANGE_LABEL = {modified: 'Sửa', deleted: 'Xóa', added: 'Thêm', 'type-changed': 'Đổi kiểu'};
378
+ /* ---------- Content diff of one changed file (worktree tasks only; read only, secrets filtered on the server) ---------- */
379
+ async function loadDiff(task, file) {
380
+ state.diff = {task_id: task.task_id, path: file, loading: true}; patch();
381
+ try { state.diff = {task_id: task.task_id, path: file, data: await api(`tasks/${task.task_id}/diff?path=${encodeURIComponent(file)}`)}; }
382
+ catch (error) { state.diff = {task_id: task.task_id, path: file, error: error.message}; }
383
+ patch();
384
+ }
385
+ /** A row is a button only where a diff can be read: a task that ran in a worktree, which the bridge owns. */
386
+ function rowProps(task, file) {
387
+ if (task.isolation !== 'worktree' || task.discarded_at) return {class: 'change-row'};
388
+ const open = () => loadDiff(task, file);
389
+ return {class: 'change-row clickable', tabindex: '0', role: 'button', 'aria-label': `Xem diff của ${file}`, onclick: open,
390
+ onkeydown: event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); } }};
391
+ }
392
+ const diffLineClass = line => line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ') || line.startsWith('index ') ? 'meta'
393
+ : line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'add' : line.startsWith('-') ? 'del' : '';
394
+ function diffViewer(task) {
395
+ const current = state.diff && state.diff.task_id === task.task_id ? state.diff : null;
396
+ if (!current) return null;
397
+ const close = h('button', {class: 'btn small', onclick: () => { state.diff = null; patch(); }, text: 'Đóng'});
398
+ const head = h('div', {class: 'change-head'}, h('strong', {class: 'mono', text: current.path}), close);
399
+ if (current.loading) return h('div', {class: 'diff-box'}, head, h('div', {class: 'typing'}, h('span', {class: 'spinner'}), 'Đang đọc diff…'));
400
+ if (current.error) return h('div', {class: 'diff-box'}, head, h('div', {class: 'callout bad', text: current.error}));
401
+ const data = current.data;
402
+ if (!data.changed) return h('div', {class: 'diff-box'}, head, h('p', {class: 'muted', text: 'File này không có thay đổi so với commit gốc của worktree.'}));
403
+ if (data.binary) return h('div', {class: 'diff-box'}, head, h('p', {class: 'muted', text: 'File nhị phân: chỉ biết là có thay đổi, không hiện được nội dung.'}));
404
+ return h('div', {class: 'diff-box'}, head,
405
+ h('pre', {class: 'log diff'}, data.diff.split('\n').map(line => h('span', {class: diffLineClass(line), text: line}))),
406
+ data.truncated ? h('div', {class: 'callout warn', text: 'Diff dài nên chỉ hiện phần đầu.'}) : null);
407
+ }
378
408
  function changesBody(task) {
379
409
  const c = state.changes && state.changes.task_id === task.task_id ? state.changes : null;
380
410
  const refresh = h('button', {class: 'btn small', onclick: () => loadChanges(task.task_id), disabled: Boolean(c && c.loading), text: c && c.data ? 'Làm mới' : 'Xem thay đổi'});
@@ -384,16 +414,18 @@ function changesBody(task) {
384
414
  const d = c.data;
385
415
  if (!d.available) return [h('div', {class: 'callout warn', text: d.reason || 'Không đọc được thay đổi.'}), refresh];
386
416
  const rows = [
387
- ...d.changes.map(item => h('li', {class: 'change-row'}, h('span', {class: `change-kind ${item.status}`, text: CHANGE_LABEL[item.status] || item.status}),
417
+ ...d.changes.map(item => h('li', rowProps(task, item.path), h('span', {class: `change-kind ${item.status}`, text: CHANGE_LABEL[item.status] || item.status}),
388
418
  h('span', {class: 'change-path mono', text: item.path}),
389
419
  h('span', {class: 'change-count mono', text: item.added === null ? 'nhị phân' : `+${item.added} −${item.deleted}`}))),
390
- ...d.untracked.map(item => h('li', {class: 'change-row'}, h('span', {class: 'change-kind untracked', text: 'Mới'}),
420
+ ...d.untracked.map(item => h('li', rowProps(task, item.path), h('span', {class: 'change-kind untracked', text: 'Mới'}),
391
421
  h('span', {class: 'change-path mono', text: item.path}), h('span', {class: 'change-count mono', text: item.bytes === null ? '' : bytes(item.bytes)})))
392
422
  ];
393
423
  return [
394
424
  h('div', {class: 'change-head'}, h('strong', {text: rows.length ? `${d.changes.length} file đã sửa hoặc xóa · ${d.untracked.length} file mới chưa theo dõi` : 'Không có thay đổi nào so với HEAD.'}), refresh),
395
- h('p', {class: 'muted', text: `So với commit ${d.head ? d.head.slice(0, 8) : '(repo chưa có commit)'}; gồm cả thay đổi đã có từ trước khi task chạy. Chỉ hiện tên file và số dòng, không có nội dung.`}),
425
+ h('p', {class: 'muted', text: `So với commit ${d.head ? d.head.slice(0, 8) : '(repo chưa có commit)'}; gồm cả thay đổi đã có từ trước khi task chạy. ${task.isolation === 'worktree' && !task.discarded_at ? 'Danh sách chỉ có tên file và số dòng; nội dung đọc ở diff từng file.' : 'Chỉ hiện tên file và số dòng, không có nội dung.'}`}),
426
+ rows.length && task.isolation === 'worktree' && !task.discarded_at ? h('p', {class: 'muted', text: 'Bấm một file để đọc nội dung thay đổi (đã lọc secret).'}) : null,
396
427
  rows.length ? h('ul', {class: 'change-list'}, rows) : null,
428
+ diffViewer(task),
397
429
  d.truncated ? h('div', {class: 'callout warn', text: 'Chỉ hiện 500 mục đầu tiên của mỗi nhóm.'}) : null
398
430
  ];
399
431
  }
@@ -437,6 +469,7 @@ function reviewBody(task) {
437
469
  h('button', {class: 'btn primary small', disabled: Boolean(block), title: block || 'Ghi thay đổi vào workspace', onclick: () => applyTask(task), text: 'Áp dụng vào workspace'}),
438
470
  h('button', {class: 'btn danger small', disabled: Boolean(task.discarded_at), onclick: () => discardTask(task), text: 'Bỏ worktree'})),
439
471
  block && !task.applied_at && !task.discarded_at ? h('p', {class: 'muted', text: block}) : null,
472
+ !task.applied_at && !task.discarded_at ? h('p', {class: 'muted', text: 'Đọc diff từng file ở mục "Thay đổi trong workspace" trước khi áp dụng.'}) : null,
440
473
  mine.length ? h('div', {}, h('div', {class: 'msg-head', text: 'Nhật ký quyết định'}),
441
474
  h('ul', {class: 'change-list'}, mine.map(item => h('li', {class: 'change-row'},
442
475
  h('span', {class: `change-kind ${item.ok ? 'added' : 'deleted'}`, text: item.action === 'apply' ? 'Áp dụng' : 'Bỏ'}),
@@ -874,7 +907,7 @@ function patch() {
874
907
  setRegion('list', JSON.stringify([state.q, state.filter, state.selected, o.budget?.task_tokens, o.tasks.map(t => stable(t))]), () => taskItems(o));
875
908
  regions.count.textContent = taskCount(o);
876
909
  const d = state.detail;
877
- const detailKey = JSON.stringify([state.selected, state.logTab, state.changes, state.decisions, d && {...d, activity: null, task: {...stable(d.task), stdout: '', stderr: '', stdout_bytes: 0, stderr_bytes: 0, stdout_truncated: false, stderr_truncated: false}}]);
910
+ const detailKey = JSON.stringify([state.selected, state.logTab, state.changes, state.decisions, state.diff, d && {...d, activity: null, task: {...stable(d.task), stdout: '', stderr: '', stdout_bytes: 0, stderr_bytes: 0, stdout_truncated: false, stderr_truncated: false}}]);
878
911
  const rebuilt = signatures.detail !== detailKey;
879
912
  setRegion('detail', detailKey, () => detailView());
880
913
  if (!rebuilt) patchLogs();