agy-mcp-bridge 0.1.12 → 0.1.14

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
@@ -399,10 +399,24 @@ Lưu ý bảo mật: nội dung yêu cầu và phản hồi được hiển th
399
399
 
400
400
  Đây là chỉ dẫn, không phải rào chắn kỹ thuật: bridge không ép được mô hình ngừng đọc file trong lúc chờ. Mức tiết kiệm thực tế chưa được đo.
401
401
 
402
+ **Tiến độ từng bước (stream-json):** `agy-mcp-bridge probe-stream` trên `agy` 1.4.x cho thấy khi chạy với `--print <prompt> --output-format stream-json`, `agy` phát các sự kiện `init`, `step_update` (nhiều lần, khi đang làm việc) và `result` (kết quả cuối). Cài đặt → "Hiển thị tiến độ từng bước" (mặc định **tắt**, áp dụng cho task mới) cho bridge chạy task theo cách đó: prompt vẫn nằm trên dòng lệnh, chỉ định dạng output đổi.
403
+
404
+ - Các bước hiện trong chi tiết task ("Các bước Antigravity báo cáo", mới nhất lên đầu), mỗi bước là một dòng tóm tắt tự động kèm dữ liệu gốc (đã lọc secret) mở ra được. Bridge giữ 100 bước gần nhất mỗi task, trong bộ nhớ.
405
+ - Các sự kiện `step_update` **không** được ghi vào log stdout của task, nên hàng nghìn bước không làm chạm trần output (256 KiB mặc định); `init` và `result` vẫn được giữ.
406
+ - Mỗi bước là bằng chứng "còn sống" thật: chúng được tính là hoạt động cho phần gia hạn bên dưới, và `antigravity_task_wait`/`status` báo thêm "N steps reported" trong dòng `activity` (không gửi nội dung bước cho Codex/Claude để khỏi tốn token).
407
+ - **Chưa biết:** nội dung chi tiết bên trong `step_update` (chỉ biết tên các khóa ngoài cùng: `event`, `step_update`), nên phần tóm tắt chọn các trường dễ hiểu (`title`, `type`, `status`, `tool`, `message`, `path`...) nếu có, nếu không thì hiện tên các trường. Cũng chưa kiểm chứng kết quả cuối ở chế độ này có cùng dạng với chế độ JSON (`status`, `response`, `num_turns`). Nếu không giống, task báo lỗi kèm tên các trường của `result` (chỉ tên, không có giá trị) và cách quay về chế độ JSON: tắt tùy chọn này.
408
+ - Muốn bridge biết chính xác hình dạng bên trong các sự kiện, chạy lại `agy-mcp-bridge probe-stream` ở bản mới: nó in thêm tên và kiểu của các trường bên trong (không in giá trị).
409
+
410
+ **Theo dõi mức sử dụng (tab "Sử dụng"):** kết quả JSON của `agy` có các trường `conversation_id`, `duration_seconds`, `num_turns`, `response`, `status` và `usage`. Bridge ghi lại, cho mỗi task đã chạy xong, thời gian chạy, thời gian `agy` báo, số lượt, số bước (khi bật tiến độ) và **mọi giá trị số trong `usage`** (giữ nguyên tên trường, trường lồng nhau nối bằng dấu chấm, ví dụ `cache.read_tokens`). Tab "Sử dụng" hiện tổng task (theo trạng thái), tổng thời gian, usage cộng dồn, biểu đồ 14 ngày (theo múi giờ của trình duyệt), bảng theo model và theo mức suy luận, và các task gần nhất; chi tiết mỗi task có dòng "Usage (agy báo)". Dữ liệu nằm ở `.bridge-state/usage.jsonl` (mỗi task một dòng, giữ qua các lần khởi động lại, tự cắt bớt khi file quá 1 MB, không ghi đường dẫn hay nội dung task) và `stats` trong bản ghi task.
411
+
412
+ - **Đâ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.
413
+ - 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.
414
+ - 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).
415
+
402
416
  **Biết Antigravity còn hoạt động, và gia hạn thay vì dừng giữa chừng (time out mềm):** ở chế độ `--print --output-format json`, `agy` không in gì cho đến khi xong, nên bridge không thể biết chắc nó đang "suy luận". Bridge chỉ nhìn được những dấu hiệu còn sống: byte mới ở output, và (khi bạn bật tính năng này) việc file log của `agy` được ghi thêm. Cài đặt → "Gia hạn khi Antigravity còn hoạt động" (mặc định **tắt**, áp dụng cho task mới) làm như sau:
403
417
 
404
418
  - Bridge yêu cầu `agy` ghi log vào `.bridge-state/agy-logs/<task_id>.log` (cờ `--log-file`) và cho `agy` giới hạn tuyệt đối làm print timeout của chính nó, thay vì hạn của task.
405
- - Đến hạn của task (`timeout_seconds`), bridge **không dừng ngay**: nó kiểm tra có hoạt động trong khoảng vừa qua không. 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**. Không có hoạt động trong khoảng đó thì dừng task (trạng thái `timeout`, lý do ghi trong `error`).
419
+ - Đến hạn của task (`timeout_seconds`), bridge **không dừng ngay**: nó kiểm tra có hoạt động trong khoảng vừa qua không (byte mới ở output, bước tiến độ nếu bật, hoặc ghi log). 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**. Không có hoạt động trong khoảng đó thì dừng task (trạng thái `timeout`, lý do ghi trong `error`).
406
420
  - **Giới hạn tuyệt đối** (30 phút đến 8 giờ, mặc định 2 giờ) luôn có hiệu lực: task vẫn còn hoạt động cũng dừng khi tới mức này, để một task treo hay chạy mãi không giữ chỗ vô hạn (chỉ có một task chạy một lúc).
407
421
  - Chi tiết task trong giao diện hiện "Log của agy cập nhật … (KB)", "Output cuối …", số lần đã gia hạn và lần kiểm tra tiếp theo. `antigravity_task_wait` và `antigravity_task_status` trả thêm một dòng `activity` (ví dụ `last log activity 12s ago; deadline extended 2x while active`) để Codex/Claude không phải tự đọc log.
408
422
  - **Chưa kiểm chứng:** tôi chưa biết `agy` có ghi log đều đặn trong lúc suy luận hay chỉ ghi lúc bắt đầu và kết thúc. Nếu log không tăng khi `agy` đang chạy thì không có dấu hiệu nào để gia hạn, và task dừng đúng hạn như khi tắt tính năng (không tệ hơn). Cách kiểm tra: bật tính năng, giao một task kéo dài vài chục giây và xem dòng "Log của agy cập nhật…" tăng khi task đang chạy.
@@ -28,12 +28,23 @@ catch {
28
28
  return null;
29
29
  } });
30
30
  const kinds = new Map();
31
+ const typeOf = (value) => value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
31
32
  for (const event of events) {
32
33
  const kind = event ? String(event.event ?? event.type ?? '(no event/type key)') : '(not JSON)';
33
- const entry = kinds.get(kind) ?? { count: 0, keys: new Set() };
34
+ const entry = kinds.get(kind) ?? { count: 0, keys: new Set(), inner: new Map() };
34
35
  entry.count++;
35
36
  for (const key of Object.keys(event ?? {}))
36
37
  entry.keys.add(key);
38
+ // One level down: only the names and value types of the fields (never the values), so the shape can be shared without exposing content.
39
+ for (const [key, value] of Object.entries(event ?? {})) {
40
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
41
+ for (const [innerKey, innerValue] of Object.entries(value)) {
42
+ const seen = entry.inner.get(`${key}.${innerKey}`) ?? new Set();
43
+ seen.add(typeOf(innerValue));
44
+ entry.inner.set(`${key}.${innerKey}`, seen);
45
+ }
46
+ }
47
+ }
37
48
  kinds.set(kind, entry);
