agy-mcp-bridge 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -370,7 +370,9 @@ Khi `npm start` đang chạy, mở `http://127.0.0.1:3000/ui` (đúng cổng đ
370
370
 
371
371
  **Thay đổi trong workspace:** mỗi task có mục "Thay đổi trong workspace" (mở ra mới đọc, có nút Làm mới) liệt kê file đã sửa, xóa, file mới chưa theo dõi và số dòng thêm/xóa so với commit HEAD, kể cả thay đổi có từ trước khi task chạy. Chỉ hiện tên file và số dòng, không hiện nội dung, và chỉ cho workspace vẫn còn trong danh sách được phép. Để đọc an toàn, bridge **không bao giờ nạp cấu hình của repo**: `git status`/`git diff` thông thường chạy cả những lệnh do chính repo khai báo (`core.fsmonitor`, `filter.*.clean`, `diff.*.textconv`), mà Antigravity có quyền ghi file nên có thể cài sẵn vào `.git/config`; chỉ tắt trình diff ngoài là chưa đủ (đã thử: lệnh `filter clean` vẫn chạy). Thay vào đó bridge so sánh trong một thư mục git tạm với cấu hình rỗng, chỉ mượn kho đối tượng của repo, tắt cấu hình hệ thống/người dùng, và chỉ sao chép vài thiết lập xuống dòng/quyền file đã kiểm tra giá trị (`core.autocrlf`, `eol`, `filemode`, `symlinks`, `ignorecase`). Có test dựng repo "nhiễm độc" để chứng minh không lệnh nào bị chạy. Giới hạn: cần `git` trong PATH, chỉ tính khác biệt nội dung (file chỉ khác kiểu xuống dòng sau chuyển đổi không tính), tối đa 500 mục mỗi nhóm, không hiện submodule, và không thay thế việc tự xem diff đầy đủ.
372
372
 
373
- Lưu ý bảo mật: nội dung yêu cầu và phản hồi được hiển thị dạng văn bản thuần, trang dùng CSP không cho script/style nội tuyến hay tải từ nơi khác, nên output của model không thể thực thi. Yêu cầu của Tech Lead được lưu cùng bản ghi task trong `.bridge-state/tasks` (đã qua lọc secret nhận diện được) để xem lại sau khi restart; đừng đưa secret vào task. Giao diện chỉ đọc, ngoại trừ hai thao tác cần phiên đăng nhập: hủy task và bật/tắt giao việc. Mã nguồn giao diện nằm ở `ui/` (HTML/CSS/JS thuần, không cần build), phần máy chủ ở `src/ui/`.
373
+ Lưu ý bảo mật: nội dung yêu cầu và phản hồi được hiển thị dạng văn bản thuần, trang dùng CSP không cho script/style nội tuyến hay tải từ nơi khác, nên output của model không thể thực thi. Yêu cầu của Tech Lead được lưu cùng bản ghi task trong `.bridge-state/tasks` (đã qua lọc secret nhận diện được) để xem lại sau khi restart; đừng đưa secret vào task. Giao diện chỉ đọc, ngoại trừ các thao tác cần phiên đăng nhập: hủy task, bật/tắt giao việc và chọn model. Mã nguồn giao diện nằm ở `ui/` (HTML/CSS/JS thuần, không cần build), phần máy chủ ở `src/ui/`.
374
+
375
+ **Chọn model:** mục Cài đặt → Model liệt kê các model lấy từ `agy models` (chỉ chạy khi mở trang Cài đặt). Chọn một model thì bridge truyền `--model <id>` cho mọi task mới; chọn "Mặc định của Antigravity" thì không truyền cờ nào. Có hiệu lực ngay, 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, và mỗi task ghi lại model đã dùng (hiện trong chi tiết task). Chỉ nhận id nằm trong danh sách của `agy models`; nếu không đọc được danh sách thì không cho đổi. Lựa chọn lưu ở `.bridge-state/model.json`. Codex/Claude không chọn được model theo từng task (không thêm tham số vào tool MCP nên không tốn thêm token).
374
376
 
375
377
  ## 11. Ghi chú thiết kế và việc chưa làm
376
378
 
@@ -1,5 +1,5 @@
1
- export function invocationFor(prompt, timeout, transport) {
2
- const common = ['--print-timeout', `${timeout}s`, '--disable-slash-commands'];
1
+ export function invocationFor(prompt, timeout, transport, model = null) {
2
+ const common = ['--print-timeout', `${timeout}s`, '--disable-slash-commands', ...(model ? ['--model', model] : [])];
3
3
  return transport === 'argv'
4
4
  ? { args: ['--print', prompt, '--output-format', 'json', ...common], stdin: undefined }
5
5
  : { args: ['--input-format', 'stream-json', '--output-format', 'stream-json', ...common], stdin: JSON.stringify({ event: 'user', message: { content: prompt } }) + '\n' };
@@ -0,0 +1,63 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { startProcess, terminateOwned } from './antigravity/process.js';
4
+ export const MODEL_FILE = 'model.json';
5
+ // Ids go straight into the agy command line, so only plain identifier characters are ever accepted (and never a leading dash).
6
+ const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
7
+ export const validModelId = (value) => typeof value === 'string' && MODEL_ID.test(value);
8
+ /** Parses `agy models`: one model per line, the id first, then the display name after two or more spaces. */
9
+ export function parseModels(text) {
10
+ const models = [];
11
+ for (const line of text.split(/\r?\n/)) {
12
+ const match = line.match(/^(\S+)\s{2,}(.+?)\s*$/);
13
+ if (match && validModelId(match[1]) && !models.some(model => model.id === match[1]))
14
+ models.push({ id: match[1], label: match[2].slice(0, 100) });
15
+ }
16
+ return models;
17
+ }
18
+ /** Runs `agy models`; rejects when the command fails, times out or prints nothing recognizable. */
19
+ export function listModels(executable, cwd, extraEnvPassthrough = []) {
20
+ return new Promise((resolve, reject) => {
21
+ const child = startProcess(executable, ['models'], cwd, { extraEnvPassthrough });
22
+ let text = '';
23
+ let timedOut = false;
24
+ const timer = setTimeout(() => { timedOut = true; void terminateOwned(child).catch(() => { }); }, 10000);
25
+ const collect = (chunk) => { if (text.length < 32768)
26
+ text += chunk.toString().slice(0, 32768 - text.length); };
27
+ child.stdout.on('data', collect);
28
+ child.once('error', error => { clearTimeout(timer); reject(error); });
29
+ child.once('close', code => {
30
+ clearTimeout(timer);
31
+ const models = parseModels(text);
32
+ if (timedOut)
33
+ reject(new Error('`agy models` timed out'));
34
+ else if (code !== 0 || !models.length)
35
+ reject(new Error('`agy models` did not return a model list'));
36
+ else
37
+ resolve(models);
38
+ });
39
+ });
40
+ }
41
+ /** The model chosen in the web UI, or null to leave the choice to Antigravity. Read on every task, so a change needs no restart. */
42
+ export async function readModel(stateDirectory) {
43
+ try {
44
+ const value = JSON.parse(await readFile(path.join(stateDirectory, MODEL_FILE), 'utf8')).model;
45
+ return validModelId(value) ? value : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export async function writeModel(stateDirectory, model) {
52
+ const file = path.join(stateDirectory, MODEL_FILE);
53
+ if (model === null) {
54
+ await rm(file, { force: true });
55
+ return;
56
+ }
57
+ if (!validModelId(model))
58
+ throw new Error('Invalid model id');
59
+ await mkdir(stateDirectory, { recursive: true });
60
+ await writeFile(`${file}.tmp`, JSON.stringify({ model }) + '\n');
61
+ await rename(`${file}.tmp`, file);
62
+ }
63
+ //# sourceMappingURL=models.js.map
@@ -8,6 +8,8 @@ import { redact } from '../security/redact.js';
8
8
  import { delegationState } from '../delegation.js';
9
9
  import { startProcess, terminateOwned } from '../antigravity/process.js';
10
10
  import { invocationFor, finalEnvelope } from '../antigravity/invocation.js';
11
+ import { packageRoot } from '../paths.js';
12
+ import { listModels, readModel } from '../models.js';
11
13
  import { pidAlive, recoverExecutionLock } from './execution-lock.js';
12
14
  const TASK_FILE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/;
13
15
  export class TaskManager {
@@ -69,7 +71,8 @@ export class TaskManager {
69
71
  request: {
70
72
  task: redact(input.task), acceptance_criteria: input.acceptance_criteria.map(redact),
71
73
  context: input.context === undefined ? null : redact(input.context), timeout_seconds: timeout,
72
- client: meta.client ? redact(meta.client).slice(0, 100) : null
74
+ client: meta.client ? redact(meta.client).slice(0, 100) : null,
75
+ model: await readModel(this.runtime.stateDirectory)
73
76
  }
74
77
  };
75
78
  this.entries.set(task.task_id, entry);
@@ -140,7 +143,7 @@ export class TaskManager {
140
143
  task.started_at = new Date().toISOString();
141
144
  await this.persist(task);
142
145
  checkStarting();
143
- const invocation = invocationFor(promptFor({ ...input, workspace }, entry.request?.client), timeout, this.config.prompt_transport);
146
+ const invocation = invocationFor(promptFor({ ...input, workspace }, entry.request?.client), timeout, this.config.prompt_transport, entry.request?.model ?? null);
144
147
  const options = { extraEnvPassthrough: this.config.extra_env_passthrough, stdin: invocation.stdin };
145
148
  const child = this.runtime.launch ? this.runtime.launch(invocation.args, workspace, options) : startProcess(this.runtime.executable, invocation.args, workspace, options);
146
149
  entry.child = child;
@@ -314,6 +317,11 @@ export class TaskManager {
314
317
  .map(([id, entry]) => ({ ...this.summary(id), title: entry.request?.task.split('\n').find(line => line.trim())?.trim().slice(0, 140) ?? null }))
315
318
  .sort((a, b) => Date.parse(b.queued_at ?? b.started_at) - Date.parse(a.queued_at ?? a.started_at));
316
319
  }
320
+ /** Models offered by the installed Antigravity CLI (read on demand, never at startup). */
321
+ availableModels() {
322
+ return this.runtime.models ? this.runtime.models() : listModels(this.runtime.executable, packageRoot, this.config.extra_env_passthrough);
323
+ }
324
+ currentModel() { return readModel(this.runtime.stateDirectory); }
317
325
  /** Full record for the web UI: the request, the exact prompt shape sent to the CLI (redacted), and the task with logs. */
318
326
  detail(id) {
319
327
  const task = this.get(id);
@@ -6,6 +6,7 @@ import { redact } from '../security/redact.js';
6
6
  import { paths } from '../paths.js';
7
7
  import { knownUpdate, readPackageInfo } from '../update.js';
8
8
  import { workspaceChanges } from '../changes.js';
9
+ import { validModelId, writeModel } from '../models.js';
9
10
  import { inside } from '../security/workspace.js';
10
11
  const assets = {
11
12
  '/ui': { file: 'index.html', type: 'text/html; charset=utf-8' },
@@ -51,6 +52,7 @@ async function overview(ctx) {
51
52
  },
52
53
  limits: env.limits ?? {}, allowed_workspaces: env.allowed_workspaces ?? [], session_workspaces: env.session_workspaces ?? [], limitations: env.limitations ?? [],
53
54
  delegation: { ...state, controllable: delegationControllable() },
55
+ model: await ctx.manager.currentModel(),
54
56
  queue: ctx.manager.overview().queue,
55
57
  warnings: [await ctx.manager.lockDiagnostic()].filter(Boolean),
56
58
  update: knownUpdate() && { ...knownUpdate(), package: readPackageInfo().name, command: 'agy-mcp-bridge update' },
@@ -162,6 +164,38 @@ export async function handleUi(req, res, ctx) {
162
164
  json(res, 200, { ...delegationState(ctx.manager.stateDirectory), controllable: true });
163
165
  return true;
164
166
  }
167
+ if (pathname === '/ui/api/models' && method === 'GET') {
168
+ const current = await ctx.manager.currentModel();
169
+ try {
170
+ json(res, 200, { models: await ctx.manager.availableModels(), current });
171
+ }
172
+ catch (error) {
173
+ json(res, 200, { models: [], current, error: error instanceof Error ? redact(error.message) : 'Could not list models' });
174
+ }
175
+ return true;
176
+ }
177
+ if (pathname === '/ui/api/model' && method === 'POST') {
178
+ const body = await readJson(req);
179
+ if (body.model !== null && !validModelId(body.model)) {
180
+ json(res, 400, { error: 'model must be a model id or null' });
181
+ return true;
182
+ }
183
+ // Only ids that `agy models` lists are accepted; if the list cannot be read, the choice cannot be verified, so it is refused.
184
+ if (body.model !== null) {
185
+ const listed = await ctx.manager.availableModels().catch(() => null);
186
+ if (!listed) {
187
+ json(res, 409, { error: 'Cannot verify the model: `agy models` is not available' });
188
+ return true;
189
+ }
190
+ if (!listed.some(model => model.id === body.model)) {
191
+ json(res, 400, { error: 'Unknown model; choose one listed by `agy models`' });
192
+ return true;
193
+ }
194
+ }
195
+ await writeModel(ctx.manager.stateDirectory, body.model);
196
+ json(res, 200, { current: body.model });
197
+ return true;
198
+ }
165
199
  json(res, 404, { error: 'Not found' });
166
200
  }
167
201
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-mcp-bridge",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Local authenticated HTTP MCP bridge that lets Codex and Claude Code delegate bounded coding tasks to the Antigravity CLI (agy)",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/ui/app.css CHANGED
@@ -203,3 +203,7 @@ pre.log { margin: 0; padding: 12px; border-radius: 10px; background: #0b0e14; co
203
203
  .login-card { padding: 24px; }
204
204
  }
205
205
  @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
206
+
207
+ .select { max-width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); font: inherit; color: inherit; }
208
+ .select:focus { border-color: var(--accent); }
209
+ .select:disabled { opacity: .55; cursor: not-allowed; }
package/ui/app.js CHANGED
@@ -135,6 +135,15 @@ async function setDelegation(enabled) {
135
135
  try { await api('delegation', {method: 'POST', body: {enabled}}); notify(enabled ? 'Đã bật giao việc cho Antigravity' : 'Đã tắt giao việc cho Antigravity'); await refresh(); }
136
136
  catch (error) { notify(error.message); }
137
137
  }
138
+ async function loadModels() {
139
+ state.modelsLoading = true;
140
+ try { state.models = await api('models'); } catch (error) { state.models = {models: [], current: null, error: error.message}; }
141
+ state.modelsLoading = false; patch();
142
+ }
143
+ async function setModel(model) {
144
+ try { await api('model', {method: 'POST', body: {model}}); notify(model ? 'Đã đổi model cho các task mới' : 'Đã về model mặc định của Antigravity'); state.models = null; await refresh(); }
145
+ catch (error) { notify(error.message); await refresh(); }
146
+ }
138
147
  async function copy(text) {
139
148
  try { await navigator.clipboard.writeText(text); notify('Đã sao chép'); } catch { notify('Không thể sao chép tự động'); }
140
149
  }
@@ -248,7 +257,7 @@ function detailView() {
248
257
  const facts = h('dl', {class: 'kv'},
249
258
  ...[['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)],
250
259
  ['Thời gian chạy', running ? elapsedEl(task.started_at) : queued ? '—' : duration(task.duration_ms)],
251
- ['Giới hạn', request ? `${request.timeout_seconds}s` : '—'], ['Exit code', task.exit_code ?? '—'], ['PID', task.pid ?? '—'],
260
+ ['Giới hạn', request ? `${request.timeout_seconds}s` : '—'], ['Model', request && 'model' in request ? request.model || 'mặc định' : '—'], ['Exit code', task.exit_code ?? '—'], ['PID', task.pid ?? '—'],
252
261
  ['CLI status', task.cli_status ?? '—']].map(([k, v]) => h('div', {}, h('dt', {text: k}), h('dd', {}, typeof v === 'string' || typeof v === 'number' ? String(v) : v))));
253
262
 
254
263
  const asker = request?.client ? `Tech Lead · ${request.client}` : 'Tech Lead (Codex / Claude)';
@@ -301,6 +310,20 @@ function patchLogs() {
301
310
  }
302
311
 
303
312
  function row(label, value) { return h('div', {}, h('dt', {text: label}), h('dd', {text: String(value)})); }
313
+ function modelCard(o) {
314
+ const loaded = state.models;
315
+ const known = (loaded?.models || []).some(m => m.id === o.model);
316
+ const select = h('select', {class: 'select', 'aria-label': 'Model cho task mới', disabled: !loaded || Boolean(loaded.error), onchange: event => setModel(event.target.value || null)},
317
+ h('option', {value: '', text: 'Mặc định của Antigravity'}),
318
+ ...(loaded?.models || []).map(m => h('option', {value: m.id, text: `${m.label} (${m.id})`})),
319
+ o.model && !known ? h('option', {value: o.model, text: `${o.model} (không còn trong danh sách)`}) : null);
320
+ select.value = o.model || '';
321
+ return h('section', {class: 'card panel wide'}, h('h2', {text: 'Model'}),
322
+ h('div', {class: 'switch-row'}, h('div', {}, h('strong', {text: o.model || 'Mặc định của Antigravity'}),
323
+ 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),
324
+ loaded?.error ? h('div', {class: 'callout warn', text: `Không lấy được danh sách model: ${loaded.error}`}) : null,
325
+ 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.'}));
326
+ }
304
327
  function settingsView(o) {
305
328
  const delegation = o.delegation;
306
329
  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.'}))),
@@ -311,6 +334,7 @@ function settingsView(o) {
311
334
  switchControl(delegation.enabled, delegation.controllable, 'Cho phép giao việc cho Antigravity')),
312
335
  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.'})
313
336
  : 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ở.'})),
337
+ modelCard(o),
314
338
  h('section', {class: 'card panel'}, h('h2', {text: 'Kết nối'}),
315
339
  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'})),
316
340
  h('dl', {class: 'rows'}, row('Giao thức', 'MCP Streamable HTTP'), row('Xác thực', 'Bearer token dùng chung'), row('Phiên bản bridge', o.server.version),
@@ -388,7 +412,8 @@ function patch() {
388
412
  setRegion('detail', detailKey, () => detailView());
389
413
  if (!rebuilt) patchLogs();
390
414
  } else {
391
- setRegion('settings', JSON.stringify([o.cli, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));
415
+ if (!state.models && !state.modelsLoading) loadModels();
416
+ setRegion('settings', JSON.stringify([state.models, o.model, o.cli, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));
392
417
  }
393
418
  tick();
394
419
  }