agy-mcp-bridge 0.1.15 → 0.1.17

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
@@ -411,6 +411,12 @@ Lưu ý bảo mật: nội dung yêu cầu và phản hồi được hiển th
411
411
 
412
412
  **Mức token (đèn cảnh báo):** Cài đặt → "Mức token" cho đặt hai mức, mặc định **không giới hạn**: mỗi task và mỗi ngày (theo giờ máy của bạn). Danh sách task hiện số token (`total_tokens` agy báo, hoặc input + output nếu không có) và đánh dấu "vượt mức" task nào quá mức; thẻ "Token hôm nay" ở trang Task đổi sang "Vượt mức" khi tổng ngày chạm mức. Đâ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 có lệnh nào cho bridge đọc hạn mức, và token của một task chỉ biết khi nó kết thúc. Mức lưu ở `.bridge-state/budget.json`, đổi không cần khởi động lại.
413
413
 
414
+ **Nhịp tim khi chờ (thử nghiệm):** nếu client gửi kèm `progressToken` (chuẩn MCP), `antigravity_task_wait` gửi một thông báo tiến độ mỗi 10 giây trong lúc giữ lệnh (nội dung là dòng `activity`). Client nào đặt lại đồng hồ timeout khi nhận tiến độ thì có thể chờ lâu hơn `tool_timeout_sec`; client không làm vậy thì bỏ qua và mọi thứ chạy như cũ. **Chưa biết Codex / Claude Code có đặt lại timeout theo tiến độ hay không**: cách thử là để `tool_timeout_sec` ở mức mặc định (60), đặt thời gian chờ 300 giây trong Cài đặt, giao một task chạy quá 1 phút và xem lệnh chờ có bị báo timeout không. Nếu vẫn timeout thì cần đặt `tool_timeout_sec` như trước. Không có token thì không gửi gì.
415
+
416
+ **Ghi cấu hình Codex bằng một lệnh:** `agy-mcp-bridge setup codex --apply` hiện đúng thay đổi sẽ ghi vào `~/.codex/config.toml` (hoặc `$CODEX_HOME/config.toml`), hỏi xác nhận (`--yes` để bỏ qua câu hỏi), sao lưu file cũ thành `config.toml.bak-<thời gian>` rồi mới ghi. Nếu mục `[mcp_servers.antigravity]` đã có thì chỉ đổi dòng `tool_timeout_sec` (mặc định **3700** giây, lớn hơn mức chờ tối đa 60 phút của giao diện nên đổi mức chờ không cần chạy lại; đổi bằng `--tool-timeout N`); nếu chưa có thì thêm cả mục. Kiểu viết nó không đọc chắc chắn được (inline table, khóa trong ngoặc kép, hai mục trùng) thì lệnh từ chối và bạn sửa tay. File CRLF giữ nguyên CRLF. Claude Code không bị sửa. Sau khi ghi, mở lại Codex.
417
+
418
+ **Kết quả "đang chạy" gọn hơn:** khi task còn chạy, `antigravity_task_wait`/`status` chỉ trả `task_id`, `status`, `duration_ms`, `activity` và `next_step` (ngắn). Đường dẫn, số byte, pid... xem ở giao diện web hoặc trong trạng thái đầy đủ khi task đã kết thúc. Mỗi lần Codex hỏi lại vì thế tốn ít token hơn.
419
+
414
420
  - **Đây là mức dùng qua bridge, không phải hạn mức.** `agy` không có lệnh xem hạn mức còn lại và bridge không bịa ra con số đó; xem hạn mức trên tài khoản Antigravity của bạn.
415
421
  - Bridge **không biết trước** các trường bên trong `usage` (tên trường do `agy` quyết định): nó cộng mọi giá trị số; một vài tên phổ biến (`input_tokens`, `output_tokens`, `total_tokens`...) được dịch nhãn. Task nào kết quả không có `usage` thì ghi nhận là "không có", không phải 0.
416
422
  - Chỉ thống kê task bắt đầu từ bản có tính năng này trở đi. Số liệu usage không được gửi cho Codex/Claude (không tốn token).
package/dist/src/cli.js CHANGED
@@ -9,6 +9,7 @@ import { existsSync } from 'node:fs';
9
9
  import { addSessionWorkspaces } from './config.js';
10
10
  import { recoverExecutionLock } from './tasks/execution-lock.js';
11
11
  import { renderSetup } from './setup.js';