38
49
  }
39
50
  const results = paths.resultsDirectory;
@@ -41,8 +52,11 @@ await mkdir(results, { recursive: true });
41
52
  await writeFile(path.join(results, 'stream-probe.ndjson'), stdout);
42
53
  await writeFile(path.join(results, 'stream-probe.stderr.txt'), stderr);
43
54
  console.log(`agy exit code: ${code}; ${lines.length} stdout line(s); raw output saved to ${path.join(results, 'stream-probe.ndjson')} (not committed)`);
44
- for (const [kind, { count, keys }] of kinds)
55
+ for (const [kind, { count, keys, inner }] of kinds) {
45
56
  console.log(`- ${kind} x${count}: keys = ${[...keys].join(', ')}`);
57
+ if (inner.size)
58
+ console.log(` fields: ${[...inner].map(([name, types]) => `${name}:${[...types].join('|')}`).join(', ')}`);
59
+ }
46
60
  console.log(events.every(Boolean) ? 'Every stdout line is a JSON object.' : 'Some stdout lines are not JSON; see the raw file.');
47
- console.log('Share the event names and keys above (not the raw file, which may contain paths) to build progress reporting on the real format.');
61
+ console.log('Share the event names, keys and fields above (names and types only, no values; not the raw file, which may contain paths).');
48
62
  //# sourceMappingURL=probe-stream.js.map
@@ -1,8 +1,10 @@
1
1
  export function invocationFor(prompt, timeout, transport, model = null, extraArgs = []) {
2
2
  const common = ['--print-timeout', `${timeout}s`, '--disable-slash-commands', ...(model ? ['--model', model] : []), ...extraArgs];
3
- return transport === 'argv'
4
- ? { args: ['--print', prompt, '--output-format', 'json', ...common], stdin: undefined }
5
- : { args: ['--input-format', 'stream-json', '--output-format', 'stream-json', ...common], stdin: JSON.stringify({ event: 'user', message: { content: prompt } }) + '\n' };
3
+ if (transport === 'argv')
4
+ return { args: ['--print', prompt, '--output-format', 'json', ...common], stdin: undefined };
5
+ if (transport === 'argv-stream')
6
+ return { args: ['--print', prompt, '--output-format', 'stream-json', ...common], stdin: undefined };
7
+ return { args: ['--input-format', 'stream-json', '--output-format', 'stream-json', ...common], stdin: JSON.stringify({ event: 'user', message: { content: prompt } }) + '\n' };
6
8
  }
7
9
  export function finalEnvelope(output, transport) {
8
10
  if (transport === 'argv')
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { redact } from './security/redact.js';
5
+ /**
6
+ * Step progress. With this on, tasks run `agy --print <prompt> --output-format stream-json`: besides the final `result`, agy emits
7
+ * `init` and `step_update` events while it works (seen with `agy-mcp-bridge probe-stream`). The bridge keeps the steps for the web UI and
8
+ * treats them as proof of life. Off by default: the path is new and the contents of a step_update are not fully known yet.
9
+ * Owner-controlled (web UI); read on every task, so a change needs no restart.
10
+ */
11
+ export const PROGRESS_FILE = 'progress.json';
12
+ export const readProgress = (stateDirectory) => {
13
+ try {
14
+ return { enabled: JSON.parse(readFileSync(path.join(stateDirectory, PROGRESS_FILE), 'utf8')).enabled === true };
15
+ }
16
+ catch {
17
+ return { enabled: false };
18
+ }
19
+ };
20
+ export async function writeProgress(stateDirectory, enabled) {
21
+ const file = path.join(stateDirectory, PROGRESS_FILE);
22
+ if (!enabled) {
23
+ await rm(file, { force: true });
24
+ return;
25
+ }
26
+ await mkdir(stateDirectory, { recursive: true });
27
+ await writeFile(`${file}.tmp`, JSON.stringify({ enabled: true }) + '\n');
28
+ await rename(`${file}.tmp`, file);
29
+ }
30
+ export const MAX_RECENT_STEPS = 100;
31
+ const PREFERRED = ['title', 'name', 'tool', 'tool_name', 'type', 'kind', 'status', 'state', 'action', 'description', 'summary', 'message', 'text', 'content', 'path', 'file', 'command'];
32
+ const clip = (value, length) => value.length > length ? `${value.slice(0, length - 1)}…` : value;
33
+ function scalar(value) {
34
+ if (typeof value === 'string')
35
+ return value.replace(/\s+/g, ' ').trim() || null;
36
+ if (typeof value === 'number' || typeof value === 'boolean')
37
+ return String(value);
38
+ return null;
39
+ }
40
+ /**
41
+ * One line describing a step_update. Its fields are decided by agy and only the top-level keys of the event are known, so this picks the
42
+ * descriptive-looking fields (title, type, status, tool, message, ...) it finds, one level down if needed, and falls back to the field names.
43
+ * Everything is redacted and shortened. The raw (redacted) text is kept too, so an unfamiliar shape can still be read in the web UI.
44
+ */
45
+ export function summarizeStep(value) {
46
+ const direct = scalar(value);
47
+ if (direct)
48
+ return redact(clip(direct, 200));
49
+ if (typeof value !== 'object' || value === null)
50
+ return '(empty step)';
51
+ const object = Array.isArray(value) ? { items: value } : value;
52
+ const parts = [];
53
+ const take = (source) => {
54
+ for (const key of PREFERRED) {
55
+ if (parts.length >= 3)
56
+ return;
57
+ const text = scalar(source[key]);
58
+ if (text && !parts.includes(clip(text, 80)))
59
+ parts.push(clip(text, 80));
60
+ }
61
+ };
62
+ take(object);
63
+ if (!parts.length)
64
+ for (const nested of Object.values(object))
65
+ if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
66
+ take(nested);
67
+ if (parts.length)
68
+ break;
69
+ }
70
+ if (parts.length)
71
+ return redact(clip(parts.join(' · '), 200));
72
+ const keys = Object.keys(object).slice(0, 6);
73
+ return keys.length ? `{${keys.join(', ')}}` : '(empty step)';
74
+ }
75
+ export function rawStep(value) {
76
+ try {
77
+ return redact(clip(JSON.stringify(value) ?? '', 600));
78
+ }
79
+ catch {
80
+ return '';
81
+ }
82
+ }
83
+ //# sourceMappingURL=progress.js.map
@@ -1,6 +1,7 @@
1
1
  import { existsSync, statSync } from 'node:fs';
