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 +3 -1
- package/dist/src/antigravity/invocation.js +2 -2
- package/dist/src/models.js +63 -0
- package/dist/src/tasks/manager.js +10 -2
- package/dist/src/ui/routes.js +34 -0
- package/package.json +1 -1
- package/ui/app.css +4 -0
- package/ui/app.js +27 -2
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ừ
|
|
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);
|
package/dist/src/ui/routes.js
CHANGED
|
@@ -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
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
|
-
|
|
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
|
}
|