12
+ import { DEFAULT_TOOL_TIMEOUT_SECONDS, codexConfigPath, planCodexConfig, readCodexConfig, writeCodexConfig } from './codex-config.js';
12
13
  import { readWait } from './wait-setting.js';
13
14
  import { configuredPort } from './http.js';
14
15
  import { offerSkillInstall, offerSkillUpdate } from './onboarding.js';
@@ -39,6 +40,9 @@ Usage: ${BIN} [command] [options]
39
40
  packaged copy; a file you edited yourself is never overwritten
40
41
  setup codex|claude|both [--port N] [--shell powershell|cmd|bash]
41
42
  Print the exact client configuration and token commands (changes nothing)
43
+ setup codex|both --apply [--yes] [--tool-timeout N] [--port N]
44
+ Write the Codex tool timeout (default 3700 s) into config.toml after showing the change and asking;
45
+ a backup is made first. Without an antigravity entry it adds one. Claude Code is never edited
42
46
  config [--json] Show where config, state and the token file live (never prints the token)
43
47
  update [--check] Install the newest version from npm (only for a global install); --check only reports
44
48
  unlock Reclaim a lock only when both recorded server and child PIDs are dead
@@ -167,12 +171,16 @@ async function updateCommand(args) {
167
171
  }
168
172
  process.exitCode = status;
169
173
  }