2
2
  import { mkdir, readdir, readFile, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { StringDecoder } from 'node:string_decoder';
4
5
  import path from 'node:path';
5
6
  import { DEFAULT_MAX_WAIT_SECONDS, delegateSchema, promptFor } from '../security/input.js';
6
7
  import { validateWorkspace } from '../security/workspace.js';
@@ -8,6 +9,8 @@ import { redact } from '../security/redact.js';
8
9
  import { delegationState } from '../delegation.js';
9
10
  import { startProcess, terminateOwned } from '../antigravity/process.js';
10
11
  import { invocationFor, finalEnvelope } from '../antigravity/invocation.js';
12
+ import { appendUsage, extractStats } from '../usage.js';
13
+ import { MAX_RECENT_STEPS, rawStep, readProgress, summarizeStep } from '../progress.js';
11
14
  import { helperDirectory } from '../paths.js';
12
15
  import { readWait } from '../wait-setting.js';
13
16
  import { CHECK_DELAYS_SECONDS, readSoftTimeout, rampedWaitSeconds } from '../soft-timeout.js';
@@ -162,7 +165,10 @@ export class TaskManager {
162
165
  await mkdir(logDirectory, { recursive: true });
163
166
  logFile = path.join(logDirectory, `${task.task_id}.log`);
164
167
  }
165
- const invocation = invocationFor(promptFor({ ...input, workspace }, entry.request?.client), soft.enabled ? absoluteLimit : timeout, this.config.prompt_transport, entry.request?.model ?? null, [...permissionFlags(permission), ...effortFlags(entry.request?.effort ?? 'default'), ...(logFile ? ['--log-file', logFile] : [])]);
168
+ // Step progress (owner setting): same prompt on the command line, but agy streams events (init, step_update, result) instead of one JSON at the end.
169
+ const transport = this.config.prompt_transport === 'stdin' ? 'stdin' : readProgress(this.runtime.stateDirectory).enabled ? 'argv-stream' : 'argv';
170
+ const streaming = transport !== 'argv';
171
+ const invocation = invocationFor(promptFor({ ...input, workspace }, entry.request?.client), soft.enabled ? absoluteLimit : timeout, transport, entry.request?.model ?? null, [...permissionFlags(permission), ...effortFlags(entry.request?.effort ?? 'default'), ...(logFile ? ['--log-file', logFile] : [])]);
166
172
  const options = { extraEnvPassthrough: this.config.extra_env_passthrough, stdin: invocation.stdin };
167
173
  const child = this.runtime.launch ? this.runtime.launch(invocation.args, workspace, options) : startProcess(this.runtime.executable, invocation.args, workspace, options);
168
174
  entry.child = child;
@@ -192,9 +198,8 @@ export class TaskManager {
192
198
  entry.stop = reason;
193
199
  void terminateOwned(child).catch(() => { task.error = 'Failed to terminate owned process tree; execution lock is retained until process closes'; });
194
200
  };
195
- const capture = (stream, chunk) => {
196
- entry.lastOutputAt = Date.now();
197
- task[`${stream}_bytes`] += chunk.length;
201
+ // Keeps data for the task's stdout/stderr logs, within the output cap.
202
+ const retain = (stream, chunk) => {
198
203
  const available = this.config.max_output_bytes - lengths[stream];
199
204
  if (available > 0) {
200
205
  const retained = Buffer.from(chunk.subarray(0, available));
@@ -207,6 +212,47 @@ export class TaskManager {
207
212
  task.error = 'Output limit exceeded; incomplete output cannot establish success';
208
213
  requestStop('failed');
209
214
  }
215
+ };
216
+ // Stream mode: stdout is one JSON event per line. step_update events become progress steps and are not kept in the log (they could
217
+ // otherwise fill the output cap long before the final result); init, result and anything else are kept as before.
218
+ const decoder = new StringDecoder('utf8');
219
+ let partialLine = '';
220
+ const takeStepLine = (line) => {
221
+ if (!line.trim())
222
+ return true;
223
+ let event;
224
+ try {
225
+ event = JSON.parse(line);
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ if (typeof event !== 'object' || event === null || event.event !== 'step_update')
231
+ return false;
232
+ const value = event.step_update;
233
+ const steps = entry.steps ??= { total: 0, recent: [] };
234
+ steps.total += 1;
235
+ steps.recent.push({ n: steps.total, at: Date.now(), summary: summarizeStep(value), raw: rawStep(value) });
236
+ if (steps.recent.length > MAX_RECENT_STEPS)
237
+ steps.recent.shift();
238
+ return true;
239
+ };
240
+ const capture = (stream, chunk) => {
241
+ entry.lastOutputAt = Date.now();
242
+ task[`${stream}_bytes`] += chunk.length;
243
+ let keep = chunk;
244
+ if (streaming && stream === 'stdout') {
245
+ const lines = (partialLine + decoder.write(chunk)).split('\n');
246
+ partialLine = lines.pop() ?? '';
247
+ keep = Buffer.from(lines.filter(line => !takeStepLine(line)).map(line => `${line}\n`).join(''));
248
+ // A line that never ends must not grow without bound: treat an oversized one as ordinary output (and so as an overflow).
249
+ if (partialLine.length > this.config.max_output_bytes) {
250
+ keep = Buffer.concat([keep, Buffer.from(partialLine)]);
251
+ partialLine = '';
252
+ }
253
+ }
254
+ if (keep.length)
255
+ retain(stream, keep);
210
256
  flushOutput();
211
257
  };
212
258
  child.stdout.on('data', (data) => capture('stdout', data));
@@ -255,6 +301,12 @@ export class TaskManager {
255
301
  finalized = true;
256
302
  clearTimeout(timer);
257
303
  clearInterval(outputTimer);
304
+ if (streaming) {
305
+ const rest = partialLine + decoder.end();
306
+ partialLine = '';
307
+ if (rest.trim() && !takeStepLine(rest))
308
+ retain('stdout', Buffer.from(rest));
309
+ }
258
310
  flushOutput(true);
259
311
  const rawStdout = Buffer.concat(buffers.stdout, lengths.stdout).toString('utf8');
260
312
  const rawStderr = Buffer.concat(buffers.stderr, lengths.stderr).toString('utf8');
@@ -269,7 +321,8 @@ export class TaskManager {
269
321
  task.status = entry.stop ?? 'failed';
270
322
  if (!entry.stop) {
271
323
  try {
272
- const result = finalEnvelope(rawStdout, this.config.prompt_transport);
324
+ const result = finalEnvelope(rawStdout, transport);
325
+ entry.stats = extractStats(result, entry.steps?.total ?? null);
273
326
  task.cli_status = typeof result.status === 'string' ? result.status : null;
274
327
  task.final_response = typeof result.response === 'string' ? redact(result.response) : null;
275
328
  if (Array.isArray(result.denied_actions))
@@ -288,12 +341,12 @@ export class TaskManager {
288
341
  task.status = 'completed';
289
342
  }
290
343
  else {
291
- task.error = typeof result.error === 'string' ? redact(result.error) : 'CLI did not provide a complete successful response';
344
+ task.error = typeof result.error === 'string' ? redact(result.error) : `CLI did not provide a complete successful response${streaming ? ` (stream result fields: ${Object.keys(result).slice(0, 12).join(', ') || 'none'}; if step progress is on, turn it off in Settings to return to the JSON mode)` : ''}`;
292
345
  }
293
346
  }
294
347
  catch {
295
- task.error ??= this.config.prompt_transport === 'stdin'
296
- ? 'CLI output is not a valid stream-json final result. If this happens with a real agy, set "prompt_transport": "argv" in the bridge config (the previously verified mode) and restart'
348
+ task.error ??= streaming
349
+ ? 'CLI output is not a valid stream-json final result. Turn off step progress in the web UI Settings (or, for stdin mode, set "prompt_transport": "argv" in the bridge config) to return to the verified JSON mode'
297
350
  : 'CLI output is not a valid final JSON envelope';
298
351
  }
299
352
  }
@@ -310,6 +363,11 @@ export class TaskManager {
310
363
  }
311
364
  }
312
365
  await this.persist(task);
366
+ await appendUsage(this.runtime.stateDirectory, {
367
+ at: task.ended_at ?? new Date().toISOString(), status: task.status, model: entry.request?.model ?? null, effort: entry.request?.effort ?? null,
368
+ permission: entry.request?.permission ?? null, duration_ms: task.duration_ms, agy_duration_seconds: entry.stats?.agy_duration_seconds ?? null,
369
+ num_turns: entry.stats?.num_turns ?? null, steps: entry.steps?.total ?? null, usage: entry.stats?.usage ?? null
370
+ });
313
371
  this.busy = false;
314
372
  entry.finish?.();
315
373
  void this.startNext();
@@ -384,7 +442,7 @@ export class TaskManager {
384
442
  ...(request.context === null ? {} : { context: request.context }), timeout_seconds: request.timeout_seconds
385
443
  }, request.client) : null;
386
444
  const entry = this.entries.get(id);
387
- return { task, request, prompt, activity: entry ? this.activityOf(entry) : null };
445
+ return { task, request, prompt, activity: entry ? this.activityOf(entry) : null, stats: entry?.stats ?? null };
388
446
  }
389
447
  /** The latest sign of life: output on stdout/stderr or a write to agy's log file (the file's modification time). 0 when there has been none. */
390
448
  lastActivity(entry) {
@@ -413,7 +471,8 @@ export class TaskManager {
413
471
  const iso = (value) => value ? new Date(value).toISOString() : null;
414
472
  return {
415
473
  last_output_at: iso(entry.lastOutputAt), log_updated_at: iso(logAt), log_bytes: logBytes, last_activity_at: iso(last || null),
416
- soft_deadline: entry.soft ? { extensions: entry.soft.extensions, next_check_at: iso(entry.soft.nextCheckAt), hard_limit_at: iso(entry.soft.hardLimitAt) } : null
474
+ soft_deadline: entry.soft ? { extensions: entry.soft.extensions, next_check_at: iso(entry.soft.nextCheckAt), hard_limit_at: iso(entry.soft.hardLimitAt) } : null,
475
+ steps: entry.steps ? { total: entry.steps.total, recent: entry.steps.recent.map(step => ({ n: step.n, at: iso(step.at), summary: step.summary, raw: step.raw })) } : null
417
476
  };
418
477
  }
419
478
  /** One short line for the client about whether Antigravity is alive (only while the task runs): the MCP tools carry it so the client need not read logs. */
@@ -423,7 +482,8 @@ export class TaskManager {
423
482
  return null;
424
483
  const last = this.lastActivity(entry);
425
484
  const source = entry.logFile && last && last !== (entry.lastOutputAt ?? 0) ? 'log' : 'output';
426
- const extended = entry.soft?.extensions ? `; deadline extended ${entry.soft.extensions}x while active` : '';
485
+ const extended = (entry.soft?.extensions ? `; deadline extended ${entry.soft.extensions}x while active` : '')
486
+ + (entry.steps ? `; ${entry.steps.total} steps reported` : '');
427
487
  return (last ? `last ${source} activity ${Math.max(0, Math.round((Date.now() - last) / 1000))}s ago` : 'no output or log activity seen yet (normal in print mode: agy prints when it finishes)') + extended;
428
488
  }
429
489
  /** Length of the next wait for a client that did not choose one: it grows with each call for this task (1 min, 5 min, the owner's maximum). */
@@ -502,7 +562,7 @@ export class TaskManager {
502
562
  try {
503
563
  await mkdir(this.tasksDirectory, { recursive: true, mode: 0o700 });
504
564
  const file = path.join(this.tasksDirectory, `${task.task_id}.json`);
505
- await writeFile(`${file}.tmp`, JSON.stringify({ task, request: this.entries.get(task.task_id)?.request ?? null }), { mode: 0o600 });
565
+ await writeFile(`${file}.tmp`, JSON.stringify({ task, request: this.entries.get(task.task_id)?.request ?? null, stats: this.entries.get(task.task_id)?.stats ?? null }), { mode: 0o600 });
506
566
  await rename(`${file}.tmp`, file);
507
567
  }
508
568
  catch { /* history is a convenience, not a correctness requirement */ }
@@ -522,6 +582,7 @@ export class TaskManager {
522
582
  }
523
583
  const loaded = [];
524
584
  const requests = new Map();
585
+ const statsById = new Map();
525
586
  for (const name of names) {
526
587
  try {
527
588
  const parsed = JSON.parse(await readFile(path.join(this.tasksDirectory, name), 'utf8'));
@@ -532,6 +593,8 @@ export class TaskManager {
532
593
  continue;
533
594
  if (request)
534
595
  requests.set(task.task_id, request);
596
+ if (parsed.task && parsed.stats)
597
+ statsById.set(task.task_id, parsed.stats);
535
598
  if (task.status === 'running' || task.status === 'queued') {
536
599
  const wasQueued = task.status === 'queued';
537
600
  task.status = 'failed';
@@ -552,7 +615,7 @@ export class TaskManager {
552
615
  for (const task of loaded.slice(this.config.max_retained_tasks))
553
616
  await this.forget(task.task_id);
554
617
  for (const task of keep)
555
- this.entries.set(task.task_id, { task, request: requests.get(task.task_id), done: Promise.resolve() });
618
+ this.entries.set(task.task_id, { task, request: requests.get(task.task_id), stats: statsById.get(task.task_id), done: Promise.resolve() });
556
619
  return keep.length;
557
620
  }
558
621
  // Diagnose only; never remove a lock automatically, never kill anything. PIDs can be reused, so wording stays cautious.
@@ -7,6 +7,8 @@ import { paths } from '../paths.js';
7
7
  import { knownUpdate, readPackageInfo } from '../update.js';
8
8
  import { workspaceChanges } from '../changes.js';
9
9
  import { validModelId, writeModel } from '../models.js';
10
+ import { readProgress, writeProgress } from '../progress.js';
11
+ import { readUsage, summarizeUsage } from '../usage.js';
10
12
  import { EFFORT_LEVELS, isEffortLevel, readEffort, writeEffort } from '../effort.js';
11
13
  import { MAX_ABSOLUTE_LIMIT_SECONDS, MIN_ABSOLUTE_LIMIT_SECONDS, isAbsoluteLimit, readSoftTimeout, writeSoftTimeout } from '../soft-timeout.js';
12
14
  import { MAX_SETTING_WAIT_SECONDS, MIN_WAIT_SECONDS, clientToolTimeoutSeconds, isWaitSeconds, writeWait } from '../wait-setting.js';
@@ -60,6 +62,7 @@ async function overview(ctx) {
60
62
  permission: permissionState(ctx.manager.stateDirectory),
61
63
  effort: readEffort(ctx.manager.stateDirectory),
62
64
  soft_timeout: readSoftTimeout(ctx.manager.stateDirectory),
65
+ progress: readProgress(ctx.manager.stateDirectory),
63
66
  wait: { seconds: ctx.manager.maxWaitSeconds, client_tool_timeout_seconds: clientToolTimeoutSeconds(ctx.manager.maxWaitSeconds) },
64
67
  queue: ctx.manager.overview().queue,
65
68
  warnings: [await ctx.manager.lockDiagnostic()].filter(Boolean),
@@ -142,6 +145,11 @@ export async function handleUi(req, res, ctx) {
142
145
  json(res, 200, await overview(ctx));
143
146
  return true;
144
147
  }
148
+ if (pathname === '/ui/api/usage' && method === 'GET') {
149
+ const offset = Number(new URL(req.url ?? '', 'http://localhost').searchParams.get('tz') ?? '0');
150
+ json(res, 200, summarizeUsage(await readUsage(ctx.manager.stateDirectory), { tzOffsetMinutes: Number.isInteger(offset) && Math.abs(offset) <= 840 ? offset : 0 }));
151
+ return true;
152
+ }
145
153
  const detail = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})$`));
146
154
  if (detail && method === 'GET') {
147
155
  json(res, 200, ctx.manager.detail(detail[1]));
@@ -172,6 +180,16 @@ export async function handleUi(req, res, ctx) {
172
180
  json(res, 200, { ...delegationState(ctx.manager.stateDirectory), controllable: true });
173
181
  return true;
174
182
  }
183
+ if (pathname === '/ui/api/progress' && method === 'POST') {
184
+ const body = await readJson(req);
185
+ if (typeof body.enabled !== 'boolean') {
186
+ json(res, 400, { error: 'enabled must be true or false' });
187
+ return true;
188
+ }
189
+ await writeProgress(ctx.manager.stateDirectory, body.enabled);
190
+ json(res, 200, readProgress(ctx.manager.stateDirectory));
191
+ return true;
192
+ }
175
193
  if (pathname === '/ui/api/soft-timeout' && method === 'POST') {
176
194
  const body = await readJson(req);
177
195
  if (typeof body.enabled !== 'boolean') {
@@ -0,0 +1,120 @@
1
+ import { appendFile, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Usage of Antigravity as the bridge sees it. agy's result carries `usage`, `duration_seconds` and `num_turns`; what is inside `usage` is not
5
+ * assumed: every numeric value in it is kept under its own name (nested names joined with a dot) and summed. Nothing here is a quota: the
6
+ * bridge has no source for "remaining" and does not invent one.
7
+ */
8
+ export const USAGE_FILE = 'usage.jsonl';
9
+ const MAX_FILE_BYTES = 1_000_000;
10
+ const KEEP_RECORDS = 3000;
11
+ const SAFE_KEY = /^[A-Za-z0-9_.-]{1,60}$/;
12
+ /** Numeric leaves of an object, at most three levels deep and 40 of them, keyed by their dotted path. */
13
+ export function flattenNumbers(value, prefix = '', depth = 0, out = {}) {
14
+ if (typeof value === 'number') {
15
+ if (Number.isFinite(value) && prefix && SAFE_KEY.test(prefix))
16
+ out[prefix] = value;
17
+ return out;
18
+ }
19
+ if (depth >= 3 || typeof value !== 'object' || value === null || Array.isArray(value))
20
+ return out;
21
+ for (const [key, inner] of Object.entries(value)) {
22
+ if (Object.keys(out).length >= 40)
23
+ break;
24
+ flattenNumbers(inner, prefix ? `${prefix}.${key}` : key, depth + 1, out);
25
+ }
26
+ return out;
27
+ }
28
+ const finiteOrNull = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
29
+ /** What the result of one task says about its own cost. All parts are optional: agy may leave any of them out. */
30
+ export function extractStats(result, steps) {
31
+ const usage = flattenNumbers(result.usage);
32
+ return { usage: Object.keys(usage).length ? usage : null, agy_duration_seconds: finiteOrNull(result.duration_seconds), num_turns: finiteOrNull(result.num_turns), steps };
33
+ }
34
+ /** One line per finished task, kept across restarts (the task history itself keeps only the latest tasks). Trimmed when the file grows large. */
35
+ export async function appendUsage(stateDirectory, record) {
36
+ const file = path.join(stateDirectory, USAGE_FILE);
37
+ try {
38
+ await appendFile(file, JSON.stringify(record) + '\n', { mode: 0o600 });
39
+ if ((await stat(file)).size > MAX_FILE_BYTES) {
40
+ const lines = (await readFile(file, 'utf8')).split('\n').filter(Boolean);
41
+ await writeFile(file, lines.slice(-KEEP_RECORDS).join('\n') + '\n', { mode: 0o600 });
42
+ }
43
+ }
44
+ catch { /* statistics are a convenience, never a reason to fail a task */ }
45
+ }
46
+ export async function readUsage(stateDirectory) {
47
+ let text;
48
+ try {
49
+ text = await readFile(path.join(stateDirectory, USAGE_FILE), 'utf8');
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ const records = [];
55
+ for (const line of text.split('\n')) {
56
+ if (!line.trim())
57
+ continue;
58
+ try {
59
+ const value = JSON.parse(line);
60
+ if (typeof value.at === 'string' && Number.isFinite(Date.parse(value.at)) && typeof value.status === 'string' && typeof value.duration_ms === 'number')
61
+ records.push(value);
62
+ }
63
+ catch { /* skip a damaged line */ }
64
+ }
65
+ return records;
66
+ }
67
+ const add = (target, source) => {
68
+ for (const [key, value] of Object.entries(source ?? {}))
69
+ target[key] = (target[key] ?? 0) + value;
70
+ };
71
+ const sum = (values) => values.reduce((total, value) => total + (value ?? 0), 0);
72
+ const newGroup = () => ({ tasks: 0, completed: 0, duration_ms: 0, turns: 0, usage: {} });
73
+ function addTo(group, record) {
74
+ group.tasks += 1;
75
+ if (record.status === 'completed')
76
+ group.completed += 1;
77
+ group.duration_ms += record.duration_ms;
78
+ group.turns += record.num_turns ?? 0;
79
+ add(group.usage, record.usage);
80
+ }
81
+ /**
82
+ * Totals, a split by model and by effort, and the last `days` days. Days are the viewer's local days: `tzOffsetMinutes` is what
83
+ * `new Date().getTimezoneOffset()` returns in the browser (minutes behind UTC, so UTC+7 is -420).
84
+ */
85
+ export function summarizeUsage(records, options = {}) {
86
+ const now = options.now ?? Date.now();
87
+ const offsetMs = -(options.tzOffsetMinutes ?? 0) * 60000;
88
+ const days = options.days ?? 14;
89
+ const dayOf = (ms) => new Date(ms + offsetMs).toISOString().slice(0, 10);
90
+ const totals = newGroup();
91
+ const byStatus = {};
92
+ const byModel = new Map();
93
+ const byEffort = new Map();
94
+ const byDay = new Map();
95
+ for (let i = days - 1; i >= 0; i--)
96
+ byDay.set(dayOf(now - i * 86400000), newGroup());
97
+ for (const record of records) {
98
+ addTo(totals, record);
99
+ byStatus[record.status] = (byStatus[record.status] ?? 0) + 1;
100
+ const model = record.model ?? 'default';
101
+ byModel.set(model, byModel.get(model) ?? newGroup());
102
+ addTo(byModel.get(model), record);
103
+ const effort = record.effort ?? 'default';
104
+ byEffort.set(effort, byEffort.get(effort) ?? newGroup());
105
+ addTo(byEffort.get(effort), record);
106
+ const day = byDay.get(dayOf(Date.parse(record.at)));
107
+ if (day)
108
+ addTo(day, record);
109
+ }
110
+ const list = (map) => [...map].map(([key, group]) => ({ key, ...group })).sort((a, b) => b.tasks - a.tasks).slice(0, 12);
111
+ return {
112
+ since: records.length ? records[0].at : null,
113
+ totals: { ...totals, by_status: byStatus, steps: sum(records.map(r => r.steps)), agy_duration_seconds: sum(records.map(r => r.agy_duration_seconds)),
114
+ with_usage: records.filter(r => r.usage).length },
115
+ by_model: list(byModel), by_effort: list(byEffort),
116
+ by_day: [...byDay].map(([day, group]) => ({ day, ...group })),
117
+ recent: records.slice(-15).reverse().map(r => ({ at: r.at, status: r.status, model: r.model, effort: r.effort, duration_ms: r.duration_ms, agy_duration_seconds: r.agy_duration_seconds, num_turns: r.num_turns, usage: r.usage }))
118
+ };
119
+ }
120
+ //# sourceMappingURL=usage.js.map
@@ -32,6 +32,7 @@ Các test hiện có chỉ kiểm tra cấu trúc skill và installer, không ki
32
32
  | 18 | Công tắc bật, `fake`/task thật chạy vài phút | Prompt 3 (giao việc rồi chờ) | Sau `antigravity_delegate` chỉ có các lời gọi `antigravity_task_wait`; không đọc file, không `git`, không chạy test, không đọc log, không viết tin giữa các lần chờ; khi xong gọi `antigravity_task_output` đúng một lần rồi mới review |
33
33
  | 19 | Phiên Codex không có các tool `antigravity_*` (mở khi chưa có `BRIDGE_HTTP_TOKEN`) | "Giao cho Antigravity review commit mới nhất." | Báo người dùng thiếu kết nối MCP và dừng; không tự viết script hay client HTTP để gọi bridge |
34
34
  | 20 | Gia hạn khi còn hoạt động đang bật; task chạy lâu hơn `timeout_seconds` mà log của agy vẫn tăng | Giao một việc kéo dài vài phút | Agent chỉ chờ; không hủy task chỉ vì nó chạy lâu hay `activity` cũ; task được gia hạn và hoàn tất; nếu `activity` im lặng rất lâu thì báo người dùng một lần để họ quyết định, không tự hủy |
35
+ | 21 | Tiến độ từng bước đang bật, task kéo dài vài phút | Giao một việc kéo dài | Agent không tự đọc các bước hay log; chỉ chờ và đọc dòng `activity`; người dùng xem các bước trong giao diện web |
35
36
 
36
37
  ## Ghi kết quả
37
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-mcp-bridge",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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": [
@@ -74,7 +74,7 @@ Replace example paths and symbols with the user's actual authorized project. `co
74
74
 
75
75
  ## Observe and reconnect
76
76
 
77
- **While a task runs, waiting is your whole job.** After `antigravity_delegate` returns `running` or `queued`, call `antigravity_task_wait` with `{"task_id":"the-returned-UUID"}` and no `wait_seconds` (or the largest value its description allows; the environment also reports it as `max_wait_seconds`), and make no other tool call until the status is terminal. That means no file reads, `git` commands, test runs, log or output reads, and no review of the same change yourself: it repeats the work you delegated and costs the tokens delegation is meant to save. Write no message between waits; if you must tell the user something, do it once before the first wait. A wait that returns `running` carries a `next_step` note: follow it, and an `activity` line saying when Antigravity last showed signs of life (output, or writes to its log file when the owner enabled that): read it, do not go and look for activity yourself. While one call is blocked you spend nothing, so one long wait is far cheaper than many short ones; the bridge owner can lengthen it in the web UI (Settings). Act again only when the status is terminal: call `antigravity_task_output` once, then review. If a wait fails with a client timeout, the client's tool timeout is shorter than the bridge's wait: tell the user once (raise the client's tool timeout, or lower the wait in the web UI) and continue with a shorter `wait_seconds`. `antigravity_task_status` is an instant, log-free snapshot for the rare quick check, not for polling. Neither returns stdout, stderr or the final response; retrieve `antigravity_task_output` at termination or when diagnosing an error.
77
+ **While a task runs, waiting is your whole job.** After `antigravity_delegate` returns `running` or `queued`, call `antigravity_task_wait` with `{"task_id":"the-returned-UUID"}` and no `wait_seconds` (or the largest value its description allows; the environment also reports it as `max_wait_seconds`), and make no other tool call until the status is terminal. That means no file reads, `git` commands, test runs, log or output reads, and no review of the same change yourself: it repeats the work you delegated and costs the tokens delegation is meant to save. Write no message between waits; if you must tell the user something, do it once before the first wait. A wait that returns `running` carries a `next_step` note: follow it, and an `activity` line saying when Antigravity last showed signs of life (output, steps it reported when the owner enabled step progress, or writes to its log file when the owner enabled that): read it, do not go and look for activity yourself. Steps and logs are for the user's web UI; do not fetch them yourself. While one call is blocked you spend nothing, so one long wait is far cheaper than many short ones; the bridge owner can lengthen it in the web UI (Settings). Act again only when the status is terminal: call `antigravity_task_output` once, then review. If a wait fails with a client timeout, the client's tool timeout is shorter than the bridge's wait: tell the user once (raise the client's tool timeout, or lower the wait in the web UI) and continue with a shorter `wait_seconds`. `antigravity_task_status` is an instant, log-free snapshot for the rare quick check, not for polling. Neither returns stdout, stderr or the final response; retrieve `antigravity_task_output` at termination or when diagnosing an error.
78
78
 
79
79
  Client disconnect does not cancel an accepted task. Reconnect to the same running server and query the saved ID; `recent_tasks` in environment can recover IDs if the delegate reply was lost. If several tasks could match, inspect their status/output before deciding which is yours. Do not resubmit just because a response was lost: submission is not idempotent. Finished-task records persist on disk across restarts until retention expiry; a task that was running during a restart comes back as `failed` and its process is not recovered, while one that was still queued comes back as `failed` with "never started": it touched no files, so resubmitting it is safe once the user still wants it. Unknown/expired ID does not prove the task never ran; inspect workspace changes and reconcile before another submission.
80
80
 
@@ -56,6 +56,10 @@
56
56
  {
57
57
  "sha256": "d4246abf59e2aea3ad08c7b858cd6404ee79f5656e580142d019f6f6c443f8df",
58
58
  "note": "activity line, slow is not stuck, soft deadline extension"
59
+ },
60
+ {
61
+ "sha256": "243a279ff026ed30ad91eaeb7376a4556bc9a1c4a9f4c206f64301c63d8853c4",
62
+ "note": "steps are for the user's web UI; do not fetch them"
59
63
  }
60
64
  ]
61
65
  }
package/ui/app.css CHANGED
@@ -216,3 +216,13 @@ pre.log { margin: 0; padding: 12px; border-radius: 10px; background: #0b0e14; co
216
216
  .perm-row.danger.on { border-color: var(--bad); }
217
217
  .perm-row input { margin-top: 4px; }
218
218
  .perm-row > span { display: grid; gap: 2px; }
219
+ .steps { margin: 8px 0 0; padding-left: 0; list-style: none; display: grid; gap: 6px; }
220
+ .steps li { padding: 6px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
221
+ .steps details { margin-top: 4px; }
222
+ .bars { display: grid; gap: 6px; margin-top: 8px; }
223
+ .bar-row { display: grid; grid-template-columns: 52px 1fr 130px; gap: 10px; align-items: center; }
224
+ .bar-track { height: 10px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border); overflow: hidden; }
225
+ .bar-fill { height: 100%; background: var(--accent); }
226
+ .usage-table { width: 100%; border-collapse: collapse; margin-top: 8px; }
227
+ .usage-table th, .usage-table td { padding: 7px 8px; text-align: left; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
228
+ .usage-table th { font-weight: 600; color: var(--muted); }
package/ui/app.js CHANGED
@@ -5,6 +5,7 @@ const STATUS = {queued: 'Đang chờ', running: 'Đang chạy', completed: 'Hoà
5
5
  const NS = 'http://www.w3.org/2000/svg';
6
6
  const ICONS = {
7
7
  tasks: 'M4 6h16M4 12h16M4 18h10',
8
+ usage: 'M4 20V10M10 20V4M16 20v-8M22 20H2',
8
9
  settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
9
10
  out: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9'
10
11
  };
@@ -70,6 +71,27 @@ function activityText(activity) {
70
71
  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) : '—'}`);
71
72
  return parts.join(' · ');
72
73
  }
74
+ /** Steps agy reported (step progress setting): newest first, each with the raw event (redacted) one click away. */
75
+ function stepsBody(activity) {
76
+ const steps = activity?.steps;
77
+ if (!steps) return [h('p', {class: 'muted', text: 'Chưa có bước nào. Bật "Hiển thị tiến độ từng bước" trong Cài đặt để agy báo từng bước khi chạy; áp dụng cho task mới.'})];
78
+ const shown = steps.recent.slice().reverse();
79
+ return [
80
+ h('p', {class: 'muted', text: `Đã nhận ${steps.total} bước${steps.total > steps.recent.length ? ` (hiện ${steps.recent.length} bước gần nhất)` : ''}. Nội dung mỗi bước do agy quyết định; đây là tóm tắt tự động, mở từng dòng để xem dữ liệu gốc (đã lọc secret).`}),
81
+ h('ol', {class: 'steps'}, shown.map(step => h('li', {},
82
+ h('span', {class: 'muted mono', text: `#${step.n} · ${clock(step.at)} · `}), h('span', {text: step.summary}),
83
+ step.raw ? h('details', {}, h('summary', {text: 'dữ liệu gốc'}), h('pre', {class: 'log', text: step.raw})) : null)))
84
+ ];
85
+ }
86
+ let stepsShown = '';
87
+ function patchSteps() {
88
+ const el = document.getElementById('steps-body');
89
+ const steps = state.detail?.activity?.steps;
90
+ const signature = steps ? `${state.selected}:${steps.total}` : `${state.selected}:none`;
91
+ if (!el || stepsShown === signature) return;
92
+ stepsShown = signature;
93
+ el.replaceChildren(...stepsBody(state.detail.activity));
94
+ }
73
95
  function patchActivity() {
74
96
  const el = document.getElementById('activity-line');
75
97
  const text = el && state.detail?.activity ? activityText(state.detail.activity) : null;
@@ -219,6 +241,22 @@ async function setSoftTimeout(enabled, limit_seconds) {
219
241
  await refresh();
220
242
  } catch (error) { notify(error.message); await refresh(); }
221
243
  }
244
+ async function setProgress(enabled) {
245
+ try { await api('progress', {method: 'POST', body: {enabled}}); notify(enabled ? 'Đã bật tiến độ từng bước (áp dụng cho task mới)' : 'Đã tắt tiến độ từng bước'); await refresh(); }
246
+ catch (error) { notify(error.message); await refresh(); }
247
+ }
248
+ let usageAt = 0;
249
+ /** Usage numbers change only when a task ends, so they are fetched when the page opens and then at most every 15 seconds. */
250
+ async function loadUsage(force) {
251
+ if (!force && Date.now() - usageAt < 15000) return;
252
+ usageAt = Date.now();
253
+ try { state.usage = await api(`usage?tz=${new Date().getTimezoneOffset()}`); } catch { /* keep the numbers already shown */ }
254
+ patch();
255
+ }
256
+ const USAGE_LABELS = {input_tokens: 'Token vào', output_tokens: 'Token ra', total_tokens: 'Tổng token', cached_tokens: 'Token từ bộ nhớ đệm', cache_read_tokens: 'Token đọc từ bộ nhớ đệm', reasoning_tokens: 'Token suy luận', thinking_tokens: 'Token suy luận', prompt_tokens: 'Token vào', completion_tokens: 'Token ra'};
257
+ const usageName = key => USAGE_LABELS[key] ? `${USAGE_LABELS[key]} (${key})` : key;
258
+ const number = value => Number(value).toLocaleString('vi-VN', {maximumFractionDigits: 2});
259
+ const usageLine = usage => usage && Object.keys(usage).length ? Object.entries(usage).map(([key, value]) => `${key} ${number(value)}`).join(' · ') : null;
222
260
  async function copy(text) {
223
261
  try { await navigator.clipboard.writeText(text); notify('Đã sao chép'); } catch { notify('Không thể sao chép tự động'); }
224
262
  }
@@ -337,7 +375,7 @@ function detailView() {
337
375
  const facts = h('dl', {class: 'kv'},
338
376
  ...[['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)],
339
377
  ['Thời gian chạy', running ? elapsedEl(task.started_at) : queued ? '—' : duration(task.duration_ms)],
340
- ['Giới hạn', request ? `${request.timeout_seconds}s` : '—'], ['Model', request && 'model' in request ? request.model || 'mặc định' : '—'], ['Quyền', request && request.permission ? (PERMISSION_LEVELS.find(l => l.id === request.permission)?.title || request.permission) : '—'], ['Effort', request && request.effort ? request.effort : '—'], ['Exit code', task.exit_code ?? '—'], ['PID', task.pid ?? '—'],
378
+ ['Giới hạn', request ? `${request.timeout_seconds}s` : '—'], ['Model', request && 'model' in request ? request.model || 'mặc định' : '—'], ['Quyền', request && request.permission ? (PERMISSION_LEVELS.find(l => l.id === request.permission)?.title || request.permission) : '—'], ['Effort', request && request.effort ? request.effort : '—'], ['Usage (agy báo)', usageLine(state.detail?.stats?.usage) ?? '—'], ['Exit code', task.exit_code ?? '—'], ['PID', task.pid ?? '—'],
341
379
  ['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))));
342
380
 
343
381
  const asker = request?.client ? `Tech Lead · ${request.client}` : 'Tech Lead (Codex / Claude)';
@@ -371,6 +409,7 @@ function detailView() {
371
409
 
372
410
  return h('article', {class: 'card detail'}, head, progress, facts, h('div', {class: 'convo'}, asked, replied),
373
411
  changesPanel(task),
412
+ details('steps', 'Các bước Antigravity báo cáo', h('div', {id: 'steps-body'}, stepsBody(state.detail?.activity))),
374
413
  details('logs', 'Log của tiến trình agy', logs),
375
414
  prompt ? details('prompt', 'Prompt đầy đủ gửi tới agy (đã lọc secret)', h('pre', {class: 'log', text: prompt})) : null);
376
415
  }
@@ -404,6 +443,15 @@ function modelCard(o) {
404
443
  loaded?.error ? h('div', {class: 'callout warn', text: `Không lấy được danh sách model: ${loaded.error}`}) : null,
405
444
  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.'}));
406
445
  }
446
+ function progressCard(o) {
447
+ const enabled = Boolean(o.progress?.enabled);
448
+ const toggle = h('input', {type: 'checkbox', checked: enabled, 'aria-label': 'Hiển thị tiến độ từng bước', onchange: event => setProgress(event.target.checked)});
449
+ return h('section', {class: 'card panel wide'}, h('h2', {text: 'Hiển thị tiến độ từng bước (stream-json)'}),
450
+ h('label', {class: 'perm-row' + (enabled ? ' on' : '')}, toggle,
451
+ h('span', {}, h('strong', {text: enabled ? 'Đang bật' : 'Đang tắt'}),
452
+ 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.'}))),
453
+ 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.'}));
454
+ }
407
455
  function softTimeoutCard(o) {
408
456
  const soft = o.soft_timeout || {enabled: false, limit_seconds: 7200};
409
457
  const known = SOFT_LIMITS.some(l => l.seconds === soft.limit_seconds);
@@ -453,6 +501,39 @@ function permissionCard(o) {
453
501
  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ở.`}),
454
502
  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 đó.'}));
455
503
  }
504
+ function usageView(u) {
505
+ 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ó.'})),
506
+ h('button', {class: 'btn small', onclick: () => loadUsage(true), text: 'Làm mới'}));
507
+ if (!u) return [head, h('div', {class: 'card empty', text: 'Đang tải…'})];
508
+ const t = u.totals;
509
+ const status = Object.entries(t.by_status).map(([key, count]) => `${STATUS[key] || key} ${count}`).join(' · ');
510
+ const cards = h('section', {class: 'stats', 'aria-label': 'Tổng quan sử dụng'},
511
+ stat('Task đã chạy', number(t.tasks), t.tasks ? `${number(t.completed)} hoàn tất · ${status}` : 'chưa có task nào'),
512
+ stat('Thời gian chạy', duration(t.duration_ms), 'tổng, do bridge đo'),
513
+ stat('Thời gian agy báo', t.agy_duration_seconds ? duration(t.agy_duration_seconds * 1000) : '—', 'tổng duration_seconds của agy'),
514
+ stat('Số lượt / bước', `${number(t.turns)} / ${number(t.steps)}`, 'num_turns / bước báo cáo'));
515
+ const callout = h('div', {class: 'callout warn', text: `Đây là mức dùng qua bridge${u.since ? ` kể từ ${clock(u.since)}` : ''} (nhật ký lưu qua các lần khởi động lại), không phải hạn mức. Hạn mức còn lại không có nguồn từ agy nên bridge không hiển thị; xem trên tài khoản Antigravity của bạn.`});
516
+ const sums = Object.entries(t.usage);
517
+ const usageCard = h('section', {class: 'card panel wide'}, h('h2', {text: 'Usage do agy báo'}),
518
+ sums.length ? [h('dl', {class: 'rows'}, sums.map(([key, value]) => row(usageName(key), number(value)))),
519
+ 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ố.`})]
520
+ : 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.'}));
521
+ const peak = Math.max(1, ...u.by_day.map(d => d.tasks));
522
+ const bars = h('div', {class: 'bars'}, u.by_day.map(d => {
523
+ const fill = h('div', {class: 'bar-fill'}); fill.style.width = `${(d.tasks / peak * 100).toFixed(1)}%`;
524
+ 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),
525
+ h('span', {class: 'mono', text: d.tasks ? `${d.tasks} · ${duration(d.duration_ms)}` : '0'}));
526
+ }));
527
+ const dayCard = h('section', {class: 'card panel wide'}, h('h2', {text: '14 ngày gần nhất'}), bars);
528
+ const groupTable = (title, groups, firstColumn) => h('section', {class: 'card panel wide'}, h('h2', {text: title}),
529
+ 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})))),
530
+ 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) ?? '—'})))))
531
+ : h('p', {class: 'muted', text: 'Chưa có dữ liệu.'}));
532
+ const recent = h('section', {class: 'card panel wide'}, h('h2', {text: 'Task gần nhất'}),
533
+ 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)}`,
534
+ usageLine(r.usage) ? h('span', {class: 'tag', text: usageLine(r.usage)}) : null))) : h('p', {class: 'muted', text: 'Chưa có task nào.'}));
535
+ 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];
536
+ }
456
537
  function settingsView(o) {
457
538
  const delegation = o.delegation;
458
539
  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.'}))),
@@ -466,6 +547,7 @@ function settingsView(o) {
466
547
  modelCard(o),
467
548
  effortCard(o),
468
549
  waitCard(o),
550
+ progressCard(o),
469
551
  softTimeoutCard(o),
470
552
  permissionCard(o),
471
553
  h('section', {class: 'card panel'}, h('h2', {text: 'Kết nối'}),
@@ -491,10 +573,10 @@ function settingsView(o) {
491
573
 
492
574
  /* ---------- Shell: built once per view change, then patched region by region ---------- */
493
575
  function mount() {
494
- const nav = (view, label) => h('button', {'aria-current': state.view === view ? 'page' : null, onclick: () => { state.view = view; render(); }}, icon(view === 'tasks' ? 'tasks' : 'settings'), label);
576
+ const nav = (view, label) => h('button', {'aria-current': state.view === view ? 'page' : null, onclick: () => { state.view = view; render(); }}, icon(view === 'tasks' ? 'tasks' : view === 'usage' ? 'usage' : 'settings'), label);
495
577
  const live = h('div', {class: 'live', role: 'status'});
496
578
  const side = h('aside', {class: 'side'}, h('div', {class: 'brand'}, h('img', {src: '/ui/favicon.svg', alt: ''}), h('span', {text: 'Antigravity Bridge'})),
497
- h('nav', {class: 'nav', 'aria-label': 'Điều hướng'}, nav('tasks', 'Task'), nav('settings', 'Cài đặt')),
579
+ h('nav', {class: 'nav', 'aria-label': 'Điều hướng'}, nav('tasks', 'Task'), nav('usage', 'Sử dụng'), nav('settings', 'Cài đặt')),
498
580
  h('div', {class: 'side-foot'}, live));
499
581
  const banner = h('div', {'aria-live': 'polite'});
500
582
  const main = h('main', {class: 'main'}, banner);
@@ -510,6 +592,9 @@ function mount() {
510
592
  class: 'chip-btn', 'data-filter': key, 'aria-pressed': String(state.filter === key), onclick: () => { state.filter = key; for (const b of chips.children) b.setAttribute('aria-pressed', String(b.dataset.filter === key)); patch(); }, text: label})));
511
593
  main.append(h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Task'}), h('p', {text: 'Nội dung trao đổi giữa Tech Lead và Antigravity.'}))),
512
594
  regions.stats, h('div', {class: 'tasks'}, h('div', {class: 'list-col'}, h('div', {class: 'list-tools'}, search, chips, regions.count), regions.list), regions.detail));
595
+ } else if (state.view === 'usage') {
596
+ regions.usage = h('div', {});
597
+ main.append(regions.usage);
513
598
  } else {
514
599
  regions.settings = h('div', {});
515
600
  main.append(regions.settings);
@@ -546,9 +631,13 @@ function patch() {
546
631
  setRegion('detail', detailKey, () => detailView());
547
632
  if (!rebuilt) patchLogs();
548
633
  patchActivity();
634
+ patchSteps();
635
+ } else if (regions.view === 'usage') {
636
+ loadUsage();
637
+ setRegion('usage', JSON.stringify(state.usage), () => usageView(state.usage));
549
638
  } else {
550
639
  if (!state.models && !state.modelsLoading) loadModels();
551
- setRegion('settings', JSON.stringify([state.models, o.model, o.permission, o.effort, o.wait, o.soft_timeout, o.cli, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));
640
+ setRegion('settings', JSON.stringify([state.models, o.model, o.permission, o.effort, o.wait, o.soft_timeout, o.progress, o.cli, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));
552
641
  }
553
642
  tick();
554
643
  }