170
- function setup(args) {
174
+ async function setup(args) {
171
175
  const target = args[0];
176
+ const usage = `Usage: ${BIN} setup codex|claude|both [--port N] [--shell powershell|cmd|bash] | setup codex|both --apply [--yes] [--tool-timeout N] [--port N]`;
172
177
  if (target !== 'codex' && target !== 'claude' && target !== 'both')
173
- throw new Error(`Usage: ${BIN} setup codex|claude|both [--port N] [--shell powershell|cmd|bash]`);
178
+ throw new Error(usage);
174
179
  let port = process.env.PORT !== undefined && process.env.PORT !== '0' ? configuredPort(process.env.PORT) : 3000;
175
180
  let shell = process.platform === 'win32' ? ['powershell', 'cmd'] : ['bash'];
181
+ let apply = false;
182
+ let yes = false;
183
+ let toolTimeout = DEFAULT_TOOL_TIMEOUT_SECONDS;
176
184
  for (let i = 1; i < args.length; i++) {
177
185
  if (args[i] === '--port' && args[i + 1] !== undefined)
178
186
  port = configuredPort(args[++i]);
@@ -182,14 +190,66 @@ function setup(args) {
182
190
  throw new Error('--shell must be powershell, cmd or bash');
183
191
  shell = [chosen];
184
192
  }
193
+ else if (args[i] === '--apply')
194
+ apply = true;
195
+ else if (args[i] === '--yes')
196
+ yes = true;
197
+ else if (args[i] === '--tool-timeout' && args[i + 1] !== undefined)
198
+ toolTimeout = Number(args[++i]);
185
199
  else
186
200
  throw new Error(`Unknown option for setup: ${args[i]}`);
187
201
  }
188
202
  if (port === 0)
189
203
  throw new Error('Give a fixed port: --port 3000 (0 picks a different port every run, so a client could not be configured)');
204
+ const mcpUrl = `http://127.0.0.1:${port}/mcp`;
205
+ if (apply) {
206
+ await applyCodexSetup(target, mcpUrl, toolTimeout, yes);
207
+ return;
208
+ }
190
209
  const tokenFile = path.join(paths.stateDirectory, 'http-token');
191
210
  const clients = target === 'both' ? ['codex', 'claude'] : [target];
192
- console.log(renderSetup({ clients, mcpUrl: `http://127.0.0.1:${port}/mcp`, tokenFile, tokenFileExists: existsSync(tokenFile), tokenFromEnv: Boolean(process.env.BRIDGE_HTTP_TOKEN), shells: shell, bin: BIN, waitSeconds: readWait(paths.stateDirectory) ?? undefined }));
211
+ console.log(renderSetup({ clients, mcpUrl, tokenFile, tokenFileExists: existsSync(tokenFile), tokenFromEnv: Boolean(process.env.BRIDGE_HTTP_TOKEN), shells: shell, bin: BIN, waitSeconds: readWait(paths.stateDirectory) ?? undefined }));
212
+ }
213
+ /** Shows exactly what would change in Codex's config.toml and writes it only after a yes (or --yes). */
214
+ async function applyCodexSetup(target, mcpUrl, toolTimeout, yes) {
215
+ if (target === 'claude')
216
+ throw new Error('--apply edits Codex only. Claude Code is configured with the claude mcp add command printed by setup claude.');
217
+ const file = codexConfigPath();
218
+ const plan = planCodexConfig(await readCodexConfig(file), { mcpUrl, toolTimeoutSeconds: toolTimeout });
219
+ if (plan.action === 'refuse') {
220
+ console.error(`Not changed: ${plan.reason}. Edit ${file} by hand (see: ${BIN} setup codex).`);
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+ if (plan.action === 'none') {
225
+ console.log(`Nothing to do: ${plan.reason} in ${file}.`);
226
+ return;
227
+ }
228
+ console.log(`File: ${file}`, `\nChange (${plan.action}):`, `\n before: ${plan.before}`, `\n after: ${plan.after.split('\n').join('\n ')}`);
229
+ console.log(`\nA tool timeout of ${toolTimeout} s lets one antigravity_task_wait call be held for as long as the web UI allows. If the bridge ever hangs, Codex waits that long before reporting an error.`);
230
+ if (!yes) {
231
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
232
+ console.error('Not changed: this is not an interactive terminal. Run it in a terminal, or add --yes.');
233
+ process.exitCode = 1;
234
+ return;
235
+ }
236
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
237
+ try {
238
+ const answer = (await rl.question('Apply this change? [y/N] ')).trim().toLowerCase();
239
+ if (answer !== 'y' && answer !== 'yes') {
240
+ console.log('Not changed.');
241
+ return;
242
+ }
243
+ }
244
+ finally {
245
+ rl.close();
246
+ }
247
+ }
248
+ const backup = await writeCodexConfig(file, plan);
249
+ console.log(backup ? `Done. Backup: ${backup}` : 'Done (file created).');
250
+ console.log('Restart Codex so it reads the new setting. The token variable still has to be set: see the first step of ' + `${BIN} setup codex.`);
251
+ if (target === 'both')
252
+ console.log('Claude Code was not edited; see: ' + `${BIN} setup claude.`);
193
253
  }
194
254
  async function workspaces(args) {
195
255
  const [action, target] = args;
@@ -0,0 +1,82 @@
1
+ import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /**
5
+ * Edits Codex's config.toml for `setup codex --apply`. Deliberately narrow: it only touches the [mcp_servers.antigravity] table, changes only
6
+ * tool_timeout_sec when the table exists, appends a complete table when there is none, and refuses any layout it cannot read with certainty
7
+ * (the user then edits by hand). The caller shows the plan and asks before anything is written; a backup is made first.
8
+ */
9
+ export const MIN_TOOL_TIMEOUT_SECONDS = 60;
10
+ export const MAX_TOOL_TIMEOUT_SECONDS = 86400;
11
+ /** Above the longest wait the web UI allows (60 minutes), so changing the wait there never needs this file again. */
12
+ export const DEFAULT_TOOL_TIMEOUT_SECONDS = 3700;
13
+ export const codexConfigPath = (env = process.env) => path.join(env.CODEX_HOME && env.CODEX_HOME.trim() ? env.CODEX_HOME : path.join(os.homedir(), '.codex'), 'config.toml');
14
+ const HEADER = /^\s*\[mcp_servers\.antigravity\]\s*(#.*)?$/;
15
+ const ANY_TABLE = /^\s*\[/;
16
+ const TIMEOUT_LINE = /^\s*tool_timeout_sec\s*=/;
17
+ export function planCodexConfig(existing, input) {
18
+ const value = input.toolTimeoutSeconds;
19
+ if (!Number.isInteger(value) || value < MIN_TOOL_TIMEOUT_SECONDS || value > MAX_TOOL_TIMEOUT_SECONDS) {
20
+ return { action: 'refuse', reason: `the tool timeout must be a whole number of seconds from ${MIN_TOOL_TIMEOUT_SECONDS} to ${MAX_TOOL_TIMEOUT_SECONDS}` };
21
+ }
22
+ const newline = existing !== null && existing.includes('\r\n') ? '\r\n' : '\n';
23
+ const line = `tool_timeout_sec = ${value}`;
24
+ const block = ['[mcp_servers.antigravity]', `url = '${input.mcpUrl.replace(/'/g, '')}'`, "bearer_token_env_var = 'BRIDGE_HTTP_TOKEN'", 'startup_timeout_sec = 30', line, 'enabled = true'];
25
+ if (existing === null)
26
+ return { action: 'create', text: block.join(newline) + newline, before: '(no file yet)', after: block.join(newline) };
27
+ const lines = existing.split(/\r?\n/);
28
+ const start = lines.findIndex(entry => HEADER.test(entry));
29
+ if (start === -1) {
30
+ if (/mcp_servers[\s\S]{0,40}antigravity/.test(existing))
31
+ return { action: 'refuse', reason: 'an antigravity entry exists in a form this command does not rewrite (inline table, dotted or quoted key)' };
32
+ const separator = existing.length === 0 || existing.endsWith('\n') ? (existing.length === 0 ? '' : newline) : newline + newline;
33
+ return { action: 'append', text: existing + separator + block.join(newline) + newline, before: '(no [mcp_servers.antigravity] table)', after: block.join(newline) };
34
+ }
35
+ if (lines.slice(start + 1).some(entry => HEADER.test(entry)))
36
+ return { action: 'refuse', reason: 'there is more than one [mcp_servers.antigravity] table; remove the extra one first' };
37
+ let end = lines.length;
38
+ for (let i = start + 1; i < lines.length; i++)
39
+ if (ANY_TABLE.test(lines[i])) {
40
+ end = i;
41
+ break;
42
+ }
43
+ const at = lines.slice(start + 1, end).findIndex(entry => TIMEOUT_LINE.test(entry));
44
+ if (at !== -1) {
45
+ const index = start + 1 + at;
46
+ if (lines[index].replace(/\s+#.*$/, '').trim() === line)
47
+ return { action: 'none', reason: `tool_timeout_sec is already ${value}` };
48
+ const before = lines[index].trim();
49
+ const next = lines.slice();
50
+ next[index] = line;
51
+ return { action: 'update', text: next.join(newline), before, after: line };
52
+ }
53
+ let insertAfter = end - 1;
54
+ while (insertAfter > start && lines[insertAfter].trim() === '')
55
+ insertAfter--;
56
+ const next = lines.slice();
57
+ next.splice(insertAfter + 1, 0, line);
58
+ return { action: 'update', text: next.join(newline), before: '(no tool_timeout_sec: Codex uses its 60 s default)', after: line };
59
+ }
60
+ /** Backs the file up, then replaces it atomically. Returns the backup path (null when there was no file). */
61
+ export async function writeCodexConfig(file, plan, now = new Date()) {
62
+ await mkdir(path.dirname(file), { recursive: true });
63
+ let backup = null;
64
+ if (plan.action !== 'create') {
65
+ backup = `${file}.bak-${now.toISOString().replace(/[-:T]/g, '').slice(0, 14)}`;
66
+ await copyFile(file, backup);
67
+ }
68
+ await writeFile(`${file}.tmp`, plan.text);
69
+ await rename(`${file}.tmp`, file);
70
+ return backup;
71
+ }
72
+ export async function readCodexConfig(file) {
73
+ try {
74
+ return await readFile(file, 'utf8');
75
+ }
76
+ catch (error) {
77
+ if (error.code === 'ENOENT')
78
+ return null;
79
+ throw error;
80
+ }
81
+ }
82
+ //# sourceMappingURL=codex-config.js.map
package/dist/src/setup.js CHANGED
@@ -25,7 +25,7 @@ export function renderSetup(input) {
25
25
  add(' An IDE or desktop app must be restarted from an environment that has this variable; setting it in another terminal does not reach an app that is already running.', '');
26
26
  let step = 2;
27
27
  if (input.clients.includes('codex')) {
28
- add(`${step++}. Codex`, ' Either run:', ` codex mcp add antigravity --url ${posix(input.mcpUrl)} --bearer-token-env-var BRIDGE_HTTP_TOKEN`, ' or merge this block into ~/.codex/config.toml (or $CODEX_HOME/config.toml); replace an existing [mcp_servers.antigravity] entry instead of adding a second one:', '', ' [mcp_servers.antigravity]', ` url = '${input.mcpUrl}'`, " bearer_token_env_var = 'BRIDGE_HTTP_TOKEN'", ' startup_timeout_sec = 30', ` tool_timeout_sec = ${clientToolTimeoutSeconds(input.waitSeconds ?? 55)}`, ' enabled = true', '', ' Check: codex mcp get antigravity (then ask Codex to call antigravity_environment)', '');
28
+ add(`${step++}. Codex`, ' Either run:', ` codex mcp add antigravity --url ${posix(input.mcpUrl)} --bearer-token-env-var BRIDGE_HTTP_TOKEN`, ' or merge this block into ~/.codex/config.toml (or $CODEX_HOME/config.toml); replace an existing [mcp_servers.antigravity] entry instead of adding a second one:', '', ' [mcp_servers.antigravity]', ` url = '${input.mcpUrl}'`, " bearer_token_env_var = 'BRIDGE_HTTP_TOKEN'", ' startup_timeout_sec = 30', ` tool_timeout_sec = ${clientToolTimeoutSeconds(input.waitSeconds ?? 55)}`, ' enabled = true', '', ' Check: codex mcp get antigravity (then ask Codex to call antigravity_environment)', ` To only set the tool timeout (and add the entry when it is missing) without editing the file yourself: ${input.bin} setup codex --apply`, '');
29
29
  }
30
30
  if (input.clients.includes('claude')) {
31
31
  add(`${step++}. Claude Code`, ' Either run (single quotes keep ${BRIDGE_HTTP_TOKEN} unexpanded so the token is not stored in the config):');
@@ -515,6 +515,8 @@ export class TaskManager {
515
515
  }
516
516
  return this.get(id);
517
517
  }
518
+ /** How often a waiting client that asked for progress hears from the bridge. */
519
+ get progressIntervalMs() { return this.runtime.progressIntervalMs ?? 10000; }
518
520
  async wait(id) {
519
521
  const entry = this.entries.get(id);
520
522
  if (!entry)
@@ -13,8 +13,14 @@ function clientLabel(envelope) {
13
13
  return clean || null;
14
14
  }
15
15
  /** Said at the moment the client decides what to do next: while the task runs, waiting is the whole job (any other call costs tokens and repeats Antigravity's work). */
16
- export const WAITING_NOTE = 'Task still running. Do nothing else: no file reads, git, tests, logs or review of this change yourself, and write no message. Call antigravity_task_wait again now.';
17
- const withNextStep = (summary, activity = null) => summary.status === 'running' || summary.status === 'queued' ? { ...summary, ...(activity ? { activity } : {}), next_step: WAITING_NOTE } : summary;
16
+ export const WAITING_NOTE = 'Still running. Do nothing else and write no message; call antigravity_task_wait again now.';
17
+ /**
18
+ * A task that is still running is reported in a few fields only: the client reads this on every wait, so each field costs tokens many times over,
19
+ * and everything else (paths, byte counts, pid...) is in the web UI and in the full status once the task has ended.
20
+ */
21
+ const withNextStep = (summary, activity = null) => summary.status === 'running' || summary.status === 'queued'
22
+ ? { task_id: summary.task_id, status: summary.status, duration_ms: summary.duration_ms, ...(activity ? { activity } : {}), next_step: WAITING_NOTE }
23
+ : summary;
18
24
  export function createServer(manager, environment) {
19
25
  const server = new McpServer({ name: 'antigravity-mcp-bridge', version: readPackageInfo().version });
20
26
  const response = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value });
@@ -41,8 +47,22 @@ export function createServer(manager, environment) {
41
47
  server.registerTool('antigravity_task_wait', {
42
48
  description: `Wait up to wait_seconds (1-${manager.maxWaitSeconds}, default ${manager.maxWaitSeconds}) for the task to finish, then return its status without logs. Returns early when the task ends; if still running, call again. Does not cancel the task.`,
43
49
  inputSchema: waitSchema, annotations: { readOnlyHint: true, openWorldHint: false }
44
- }, async ({ task_id, wait_seconds }) => guard(async () => {
45
- await manager.waitFor(task_id, manager.nextWaitSeconds(task_id, wait_seconds));
50
+ }, async ({ task_id, wait_seconds }, ctx) => guard(async () => {
51
+ // A client that sent a progress token gets a heartbeat while the wait is held. Clients that reset their tool timeout on progress can then
52
+ // wait far longer than tool_timeout_sec; clients that do not simply ignore it. Never sent without a token, and a failed send never fails the wait.
53
+ const token = ctx.mcpReq._meta?.progressToken;
54
+ let beats = 0;
55
+ const beat = token === undefined ? null : setInterval(() => {
56
+ beats += 1;
57
+ void ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: token, progress: beats, message: manager.activityNote(task_id) ?? 'Antigravity is still working' } }).catch(() => { });
58
+ }, manager.progressIntervalMs);
59
+ try {
60
+ await manager.waitFor(task_id, manager.nextWaitSeconds(task_id, wait_seconds));
61
+ }
62
+ finally {
63
+ if (beat)
64
+ clearInterval(beat);
65
+ }
46
66
  return withNextStep(manager.summary(task_id), manager.activityNote(task_id));
47
67
  }));
48
68
  server.registerTool('antigravity_task_output', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-mcp-bridge",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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": [