@harness-mix/cli 0.2.3 → 0.3.0

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +469 -467
  3. package/docs/harness-management.md +1 -1
  4. package/docs/multi-agent-collaboration.md +3 -1
  5. package/docs/native-acp.md +138 -127
  6. package/output/native-build/desktop-controller.mjs +1 -1
  7. package/output/native-build/renderer-extension.js +172 -23
  8. package/package.json +13 -9
  9. package/scripts/acp-image-test.cjs +69 -0
  10. package/scripts/adapters-test.cjs +25 -0
  11. package/scripts/antigravity-adapter-test.cjs +647 -626
  12. package/scripts/codex-accounts-test.cjs +34 -0
  13. package/scripts/codex-adapter-test.cjs +238 -127
  14. package/scripts/collaboration-test.cjs +577 -262
  15. package/scripts/collaboration-ui-smoke.cjs +25 -3
  16. package/scripts/e2e-delegate.cjs +1 -1
  17. package/scripts/e2e-hermes-image.cjs +60 -0
  18. package/scripts/jsonl-stdin-test.cjs +40 -31
  19. package/scripts/kiro-cursor-adapters-test.cjs +124 -100
  20. package/scripts/native-acp-depth-test.cjs +30 -5
  21. package/scripts/native-update-apply-test.cjs +269 -215
  22. package/scripts/native-update.cjs +78 -0
  23. package/scripts/native-vendor-adapters-test.cjs +196 -154
  24. package/scripts/openclaw-adapter-test.cjs +121 -3
  25. package/scripts/openclaw-mcp-probe.cjs +100 -0
  26. package/scripts/openclaw-mcp-tool-probe.cjs +55 -0
  27. package/scripts/openclaw-thinking-probe.cjs +73 -0
  28. package/scripts/salvage-rollout-writes.cjs +72 -0
  29. package/scripts/storage-verification-test.cjs +11 -0
  30. package/scripts/zcode-adapter-test.cjs +329 -0
  31. package/scripts/zcode-live-probe.cjs +66 -0
  32. package/src/main/adapters/antigravity.js +1428 -1418
  33. package/src/main/adapters/claude.js +15 -7
  34. package/src/main/adapters/codex-app-server.js +29 -11
  35. package/src/main/adapters/codex.js +690 -649
  36. package/src/main/adapters/native-acp-command.js +51 -48
  37. package/src/main/adapters/native-acp.js +47 -12
  38. package/src/main/adapters/omp.js +7 -2
  39. package/src/main/adapters/openclaw.js +534 -343
  40. package/src/main/adapters/pi-family.js +35 -8
  41. package/src/main/adapters/qoder.js +12 -8
  42. package/src/main/adapters/zcode.js +925 -10
  43. package/src/main/host/collaboration-tools.js +1 -1
  44. package/src/main/host/collaboration.js +971 -714
  45. package/src/main/host/jsonl.js +130 -120
  46. package/src/main/host/runtime.js +9 -3
  47. package/src/main/host/verification-gates.js +14 -2
  48. package/src/main/native/codex-accounts.js +15 -4
  49. package/src/main/native/config.js +9 -9
  50. package/src/main/native/launcher.js +252 -237
  51. package/src/main/native/process-utils.js +157 -57
  52. package/src/main/native/protocol.js +1227 -1187
  53. package/src/main/native/update-state.js +123 -110
  54. package/src/main/native/updater.js +460 -394
  55. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js +3 -1
  56. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js.map +1 -1
  57. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  58. package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
  59. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  60. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +1 -0
  61. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +29 -0
  63. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  64. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts +4 -0
  65. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  66. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  67. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3224 -3181
  68. package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +25 -0
  69. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +27 -0
  70. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +93 -13
  71. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
  72. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +47 -1
@@ -1,1418 +1,1428 @@
1
- const { spawn, execFile } = require('node:child_process');
2
- const { randomUUID, randomBytes } = require('node:crypto');
3
- const fs = require('node:fs');
4
- const http = require('node:http');
5
- const os = require('node:os');
6
- const path = require('node:path');
7
- const readline = require('node:readline');
8
- const { recordNative } = require('../harness-adapter/fixture-recorder');
9
- const { terminateTree } = require('../native/process-utils');
10
-
11
- const manifest = {
12
- id: 'antigravity',
13
- name: 'Antigravity',
14
- icon: 'antigravity-color.svg',
15
- aliases: ['agy', 'antigravity'],
16
- capabilities: {
17
- collaborationTools: true,
18
- streaming: true,
19
- thinking: true,
20
- tools: true,
21
- approvals: true,
22
- questions: true,
23
- models: true,
24
- thinkingLevels: true,
25
- permissionModes: true,
26
- resume: true,
27
- fork: true,
28
- forkFromMessage: true,
29
- compaction: false,
30
- usage: true,
31
- contextUsage: true,
32
- attachments: true,
33
- },
34
- };
35
-
36
- const ANTIGRAVITY_PERMISSION_MODES = [
37
- { id: 'default', label: '默认', description: '按 Antigravity 预设规则拦截或执行' },
38
- { id: 'desktop', label: '桌面确认', description: '在 Harness Mix 桌面端弹出确认工具执行' },
39
- { id: 'skip', label: '自动放行', description: '自动放行工具执行(--dangerously-skip-permissions)', dangerous: true },
40
- ];
41
-
42
- const ANTIGRAVITY_WORKSPACE_FILE_INSTRUCTION =
43
- '[System Instruction: When creating new files in the workspace, you MUST use the write_to_file tool. When modifying existing files, use the replace_file_content tool. CRITICAL: NEVER include ArtifactMetadata when calling write_to_file for workspace files (ArtifactMetadata is strictly reserved for artifacts in the brain directory, and providing it for workspace files causes a path validation rejection). Do NOT use terminal commands (such as Set-Content, Out-File, echo, or cat) to create or write code files. For clarification, ask_question is connected to the harnessmix Desktop through a Hook. Use single-choice or text questions. The Hook returns the actual user response in its reason while blocking the native auto-skip behavior; do not retry merely because the native tool reports it was blocked.]\n\n';
44
-
45
- const EFFORT_LABELS = {
46
- low: 'Low',
47
- medium: 'Medium',
48
- high: 'High',
49
- };
50
- const EFFORT_SUFFIX_PATTERN = /^(?<base>.+)-(?<effort>low|medium|high)$/u;
51
- const EFFORT_LABEL_SUFFIX_PATTERN = /\s*\((?:low|medium|high)\)$/iu;
52
- const ANTIGRAVITY_RESULT_DRAIN_MS = 250;
53
- const ANTIGRAVITY_RESULT_DRAIN_MAX_MS = 10_000;
54
- // agy(1.2.x 起)偶发返回 status=SUCCESS 但 response 为空、且全程没有任何
55
- // agent_response 步骤的回合:工具实际已执行,只是最终 assistant 文本丢失。
56
- // 这种回合是可恢复的——在同一个原生会话里补发一轮 nudge,让模型把答案补出来,
57
- // 而不是直接把错误抛给用户。重试次数可用环境变量覆盖(0 = 禁用自动补问)。
58
- const ANTIGRAVITY_EMPTY_RESULT_RETRIES = 1;
59
- const ANTIGRAVITY_EMPTY_RESULT_NUDGE =
60
- '[System Instruction: Your previous turn ended WITHOUT any visible assistant response, even though the turn itself completed. The user saw nothing. Do NOT repeat tool calls that already succeeded. Directly write the final answer, or a summary of what you did, as plain assistant text now.]\n\n';
61
-
62
- function emptyResultMaxAttempts() {
63
- const raw = Number(process.env.HARNESSMIX_ANTIGRAVITY_EMPTY_RESULT_RETRIES);
64
- const retries = Number.isInteger(raw) && raw >= 0 ? raw : ANTIGRAVITY_EMPTY_RESULT_RETRIES;
65
- return 1 + retries;
66
- }
67
-
68
- function positiveDuration(value, fallback) {
69
- const duration = Number(value);
70
- return Number.isFinite(duration) && duration > 0 ? duration : fallback;
71
- }
72
-
73
- function resolveExecutable() {
74
- if (process.env.HARNESS_MIX_ANTIGRAVITY_COMMAND) return process.env.HARNESS_MIX_ANTIGRAVITY_COMMAND;
75
- if (process.env.HARNESSMIX_ANTIGRAVITY_COMMAND) return process.env.HARNESSMIX_ANTIGRAVITY_COMMAND;
76
- if (process.platform === 'win32') {
77
- const local = path.join(process.env.LOCALAPPDATA || '', 'agy', 'bin', 'agy.exe');
78
- if (fs.existsSync(local)) return local;
79
- }
80
- return 'agy';
81
- }
82
-
83
- function parseModelsOutput(output) {
84
- const grouped = new Map();
85
- for (const rawLine of String(output || '').split(/\r?\n/)) {
86
- const line = rawLine.trim();
87
- if (!line || line.startsWith('Fetching')) continue;
88
- const tabIdx = line.indexOf('\t');
89
- if (tabIdx <= 0) continue;
90
- const id = line.slice(0, tabIdx).trim();
91
- const label = line.slice(tabIdx + 1).trim();
92
- if (!id || !label) continue;
93
- const match = EFFORT_SUFFIX_PATTERN.exec(id);
94
- const baseId = match?.groups?.base || id;
95
- const baseLabel = match ? label.replace(EFFORT_LABEL_SUFFIX_PATTERN, '').trim() || label : label;
96
- const effort = match?.groups?.effort || null;
97
-
98
- if (!grouped.has(baseId)) {
99
- grouped.set(baseId, { id: baseId, name: baseLabel, efforts: [] });
100
- }
101
- const entry = grouped.get(baseId);
102
- if (effort && !entry.efforts.some((e) => e.id === effort)) {
103
- entry.efforts.push({ id: effort, label: EFFORT_LABELS[effort] || effort });
104
- }
105
- }
106
-
107
- const models = [];
108
- for (const entry of grouped.values()) {
109
- let provider = 'google';
110
- if (/claude/i.test(entry.id)) provider = 'anthropic';
111
- else if (/gpt|openai/i.test(entry.id)) provider = 'openai';
112
-
113
- const order = ['low', 'medium', 'high'];
114
- entry.efforts.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
115
-
116
- models.push({
117
- id: entry.id,
118
- name: entry.name,
119
- provider,
120
- efforts: entry.efforts,
121
- defaultEffort: entry.efforts.at(-1)?.id || 'high',
122
- contextWindow: /claude/i.test(entry.id) ? 200_000 : 1_048_576,
123
- });
124
- }
125
- return models;
126
- }
127
-
128
- function resolveContextWindow(modelId) {
129
- if (modelId && /claude/i.test(modelId)) return 200_000;
130
- return 1_048_576;
131
- }
132
-
133
- function usedPercentFrom(remainingFraction) {
134
- if (typeof remainingFraction !== 'number' || !Number.isFinite(remainingFraction)) return null;
135
- const used = (1 - Math.min(1, Math.max(0, remainingFraction))) * 100;
136
- return Math.round(used * 100) / 100;
137
- }
138
-
139
- function parseAntigravityUsageCommand(command, fetchedAt = new Date().toISOString()) {
140
- if (!command || typeof command !== 'object' || command.name !== 'usage' || !command.data) return null;
141
- const { groups } = command.data;
142
- if (!Array.isArray(groups)) return null;
143
- const buckets = [];
144
- for (const group of groups) {
145
- if (!group || !Array.isArray(group.buckets)) continue;
146
- const groupName = typeof group.name === 'string' ? group.name.trim() : '';
147
- for (const bucket of group.buckets) {
148
- if (!bucket || typeof bucket !== 'object') continue;
149
- const usagePercent = usedPercentFrom(bucket.remaining_fraction);
150
- if (usagePercent === null) continue;
151
- const window = typeof bucket.window === 'string' ? bucket.window.trim() : '';
152
- const label = window === 'weekly' ? 'Weekly window' : window === '5h' ? '5-hour window' : (window || bucket.id);
153
- const resetsAt = typeof bucket.reset_time === 'string' && bucket.reset_time.trim() ? bucket.reset_time.trim() : undefined;
154
- buckets.push({
155
- product: groupName ? `${groupName} · ${label}` : label,
156
- usagePercent,
157
- window,
158
- ...(resetsAt ? { resetsAt } : {}),
159
- });
160
- }
161
- }
162
- if (!buckets.length) return null;
163
- const leading = [...buckets].sort((a, b) => {
164
- if (b.usagePercent !== a.usagePercent) return b.usagePercent - a.usagePercent;
165
- return Number(b.window === '5h') - Number(a.window === '5h');
166
- })[0];
167
- const others = buckets.filter((b) => b !== leading);
168
- return {
169
- usedPercent: leading.usagePercent,
170
- periodType: leading.window === '5h' ? 'five_hour' : 'weekly',
171
- fetchedAt,
172
- ...(leading.resetsAt ? { resetsAt: leading.resetsAt } : {}),
173
- ...(others.length > 0
174
- ? { productUsage: others.map(({ product, usagePercent, resetsAt }) => ({ product, usagePercent, ...(resetsAt ? { resetsAt } : {}) })) }
175
- : {}),
176
- };
177
- }
178
-
179
- async function fetchAntigravityQuota(executable = resolveExecutable()) {
180
- return new Promise((resolve) => {
181
- execFile(executable, ['--print=/usage', '--output-format', 'stream-json'], { windowsHide: true, timeout: 15000 }, (err, stdout) => {
182
- if (err || !stdout) return resolve(null);
183
- for (const line of stdout.split(/\r?\n/)) {
184
- if (!line.trim()) continue;
185
- try {
186
- const event = JSON.parse(line);
187
- if (event?.event === 'command_result') {
188
- const snapshot = parseAntigravityUsageCommand(event.command);
189
- if (snapshot) return resolve(snapshot);
190
- }
191
- } catch {}
192
- }
193
- resolve(null);
194
- });
195
- });
196
- }
197
-
198
- function parseUsage(usageObj, modelId, quota) {
199
- if (!usageObj) return null;
200
- const input = usageObj.input_tokens ?? usageObj.inputTokens;
201
- const output = usageObj.output_tokens ?? usageObj.outputTokens;
202
- const thinking = usageObj.thinking_tokens ?? usageObj.reasoningOutputTokens;
203
- const cached = usageObj.cache_read_tokens ?? usageObj.cachedInputTokens;
204
- const total = usageObj.total_tokens ?? usageObj.totalTokens;
205
- const contextWindow = resolveContextWindow(modelId);
206
- const contextUsed = usageObj.context_used_tokens ?? usageObj.estimated_tokens_used ?? input;
207
- const tokens = typeof contextUsed === 'number' ? contextUsed : (typeof total === 'number' ? total : null);
208
- const result = {
209
- tokens,
210
- contextWindow,
211
- contextPercent: tokens != null && contextWindow ? Math.min(100, Math.round((100 * tokens / contextWindow) * 10) / 10) : null,
212
- inputTokens: typeof input === 'number' ? input : null,
213
- outputTokens: typeof output === 'number' ? output : null,
214
- reasoningOutputTokens: typeof thinking === 'number' ? thinking : null,
215
- cachedInputTokens: typeof cached === 'number' ? cached : null,
216
- totalTokens: typeof total === 'number' ? total : (typeof input === 'number' && typeof output === 'number' ? input + output : null),
217
- };
218
- if (quota) {
219
- if (quota.periodType === 'five_hour') {
220
- result.planFiveHourUsedPercent = quota.usedPercent;
221
- if (quota.resetsAt) {
222
- const unix = Math.floor(Date.parse(quota.resetsAt) / 1000);
223
- if (Number.isFinite(unix) && unix > 0) result.planFiveHourResetsAtUnix = unix;
224
- }
225
- }
226
- const weekly = quota.productUsage?.find(p => /weekly/i.test(p.product)) || (quota.periodType === 'weekly' ? quota : null);
227
- if (weekly) {
228
- result.planSevenDayUsedPercent = weekly.usagePercent;
229
- if (weekly.resetsAt) {
230
- const unix = Math.floor(Date.parse(weekly.resetsAt) / 1000);
231
- if (Number.isFinite(unix) && unix > 0) result.planSevenDayResetsAtUnix = unix;
232
- }
233
- }
234
- }
235
- return result;
236
- }
237
-
238
- function errorText(value) {
239
- if (value == null) return '';
240
- if (value instanceof Error) return value.message.trim();
241
- if (typeof value === 'string') return value.trim();
242
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
243
- if (Array.isArray(value)) return value.map(errorText).filter(Boolean).join('; ');
244
- if (typeof value === 'object') {
245
- const code = value.code ?? value.errorCode ?? value.status;
246
- const nested = value.message ?? value.errorMessage ?? value.error_message ?? value.detail ?? value.description ?? value.reason ?? value.error;
247
- const message = errorText(nested);
248
- if (message) {
249
- const codeText = code == null ? '' : String(code).trim();
250
- return codeText && !message.startsWith(`${codeText}:`) ? `${codeText}: ${message}` : message;
251
- }
252
- try { return JSON.stringify(value); } catch { return ''; }
253
- }
254
- return String(value);
255
- }
256
-
257
- function formatAntigravityResultError(result) {
258
- const detail = [result?.error, result?.errorMessage, result?.error_message, result?.message, result?.detail]
259
- .map(errorText)
260
- .find(Boolean);
261
- if (detail) return `Antigravity 回合失败:${detail}`;
262
- const status = errorText(result?.status);
263
- return status ? `Antigravity 回合失败(${status})` : 'Antigravity 回合失败(未知错误)';
264
- }
265
-
266
- function resultSucceeded(result) {
267
- return errorText(result?.status).toUpperCase() === 'SUCCESS';
268
- }
269
-
270
- function modelSupportsEffort(model) {
271
- const id = String(model?.id ?? '').trim();
272
- if (Array.isArray(model?.efforts)) return model.efforts.length > 0;
273
- // Claude models expose built-in thinking in agy and reject --effort.
274
- return !/^claude(?:[-_.]|$)/i.test(id);
275
- }
276
-
277
- function formatPrompt(text) {
278
- if (!text) return '';
279
- if (text.startsWith('/') || text.includes('ArtifactMetadata')) return text;
280
- return `${ANTIGRAVITY_WORKSPACE_FILE_INSTRUCTION}${text}`;
281
- }
282
-
283
- async function prepareImageAttachments(images, cwd) {
284
- if (!Array.isArray(images) || !images.length) return { imageEntries: [], extraDirs: [] };
285
- const imageEntries = [];
286
- const extraDirs = [];
287
- let targetDir = null;
288
- const ensureTargetDir = async () => {
289
- if (targetDir) return targetDir;
290
- targetDir = path.join(cwd, '.gemini', 'attachments');
291
- try {
292
- await fs.promises.mkdir(targetDir, { recursive: true });
293
- } catch {
294
- targetDir = path.join(os.tmpdir(), 'harness-mix-antigravity-attachments');
295
- await fs.promises.mkdir(targetDir, { recursive: true });
296
- if (!extraDirs.includes(targetDir)) extraDirs.push(targetDir);
297
- }
298
- return targetDir;
299
- };
300
-
301
- for (const img of images) {
302
- if (!img) continue;
303
- const ext = path.extname(img.name || '') || (img.mime === 'image/jpeg' ? '.jpg' : img.mime === 'image/gif' ? '.gif' : img.mime === 'image/webp' ? '.webp' : img.mime === 'image/svg+xml' ? '.svg' : '.png');
304
- const safeBase = path.basename(img.name || 'image', ext).replace(/[^a-zA-Z0-9_\-\u4e00-\u9fa5]/g, '_') || 'image';
305
- const fileName = `${Date.now()}-${randomUUID().slice(0, 6)}-${safeBase}${ext}`;
306
- const sourcePath = typeof img.path === 'string' && img.path ? path.resolve(cwd, img.path) : null;
307
- const hasData = typeof img.data === 'string' && img.data.length > 0;
308
- if (!sourcePath && !hasData) throw new Error(`图片附件「${img.name || 'image'}」缺少本地文件或内容`);
309
-
310
- // Desktop clipboard images live in %TEMP% and may disappear after this
311
- // turn. Always materialize a durable copy before putting a path in the
312
- // native prompt, even when the source still exists right now.
313
- const durableDir = await ensureTargetDir();
314
- const filePath = path.join(durableDir, fileName);
315
- if (hasData) await fs.promises.writeFile(filePath, Buffer.from(img.data, 'base64'));
316
- else await fs.promises.copyFile(sourcePath, filePath);
317
-
318
- if (!fs.existsSync(filePath)) {
319
- throw new Error(`图片附件「${img.name || 'image'}」保存失败`);
320
- }
321
- const normalizedPath = path.resolve(filePath).replace(/\\/g, '/');
322
- imageEntries.push({ name: img.name || path.basename(filePath), path: normalizedPath });
323
- }
324
- return { imageEntries, extraDirs };
325
- }
326
-
327
- async function restoreLegacyClipboardAttachments(thread) {
328
- const tempRoot = path.resolve(os.tmpdir());
329
- const restored = new Set();
330
- const messages = Array.isArray(thread?.messages) ? thread.messages : [];
331
- for (const message of messages) {
332
- const attachments = Array.isArray(message?.attachments) ? message.attachments : [];
333
- for (const attachment of attachments) {
334
- if (attachment?.kind !== 'image' || typeof attachment.path !== 'string' || !attachment.path || typeof attachment.data !== 'string' || !attachment.data) continue;
335
- const filePath = path.resolve(thread.cwd || process.cwd(), attachment.path);
336
- const relative = path.relative(tempRoot, filePath);
337
- if (!relative || path.isAbsolute(relative) || relative.includes(path.sep) || !/^codex-clipboard-[a-z0-9-]+\.(?:png|jpe?g|gif|webp)$/i.test(path.basename(filePath))) continue;
338
- if (fs.existsSync(filePath) || restored.has(filePath)) continue;
339
- try {
340
- await fs.promises.writeFile(filePath, Buffer.from(attachment.data, 'base64'));
341
- restored.add(filePath);
342
- } catch { /* A missing legacy temp file must not prevent session recovery. */ }
343
- }
344
- }
345
- return restored.size;
346
- }
347
-
348
- function toolTitle(name) {
349
- if (!name) return 'Antigravity 工具';
350
- if (name === 'run_command') return 'exec_command';
351
- if (name === 'write_to_file' || name === 'replace_file_content') return 'edit';
352
- if (name === 'view_file') return 'view_file';
353
- if (name === 'list_dir') return 'list_dir';
354
- if (name === 'grep_search') return 'grep_search';
355
- if (name === 'find_by_name') return 'find_by_name';
356
- if (name === 'read_url_content') return 'web_fetch';
357
- if (name === 'search_web') return 'search_web';
358
- if (name === 'ask_question') return '提问';
359
- if (name === 'generate_image') return '生成图片';
360
- if (name === 'schedule') return '调度任务';
361
- if (name === 'manage_task') return '后台任务';
362
- if (name === 'invoke_subagent') return '子 Agent';
363
- return name;
364
- }
365
-
366
- function mergePendingStep(pending, step) {
367
- if (!pending) return step;
368
- const toolName = step.tool_name ?? pending.tool_name;
369
- const name = step.tool_info?.name ?? pending.tool_info?.name;
370
- const parameters = step.tool_info?.parameters ?? pending.tool_info?.parameters;
371
- const output = step.tool_info?.output ?? pending.tool_info?.output;
372
- const error = step.tool_info?.error ?? pending.tool_info?.error;
373
- return {
374
- ...pending,
375
- ...step,
376
- ...(toolName !== undefined ? { tool_name: toolName } : {}),
377
- ...(step.tool_info !== undefined || pending.tool_info !== undefined
378
- ? {
379
- tool_info: {
380
- ...(name !== undefined ? { name } : {}),
381
- ...(parameters !== undefined ? { parameters } : {}),
382
- ...(output !== undefined ? { output } : {}),
383
- ...(error !== undefined ? { error } : {}),
384
- },
385
- }
386
- : {}),
387
- };
388
- }
389
-
390
- function toolInput(step) {
391
- const params = step.tool_info?.parameters;
392
- if (!params) return undefined;
393
- if (typeof params === 'string') return params;
394
- if (params.CommandLine) return params.CommandLine;
395
- if (params.TargetFile) return `${params.TargetFile}\n${params.Description || ''}`.trim();
396
- if (params.AbsolutePath) return params.AbsolutePath;
397
- if (params.DirectoryPath) return params.DirectoryPath;
398
- if (params.Url) return params.Url;
399
- if (params.query) return params.query;
400
- if (Array.isArray(params.Subagents)) {
401
- return params.Subagents.map((s) => `[${s.Role || s.TypeName || 'Agent'}] ${s.Prompt || ''}`.trim()).join('\n');
402
- }
403
- return JSON.stringify(params, null, 2);
404
- }
405
-
406
- function toolOutput(step) {
407
- const info = step.tool_info;
408
- if (!info) return undefined;
409
- if (info.error) return typeof info.error === 'string' ? info.error : JSON.stringify(info.error, null, 2);
410
- if (info.output != null) return typeof info.output === 'string' ? info.output : JSON.stringify(info.output, null, 2);
411
- return undefined;
412
- }
413
-
414
- function nativeConversationDbPath(sessionId, homedir = os.homedir()) {
415
- return path.join(homedir, '.gemini', 'antigravity-cli', 'conversations', `${sessionId}.db`);
416
- }
417
-
418
- function nativeBrainDirPath(sessionId, homedir = os.homedir()) {
419
- return path.join(homedir, '.gemini', 'antigravity-cli', 'brain', sessionId);
420
- }
421
-
422
- async function cloneDatabase(sourceId, derivedId, retainedTurnsCountOrHomedir, homedirOption = os.homedir()) {
423
- let retainedTurnsCount;
424
- let homedir = homedirOption;
425
- if (typeof retainedTurnsCountOrHomedir === 'string') {
426
- homedir = retainedTurnsCountOrHomedir;
427
- } else if (typeof retainedTurnsCountOrHomedir === 'number') {
428
- retainedTurnsCount = retainedTurnsCountOrHomedir;
429
- }
430
-
431
- const sourceDb = nativeConversationDbPath(sourceId, homedir);
432
- const targetDb = nativeConversationDbPath(derivedId, homedir);
433
- try {
434
- await fs.promises.mkdir(path.dirname(targetDb), { recursive: true });
435
- await fs.promises.copyFile(sourceDb, targetDb);
436
- } catch {
437
- return false;
438
- }
439
-
440
- let remainingStepCount = 0;
441
- try {
442
- const { DatabaseSync } = require('node:sqlite');
443
- const db = new DatabaseSync(targetDb);
444
- try {
445
- db.prepare('UPDATE trajectory_meta SET cascade_id = ?').run(derivedId);
446
- if (typeof retainedTurnsCount === 'number' && Number.isInteger(retainedTurnsCount)) {
447
- try {
448
- if (retainedTurnsCount <= 0) {
449
- db.prepare('DELETE FROM steps').run();
450
- } else {
451
- const rows = db.prepare('SELECT idx FROM steps WHERE step_type = 14 ORDER BY idx ASC').all();
452
- const cutoff = rows[retainedTurnsCount];
453
- if (cutoff && typeof cutoff.idx === 'number') {
454
- db.prepare('DELETE FROM steps WHERE idx >= ?').run(cutoff.idx);
455
- }
456
- }
457
- } catch {}
458
- try { db.prepare('DELETE FROM gen_metadata WHERE idx >= ?').run(retainedTurnsCount); } catch {}
459
- try { db.prepare('DELETE FROM executor_metadata WHERE idx >= ?').run(retainedTurnsCount); } catch {}
460
- try { db.prepare('DELETE FROM parent_references WHERE idx >= ?').run(retainedTurnsCount); } catch {}
461
- try { db.prepare('DELETE FROM battle_mode_infos WHERE idx >= ?').run(retainedTurnsCount); } catch {}
462
- }
463
- try {
464
- const countRow = db.prepare('SELECT count(*) as c FROM steps').get();
465
- if (countRow && typeof countRow.c === 'number') remainingStepCount = countRow.c;
466
- } catch {}
467
- } finally {
468
- db.close();
469
- }
470
-
471
- // Register in conversation_summaries.db so `agy` trajectory lookup succeeds
472
- try {
473
- const summariesDbPath = path.join(homedir, '.gemini', 'antigravity-cli', 'conversation_summaries.db');
474
- if (fs.existsSync(summariesDbPath)) {
475
- const sumDb = new DatabaseSync(summariesDbPath);
476
- try {
477
- const row = sumDb.prepare('SELECT * FROM conversation_summaries WHERE conversation_id = ?').get(sourceId);
478
- if (row) {
479
- const cols = Object.keys(row);
480
- const newRow = {
481
- ...row,
482
- conversation_id: derivedId,
483
- last_modified_time: new Date().toISOString(),
484
- ...(typeof retainedTurnsCount === 'number' ? { step_count: remainingStepCount } : {}),
485
- };
486
- const placeholders = cols.map(() => '?').join(', ');
487
- const values = cols.map((col) => newRow[col]);
488
- sumDb
489
- .prepare(
490
- `INSERT OR REPLACE INTO conversation_summaries (${cols.map((c) => `\`${c}\``).join(', ')}) VALUES (${placeholders})`
491
- )
492
- .run(...values);
493
- }
494
- } finally {
495
- sumDb.close();
496
- }
497
- }
498
- } catch {}
499
- } catch {}
500
- return true;
501
- }
502
-
503
- async function cloneBrain(sourceId, derivedId, homedir = os.homedir()) {
504
- const sourceBrain = nativeBrainDirPath(sourceId, homedir);
505
- const targetBrain = nativeBrainDirPath(derivedId, homedir);
506
- try {
507
- await fs.promises.cp(sourceBrain, targetBrain, {
508
- recursive: true,
509
- filter: (src) => path.basename(src) !== '.system_generated',
510
- });
511
- return true;
512
- } catch {
513
- return false;
514
- }
515
- }
516
-
517
- const ANTIGRAVITY_QUESTION_HOOK_CLIENT = `
518
- const http = require("node:http");
519
- const limit = 131072;
520
- let input = "";
521
- let finished = false;
522
- let request;
523
- function finish(value) {
524
- if (finished) return;
525
- finished = true;
526
- process.stdout.write(JSON.stringify(value));
527
- }
528
- function unavailable() {
529
- finish({
530
- decision: "deny",
531
- reason: "harnessmix question bridge is unavailable. No user answer was received. Do not report that the user skipped or selected an option."
532
- });
533
- if (request) request.destroy();
534
- }
535
- process.stdin.setEncoding("utf8");
536
- process.stdin.on("error", unavailable);
537
- process.stdin.on("data", chunk => {
538
- input += chunk;
539
- if (Buffer.byteLength(input) > limit) {
540
- input = "";
541
- process.stdin.destroy();
542
- unavailable();
543
- }
544
- });
545
- process.stdin.on("end", () => {
546
- if (finished) return;
547
- try {
548
- const target = new URL(process.env.HARNESSMIX_AGY_QUESTION_URL);
549
- if (target.protocol !== "http:" || target.hostname !== "127.0.0.1" ||
550
- target.pathname !== "/question" || target.username || target.password) {
551
- return unavailable();
552
- }
553
- const payload = JSON.parse(input);
554
- const approval = process.env.HARNESSMIX_AGY_QUESTION_APPROVALS === "1" &&
555
- payload.toolCall && payload.toolCall.name !== "ask_question";
556
- request = http.request(target, {
557
- method: "POST",
558
- headers: {
559
- "content-type": "application/json",
560
- "authorization": "Bearer " + process.env.HARNESSMIX_AGY_QUESTION_TOKEN,
561
- "content-length": Buffer.byteLength(input)
562
- }
563
- }, response => {
564
- let body = "";
565
- response.setEncoding("utf8");
566
- response.on("error", unavailable);
567
- response.on("aborted", unavailable);
568
- response.on("data", chunk => {
569
- body += chunk;
570
- if (Buffer.byteLength(body) > limit) unavailable();
571
- });
572
- response.on("end", () => {
573
- try {
574
- const value = JSON.parse(body);
575
- if (response.statusCode !== 200 ||
576
- (value.decision !== "deny" && !(approval && value.decision === "allow")) ||
577
- typeof value.reason !== "string") return unavailable();
578
- finish(value);
579
- } catch { unavailable(); }
580
- });
581
- });
582
- request.setTimeout(Number(process.env.HARNESSMIX_AGY_QUESTION_TIMEOUT_MS) + 5000, unavailable);
583
- request.on("error", unavailable);
584
- request.end(input);
585
- } catch { unavailable(); }
586
- });
587
- `;
588
-
589
- class QuestionBridge {
590
- constructor({ approvals, emit, timeoutMs = 600000 }) {
591
- this.approvals = approvals;
592
- this.emit = emit;
593
- this.timeoutMs = timeoutMs;
594
- this.token = randomBytes(32).toString('hex');
595
- this.pending = new Map();
596
- this.directory = '';
597
- this.server = null;
598
- this.environment = {};
599
- }
600
-
601
- static async create(options) {
602
- const bridge = new QuestionBridge(options);
603
- const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'harnessmix-agy-question-'));
604
- bridge.directory = dir;
605
-
606
- bridge.server = http.createServer((req, res) => bridge.#receive(req, res));
607
- await new Promise((resolve, reject) => {
608
- bridge.server.once('error', reject);
609
- bridge.server.listen(0, '127.0.0.1', () => {
610
- bridge.server.removeListener('error', reject);
611
- resolve();
612
- });
613
- });
614
-
615
- const address = bridge.server.address();
616
- const port = address.port;
617
- const clientPath = path.join(dir, 'question-hook.cjs');
618
- await fs.promises.mkdir(path.join(dir, '.agents'), { recursive: true });
619
- await fs.promises.writeFile(clientPath, ANTIGRAVITY_QUESTION_HOOK_CLIENT, 'utf8');
620
-
621
- const hookCmd = process.platform === 'win32'
622
- ? '%HARNESSMIX_AGY_QUESTION_NODE% %HARNESSMIX_AGY_QUESTION_CLIENT%'
623
- : `"${process.execPath}" "${clientPath}"`;
624
-
625
- await fs.promises.writeFile(
626
- path.join(dir, '.agents', 'hooks.json'),
627
- JSON.stringify({
628
- 'harnessmix-question-bridge': {
629
- PreToolUse: [
630
- {
631
- matcher: options.approvals ? '.*' : '^ask_question$',
632
- hooks: [
633
- {
634
- type: 'command',
635
- command: hookCmd,
636
- timeout: Math.ceil(options.timeoutMs / 1000) + 15,
637
- },
638
- ],
639
- },
640
- ],
641
- },
642
- }, null, 2),
643
- 'utf8',
644
- );
645
-
646
- bridge.environment = {
647
- HARNESSMIX_AGY_QUESTION_TOKEN: bridge.token,
648
- HARNESSMIX_AGY_QUESTION_URL: `http://127.0.0.1:${port}/question`,
649
- HARNESSMIX_AGY_QUESTION_TIMEOUT_MS: String(options.timeoutMs),
650
- HARNESSMIX_AGY_QUESTION_APPROVALS: options.approvals ? '1' : '0',
651
- HARNESSMIX_AGY_QUESTION_NODE: `"${process.execPath.replaceAll('\\', '/')}"`,
652
- HARNESSMIX_AGY_QUESTION_CLIENT: `"${clientPath.replaceAll('\\', '/')}"`,
653
- };
654
-
655
- if (options.collaboration || options.managedMcp?.length) {
656
- const pluginDir = path.join(dir, '.agents', 'plugins', 'harness-mix');
657
- const plainPluginDir = path.join(dir, 'plugins', 'harness-mix');
658
- await fs.promises.mkdir(pluginDir, { recursive: true });
659
- await fs.promises.mkdir(plainPluginDir, { recursive: true });
660
- const pluginManifest = JSON.stringify({ name: 'harness-mix-collaboration', description: 'Harness Mix Multi-agent Collaboration' }, null, 2);
661
- const mcpConfig = JSON.stringify({
662
- mcpServers: require('./managed-mcp').namedServers(options.managedMcp, options.collaboration),
663
- }, null, 2);
664
- await fs.promises.writeFile(path.join(pluginDir, 'plugin.json'), pluginManifest, 'utf8');
665
- await fs.promises.writeFile(path.join(pluginDir, 'mcp_config.json'), mcpConfig, 'utf8');
666
- await fs.promises.writeFile(path.join(plainPluginDir, 'plugin.json'), pluginManifest, 'utf8');
667
- await fs.promises.writeFile(path.join(plainPluginDir, 'mcp_config.json'), mcpConfig, 'utf8');
668
- await fs.promises.writeFile(path.join(dir, '.agents', 'mcp_config.json'), mcpConfig, 'utf8');
669
- Object.assign(bridge.environment, options.collaboration?.env || {});
670
- }
671
-
672
- return bridge;
673
- }
674
-
675
- #receive(req, res) {
676
- const auth = req.headers.authorization || '';
677
- if (auth !== `Bearer ${this.token}` || req.method !== 'POST' || req.url !== '/question') {
678
- res.writeHead(403, { 'content-type': 'application/json' });
679
- res.end(JSON.stringify({ decision: 'deny', reason: 'Forbidden' }));
680
- return;
681
- }
682
- let body = '';
683
- req.setEncoding('utf8');
684
- req.on('data', (chunk) => { body += chunk; });
685
- req.on('end', () => {
686
- let payload;
687
- try { payload = JSON.parse(body); }
688
- catch {
689
- res.writeHead(400, { 'content-type': 'application/json' });
690
- res.end(JSON.stringify({ decision: 'deny', reason: 'Invalid JSON' }));
691
- return;
692
- }
693
- this.#handlePayload(payload, res);
694
- });
695
- }
696
-
697
- #handlePayload(payload, res) {
698
- const toolCall = payload.toolCall || {};
699
- const toolName = toolCall.name;
700
- const conversationId = payload.conversationId;
701
-
702
- if (toolName === 'ask_question') {
703
- const questions = toolCall.args?.questions || [];
704
- const q = questions[0] || { question: 'Antigravity 提问' };
705
- const requestId = `antigravity-q-${randomUUID()}`;
706
- this.pending.set(requestId, { type: 'question', questions, response: res });
707
- this.emit({
708
- kind: 'approval',
709
- requestId,
710
- method: q.options?.length ? undefined : 'input',
711
- title: 'Antigravity 提问',
712
- message: q.question,
713
- options: (q.options || []).map((opt) => ({ id: opt, label: opt })),
714
- placeholder: '请输入或选择…',
715
- nativeRef: { sessionId: conversationId },
716
- });
717
- return;
718
- }
719
-
720
- if (this.approvals && toolName) {
721
- const requestId = `antigravity-app-${randomUUID()}`;
722
- this.pending.set(requestId, { type: 'approval', toolCall, response: res });
723
- this.emit({
724
- kind: 'approval',
725
- requestId,
726
- title: `Antigravity: ${toolName}`,
727
- message: `请求执行工具 ${toolName}\n${JSON.stringify(toolCall.args || {}, null, 2)}`,
728
- options: [
729
- { id: 'allow', label: '允许' },
730
- { id: 'deny', label: '拒绝', kind: 'reject' },
731
- ],
732
- nativeRef: { sessionId: conversationId },
733
- });
734
- return;
735
- }
736
-
737
- res.writeHead(200, { 'content-type': 'application/json' });
738
- res.end(JSON.stringify({ decision: 'allow' }));
739
- }
740
-
741
- respond(requestId, response) {
742
- const pending = this.pending.get(requestId);
743
- if (!pending) return false;
744
- this.pending.delete(requestId);
745
-
746
- if (pending.type === 'question') {
747
- const answer = response?.cancelled ? '' : String(response?.optionId ?? response?.value ?? '');
748
- const result = response?.cancelled
749
- ? { status: 'cancelled', answers: {} }
750
- : {
751
- status: 'answered',
752
- answers: (pending.questions || []).map((q) => ({
753
- question: q.question,
754
- answers: answer ? [answer] : [],
755
- })),
756
- };
757
- const reason =
758
- 'harnessmix handled this question through the Desktop. Native ask_question is blocked only to prevent automatic skipping. ' +
759
- 'The following JSON contains the actual user response, not a tool permission decision: ' +
760
- JSON.stringify(result);
761
- if (!pending.response.writableEnded) {
762
- pending.response.writeHead(200, { 'content-type': 'application/json' });
763
- pending.response.end(JSON.stringify({ decision: 'deny', reason }));
764
- }
765
- return true;
766
- }
767
-
768
- if (pending.type === 'approval') {
769
- const allowed = !response?.cancelled && (response?.optionId === 'allow' || response?.confirmed !== false);
770
- const decision = allowed ? 'allow' : 'deny';
771
- const reason = allowed ? 'User approved this tool call once.' : 'User denied this tool call.';
772
- if (!pending.response.writableEnded) {
773
- pending.response.writeHead(200, { 'content-type': 'application/json' });
774
- pending.response.end(JSON.stringify({ decision, reason }));
775
- }
776
- return true;
777
- }
778
- return false;
779
- }
780
-
781
- async dispose() {
782
- for (const pending of this.pending.values()) {
783
- if (!pending.response.writableEnded) {
784
- pending.response.writeHead(200, { 'content-type': 'application/json' });
785
- pending.response.end(JSON.stringify({ decision: 'deny', reason: 'Session closed' }));
786
- }
787
- }
788
- this.pending.clear();
789
- if (this.server) {
790
- await new Promise((resolve) => this.server.close(resolve)).catch(() => {});
791
- this.server = null;
792
- }
793
- if (this.directory) {
794
- await fs.promises.rm(this.directory, { recursive: true, force: true }).catch(() => {});
795
- this.directory = '';
796
- }
797
- }
798
- }
799
-
800
- function create(emit, options = {}) {
801
- let cachedQuota = null;
802
- let quotaPromise = null;
803
- const spawnProcess = options.spawnProcess || spawn;
804
- const resultDrainMs = positiveDuration(
805
- options.resultDrainMs ?? process.env.HARNESS_MIX_ANTIGRAVITY_RESULT_DRAIN_MS,
806
- ANTIGRAVITY_RESULT_DRAIN_MS,
807
- );
808
- const resultDrainMaxMs = Math.max(
809
- resultDrainMs,
810
- positiveDuration(
811
- options.resultDrainMaxMs ?? process.env.HARNESS_MIX_ANTIGRAVITY_RESULT_DRAIN_MAX_MS,
812
- ANTIGRAVITY_RESULT_DRAIN_MAX_MS,
813
- ),
814
- );
815
-
816
- return {
817
- manifest,
818
-
819
- async inspect() {
820
- const bin = resolveExecutable();
821
- return new Promise((resolve) => {
822
- execFile(bin, ['--version'], { windowsHide: true, timeout: 10000 }, (error, stdout) => {
823
- if (error) {
824
- resolve({ available: false, detail: '未找到 Antigravity CLI(agy)' });
825
- } else {
826
- if (!cachedQuota) fetchAntigravityQuota().then((q) => { if (q) cachedQuota = q; }).catch(() => {});
827
- resolve({ available: true, detail: `agy ${String(stdout).trim()}` });
828
- }
829
- });
830
- });
831
- },
832
-
833
- async open({ thread, emit: emitEvent, diagnostic, collaboration, managedMcp = [] }) {
834
- // Do not start a second agy process while a real turn may be starting.
835
- // Quota is loaded only by the explicit account/usage refresh path below.
836
- await restoreLegacyClipboardAttachments(thread);
837
- const model = thread.options?.model ?? { id: 'gemini-3.8-flash', name: 'Gemini 3.8 Flash', provider: 'google' };
838
- const thinkingLevel = thread.options?.thinking ?? 'high';
839
- const permissionMode = thread.options?.permissionMode ?? 'default';
840
- const session = {
841
- nativeSessionId: thread.nativeSessionId || null,
842
- cwd: thread.cwd,
843
- model,
844
- thinkingLevel,
845
- permissionMode,
846
- collaboration: collaboration || null,
847
- managedMcp,
848
- collaborationEnabled: !!collaboration,
849
- activeTurn: null,
850
- bridge: null,
851
- usage: undefined,
852
- pendingApprovals: new Map(),
853
- };
854
- if (thread.restore && thread.nativeSessionId) {
855
- emitEvent({ kind: 'session', nativeSessionId: thread.nativeSessionId, model });
856
- }
857
- return session;
858
- },
859
-
860
- async send(session, prompt, hooks, attachments) {
861
- if (session.activeTurn) throw new Error('Antigravity 当前回合尚未结束');
862
-
863
- const emitEvent = hooks?.emit || emit;
864
-
865
- let effectivePrompt = prompt ?? '';
866
- const { imageEntries, extraDirs } = await prepareImageAttachments(attachments?.images, session.cwd);
867
- if (imageEntries.length) {
868
- const imageNotice = [
869
- '[用户上传了图片附件]',
870
- '[仅使用本轮列出的持久化副本路径;不要复用历史消息中的 codex-clipboard 临时路径。]',
871
- ...imageEntries.map(e => `- [${e.name}](file:///${e.path}): 请使用 view_file 工具查看并分析该图片。`),
872
- ].join('\n');
873
- effectivePrompt = effectivePrompt.trim()
874
- ? `${imageNotice}\n\n${effectivePrompt}`
875
- : `${imageNotice}\n\n请查看并分析上述图片附件。`;
876
- }
877
-
878
- // 单轮执行:spawn agy、转发 stream-json 事件、等待 result 结算。
879
- // suppressEmptyResultError 用于自动补问场景——空 SUCCESS 的第一轮不向 UI
880
- // 报错,仅在最后一次尝试仍然为空时才发出 error 事件。
881
- const runTurn = async (promptText, { suppressEmptyResultError = false } = {}) => {
882
- const bin = resolveExecutable();
883
- const approvals = session.permissionMode === 'desktop' || session.permissionMode === 'desktop-approvals';
884
- const bridge = await QuestionBridge.create({
885
- approvals,
886
- emit: emitEvent,
887
- collaboration: session.collaboration,
888
- managedMcp: session.managedMcp,
889
- });
890
- session.bridge = bridge;
891
-
892
- const logPath = path.join(os.tmpdir(), `harness-mix-antigravity-${randomUUID()}.log`);
893
- const args = [
894
- '--input-format', 'stream-json',
895
- '--output-format', 'stream-json',
896
- '--print-timeout', '30m',
897
- ];
898
- if (session.nativeSessionId) {
899
- args.push('--conversation', session.nativeSessionId);
900
- }
901
- if (session.model?.id) {
902
- args.push('--model', session.model.id);
903
- }
904
- if (session.thinkingLevel && modelSupportsEffort(session.model)) {
905
- args.push('--effort', session.thinkingLevel);
906
- }
907
- if (['skip', 'desktop', 'dangerously-skip-permissions', 'desktop-approvals'].includes(session.permissionMode)) {
908
- args.push('--dangerously-skip-permissions');
909
- }
910
- args.push('--add-dir', session.cwd);
911
- args.push('--add-dir', bridge.directory);
912
- for (const dir of extraDirs) {
913
- args.push('--add-dir', dir);
914
- }
915
- args.push('--log-file', logPath);
916
-
917
- const child = spawnProcess(bin, args, {
918
- cwd: session.cwd,
919
- env: { ...process.env, ...bridge.environment },
920
- windowsHide: true,
921
- stdio: ['pipe', 'pipe', 'pipe'],
922
- });
923
- // 进程崩溃/提前退出时,迟到的 stdin.write/end 在流上异步抛 EPIPE;
924
- // 无 error 监听的 Writable 会被 Node 当作未捕获异常 crash 宿主进程
925
- child.stdin.on('error', () => {});
926
-
927
- let turnResolve, turnReject;
928
- const settled = new Promise((resolve, reject) => {
929
- turnResolve = resolve;
930
- turnReject = reject;
931
- });
932
-
933
- const turn = {
934
- child,
935
- resolve: turnResolve,
936
- reject: turnReject,
937
- logPath,
938
- resultSeen: false,
939
- result: null,
940
- completed: false,
941
- processClosing: false,
942
- processClosed: false,
943
- killTimer: null,
944
- resultDrainTimer: null,
945
- resultDrainMaxTimer: null,
946
- pendingSteps: new Map(),
947
- };
948
- session.activeTurn = turn;
949
- let sawTextSinceLastTool = false;
950
- let stderrTail = '';
951
- child.stderr?.setEncoding('utf8')?.on('data', (chunk) => {
952
- stderrTail = (stderrTail + chunk).slice(-8000);
953
- });
954
-
955
- // agy 在 stream-json 模式下产出 result 后进程仍常驻(实测 25s+ 不自行退出),
956
- // 不主动收尾会让 session.activeTurn 永远悬挂,下一回合 send 被“当前回合尚未结束”拒绝。
957
- // 但某些工具(尤其 view_file 图片)会先产生没有 output 字段的 DONE,随后才
958
- // 刷出最终 assistant_response。先结算 Turn 再结束 stdin 会把这段迟到文本丢给
959
- // 已关闭的 Normalizer,所以空结果时必须先做一个有界 drain。
960
- const clearResultDrainTimers = () => {
961
- if (turn.resultDrainTimer) clearTimeout(turn.resultDrainTimer);
962
- if (turn.resultDrainMaxTimer) clearTimeout(turn.resultDrainMaxTimer);
963
- turn.resultDrainTimer = null;
964
- turn.resultDrainMaxTimer = null;
965
- };
966
-
967
- const closeProcess = () => {
968
- if (turn.processClosing || turn.processClosed) return;
969
- turn.processClosing = true;
970
- try { child.stdin.end(); } catch {}
971
- turn.killTimer = setTimeout(() => { void terminateTree(child.pid); }, 10000);
972
- turn.killTimer.unref?.();
973
- };
974
-
975
- const settleResult = () => {
976
- if (turn.completed || !turn.result) return;
977
- turn.completed = true;
978
- clearResultDrainTimers();
979
- if (session.activeTurn === turn) session.activeTurn = null;
980
- const { res, nativeRef } = turn.result;
981
- const completedRef = {
982
- ...nativeRef,
983
- turnId: res.num_turns != null ? `turn:${res.num_turns}` : undefined,
984
- checkpointId: res.num_turns != null ? String(res.num_turns) : undefined,
985
- };
986
- const successful = resultSucceeded(res);
987
- const hasAssistantText = (typeof res.response === 'string'
988
- ? Boolean(res.response.trim())
989
- : Boolean(res.response)) || sawTextSinceLastTool;
990
- if (!successful) {
991
- const message = formatAntigravityResultError(res);
992
- emitEvent({ kind: 'error', message, nativeRef: completedRef });
993
- turnReject?.(new Error(message));
994
- closeProcess();
995
- return;
996
- }
997
- if (!hasAssistantText) {
998
- const message = 'Antigravity 回合返回 SUCCESS,但没有 assistant 文本';
999
- const error = new Error(message);
1000
- // 标记为可自动补问的失败:由 send 的重试循环决定是否向 UI 报错
1001
- error.code = 'ANTIGRAVITY_EMPTY_ASSISTANT';
1002
- error.nativeRef = completedRef;
1003
- if (!suppressEmptyResultError) {
1004
- emitEvent({ kind: 'error', message, nativeRef: completedRef });
1005
- }
1006
- turnReject?.(error);
1007
- closeProcess();
1008
- return;
1009
- }
1010
- emitEvent({
1011
- kind: 'completed',
1012
- finalAnswer: true,
1013
- stopReason: 'completed',
1014
- nativeRef: completedRef,
1015
- });
1016
- turnResolve?.();
1017
- closeProcess();
1018
- };
1019
-
1020
- const scheduleResultDrain = () => {
1021
- if (!turn.resultSeen || turn.completed || !turn.result) return;
1022
- if (!resultSucceeded(turn.result.res)) {
1023
- settleResult();
1024
- return;
1025
- }
1026
- const hasAssistantText = (typeof turn.result.res.response === 'string'
1027
- ? Boolean(turn.result.res.response.trim())
1028
- : Boolean(turn.result.res.response)) || sawTextSinceLastTool;
1029
- if (hasAssistantText) {
1030
- if (turn.resultDrainMaxTimer) clearTimeout(turn.resultDrainMaxTimer);
1031
- turn.resultDrainMaxTimer = null;
1032
- if (turn.resultDrainTimer) clearTimeout(turn.resultDrainTimer);
1033
- turn.resultDrainTimer = setTimeout(settleResult, resultDrainMs);
1034
- return;
1035
- }
1036
- // No text at result time is not proof that the native turn is finished.
1037
- // Keep the Core turn active until a late assistant response arrives, but
1038
- // always retain a hard ceiling for a genuinely tool-only/empty turn.
1039
- if (!turn.resultDrainMaxTimer) {
1040
- turn.resultDrainMaxTimer = setTimeout(settleResult, resultDrainMaxMs);
1041
- }
1042
- };
1043
-
1044
- const rl = readline.createInterface({ input: child.stdout });
1045
- rl.on('line', (line) => {
1046
- let event;
1047
- try { event = JSON.parse(line.trim()); } catch { return; }
1048
- recordNative(manifest.id, event);
1049
-
1050
- const nativeRef = {
1051
- sessionId: session.nativeSessionId || event.conversation_id || event.result?.conversation_id,
1052
- };
1053
-
1054
- if (event.event === 'init') {
1055
- session.nativeSessionId = event.conversation_id;
1056
- emitEvent({ kind: 'session', nativeSessionId: event.conversation_id, model: session.model });
1057
- return;
1058
- }
1059
-
1060
- if (event.event === 'step_update') {
1061
- const rawStep = event.step_update || {};
1062
- const prevStep = turn.pendingSteps.get(rawStep.step_index);
1063
- const s = mergePendingStep(prevStep, rawStep);
1064
- if (s.step_index != null) turn.pendingSteps.set(s.step_index, s);
1065
- const stepRef = { ...nativeRef, itemId: String(s.step_index) };
1066
-
1067
- if (s.step_type === 'agent_response') {
1068
- const text = s.text_delta || s.text || s.content || s.message;
1069
- if (typeof text === 'string' && text) {
1070
- sawTextSinceLastTool = true;
1071
- emitEvent({ kind: 'text-delta', text, nativeRef: stepRef });
1072
- scheduleResultDrain();
1073
- }
1074
- return;
1075
- }
1076
-
1077
- if (s.step_type === 'thought' || s.step_type === 'thinking') {
1078
- const thinkingText = s.text_delta || s.text || s.content;
1079
- if (thinkingText) {
1080
- emitEvent({ kind: 'thinking-delta', text: thinkingText, nativeRef: stepRef });
1081
- }
1082
- return;
1083
- }
1084
-
1085
- if (s.step_type === 'subagent') {
1086
- sawTextSinceLastTool = false;
1087
- const info = s.subagent_info || {};
1088
- const subagents = Array.isArray(info.subagents) ? info.subagents : [];
1089
- const state = s.state === 'DONE' ? 'done' : (s.state === 'ERROR' ? 'error' : 'running');
1090
- const details = subagents.map((sub) => `[${sub.role || sub.type_name || 'Subagent'}] ID: ${sub.conversation_id || ''}\n${sub.initial_prompt || ''}`.trim()).join('\n\n');
1091
- emitEvent({
1092
- kind: 'tool',
1093
- toolCallId: String(s.step_index),
1094
- title: '子 Agent',
1095
- state,
1096
- input: details || undefined,
1097
- output: s.state === 'DONE' ? ' Agent 启动完成' : undefined,
1098
- nativeRef: stepRef,
1099
- });
1100
- return;
1101
- }
1102
-
1103
- if (s.step_type === 'tool') {
1104
- sawTextSinceLastTool = false;
1105
- const toolName = s.tool_name || s.tool_info?.name;
1106
- const state = s.state === 'DONE' ? 'done' : (s.state === 'ERROR' ? 'error' : 'running');
1107
- emitEvent({
1108
- kind: 'tool',
1109
- toolCallId: String(s.step_index),
1110
- title: toolTitle(toolName),
1111
- state,
1112
- input: toolInput(s),
1113
- output: toolOutput(s),
1114
- nativeRef: stepRef,
1115
- });
1116
-
1117
- // Native file mutations
1118
- if (s.state === 'DONE' && (toolName === 'write_to_file' || toolName === 'replace_file_content')) {
1119
- const params = s.tool_info?.parameters || {};
1120
- // agy stream-json 工具事件只携带 TargetFile,不携带文件内容(实测 ACTIVE/DONE 均如此)。
1121
- // 保持 complete: false(与 codex.js 同一约定):仅作实时提示,回合结算时由
1122
- // 工作区快照(ReviewStore)用权威 before/after 覆盖,UI 才能显示真实增删统计。
1123
- const target = typeof params.TargetFile === 'string' ? params.TargetFile : null;
1124
- const relative = target ? path.relative(session.cwd, path.resolve(session.cwd, target)).replace(/\\/g, '/') : '';
1125
- // 只投影工作区内的变更;脑目录等 --add-dir 目录不属于本轮文件审查
1126
- if (relative && relative !== '..' && !relative.startsWith('../') && !path.isAbsolute(relative)) {
1127
- emitEvent({
1128
- kind: 'file-change',
1129
- source: 'native',
1130
- changes: [{
1131
- path: target,
1132
- changeType: toolName === 'write_to_file' ? (params.Overwrite ? 'modified' : 'added') : 'modified',
1133
- complete: false,
1134
- nativeRef: stepRef,
1135
- }],
1136
- nativeRef: stepRef,
1137
- });
1138
- }
1139
- }
1140
- }
1141
-
1142
- if (s.usage) {
1143
- const u = parseUsage(s.usage, session.model?.id, cachedQuota);
1144
- if (u) {
1145
- session.usage = u;
1146
- emitEvent({ kind: 'usage', usage: u, nativeRef: stepRef });
1147
- }
1148
- }
1149
- return;
1150
- }
1151
-
1152
- if (event.event === 'result') {
1153
- if (turn.resultSeen || turn.completed) return;
1154
- const res = event.result || {};
1155
- if (res.conversation_id && !session.nativeSessionId) {
1156
- session.nativeSessionId = res.conversation_id;
1157
- }
1158
- if (res.usage) {
1159
- const u = parseUsage(res.usage, session.model?.id, cachedQuota);
1160
- if (u) {
1161
- session.usage = u;
1162
- emitEvent({ kind: 'usage', usage: u, nativeRef });
1163
- }
1164
- }
1165
- if (res.response && !sawTextSinceLastTool) {
1166
- if (typeof res.response === 'string') {
1167
- sawTextSinceLastTool = true;
1168
- emitEvent({ kind: 'text-delta', text: res.response, nativeRef });
1169
- }
1170
- }
1171
- turn.resultSeen = true;
1172
- turn.result = { res, nativeRef };
1173
- scheduleResultDrain();
1174
- }
1175
- });
1176
-
1177
- child.on('error', (err) => {
1178
- if (turn.resultSeen) {
1179
- settleResult();
1180
- return;
1181
- }
1182
- if (turn.killTimer) clearTimeout(turn.killTimer);
1183
- clearResultDrainTimers();
1184
- turn.completed = true;
1185
- if (session.activeTurn === turn) session.activeTurn = null;
1186
- void fs.promises.unlink(logPath).catch(() => {});
1187
- void bridge.dispose();
1188
- if (session.bridge === bridge) session.bridge = null;
1189
- emitEvent({ kind: 'error', message: `Antigravity 进程错误: ${err.message}` });
1190
- turnReject?.(err);
1191
- });
1192
-
1193
- child.on('close', (code) => {
1194
- turn.processClosed = true;
1195
- if (turn.killTimer) clearTimeout(turn.killTimer);
1196
- clearResultDrainTimers();
1197
- void fs.promises.unlink(logPath).catch(() => {});
1198
- void bridge.dispose();
1199
- if (session.bridge === bridge) session.bridge = null;
1200
- if (turn.resultSeen) {
1201
- // A real process close means stdout can no longer deliver a late
1202
- // assistant event; settle the already received result now.
1203
- settleResult();
1204
- } else if (!turn.completed) {
1205
- turn.completed = true;
1206
- if (session.activeTurn === turn) session.activeTurn = null;
1207
- const cleanErr = stderrTail.trim();
1208
- const message = cleanErr
1209
- ? `Antigravity CLI 未返回 result(退出码 ${code ?? 'unknown'}):${cleanErr}`
1210
- : `Antigravity CLI 未返回 result(退出码 ${code ?? 'unknown'})`;
1211
- // 进程未产出 result 即退出(崩溃、认证失败或协议中断):明确失败,
1212
- // 不能把 code 0 的空 stdout 伪装成成功回合。
1213
- emitEvent({ kind: 'error', message });
1214
- turnReject?.(new Error(message));
1215
- }
1216
- });
1217
-
1218
- try {
1219
- if (child.stdin.writable) {
1220
- child.stdin.write(JSON.stringify({ event: 'user', message: { content: promptText } }) + '\n');
1221
- }
1222
- } catch (err) {
1223
- const message = `Antigravity 输入失败:${err.message}`;
1224
- turn.completed = true;
1225
- if (session.activeTurn === turn) session.activeTurn = null;
1226
- void bridge.dispose();
1227
- if (session.bridge === bridge) session.bridge = null;
1228
- void terminateTree(child.pid);
1229
- emitEvent({ kind: 'error', message });
1230
- turnReject?.(new Error(message));
1231
- }
1232
-
1233
- return settled;
1234
- };
1235
-
1236
- const fullPrompt = formatPrompt(effectivePrompt);
1237
- const maxAttempts = emptyResultMaxAttempts();
1238
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1239
- const lastAttempt = attempt === maxAttempts;
1240
- try {
1241
- // eslint-disable-next-line no-await-in-loop
1242
- return await runTurn(attempt > 1 ? ANTIGRAVITY_EMPTY_RESULT_NUDGE : fullPrompt, {
1243
- suppressEmptyResultError: !lastAttempt,
1244
- });
1245
- } catch (err) {
1246
- if (err?.code === 'ANTIGRAVITY_EMPTY_ASSISTANT' && !lastAttempt) {
1247
- emitEvent({
1248
- kind: 'status',
1249
- text: 'Antigravity 回合返回了空结果,正在同一会话中自动补问…',
1250
- nativeRef: err.nativeRef,
1251
- });
1252
- continue;
1253
- }
1254
- throw err;
1255
- }
1256
- }
1257
- return undefined;
1258
- },
1259
-
1260
- async cancel(session) {
1261
- if (session.activeTurn) {
1262
- session.activeTurn.completed = true;
1263
- if (session.activeTurn.child) {
1264
- void terminateTree(session.activeTurn.child.pid);
1265
- }
1266
- session.activeTurn.resolve?.();
1267
- session.activeTurn = null;
1268
- }
1269
- if (session.bridge) {
1270
- await session.bridge.dispose().catch(() => {});
1271
- session.bridge = null;
1272
- }
1273
- },
1274
-
1275
- async respond(session, requestId, response) {
1276
- if (session.bridge) {
1277
- session.bridge.respond(requestId, response);
1278
- }
1279
- },
1280
-
1281
- async listModels() {
1282
- return null;
1283
- },
1284
-
1285
- async listModelsFor(_session) {
1286
- const bin = resolveExecutable();
1287
- return new Promise((resolve) => {
1288
- execFile(bin, ['models'], { windowsHide: true, timeout: 20000 }, (error, stdout) => {
1289
- if (error) return resolve([]);
1290
- resolve(parseModelsOutput(stdout));
1291
- });
1292
- });
1293
- },
1294
-
1295
- async setModel(session, model) {
1296
- session.model = { id: model.id, name: model.name || model.id, provider: model.provider || 'google' };
1297
- return session.model;
1298
- },
1299
-
1300
- async setThinkingLevel(session, level) {
1301
- session.thinkingLevel = level;
1302
- },
1303
-
1304
- async setPermissionMode(session, mode) {
1305
- session.permissionMode = mode;
1306
- },
1307
-
1308
- async getContextUsage(session) {
1309
- return session.usage;
1310
- },
1311
-
1312
- async describe() {
1313
- const bin = resolveExecutable();
1314
- const stdout = await new Promise((resolve) => {
1315
- execFile(bin, ['models'], { windowsHide: true, timeout: 20000 }, (error, out) => {
1316
- if (error) return resolve('');
1317
- resolve(out);
1318
- });
1319
- });
1320
- const models = parseModelsOutput(stdout);
1321
- return {
1322
- models,
1323
- thinkingLevels: [
1324
- // default: true adapter 的显式声明:该 Harness 的思考档位可在 UI 选择,
1325
- // 且默认档与 open() 的回退值('high')一致——协议层据此下发可选集合。
1326
- { id: 'high', label: 'High', default: true },
1327
- { id: 'medium', label: 'Medium' },
1328
- { id: 'low', label: 'Low' },
1329
- ],
1330
- permissionModes: ANTIGRAVITY_PERMISSION_MODES,
1331
- };
1332
- },
1333
-
1334
- async describeFor(session) {
1335
- const { models, thinkingLevels } = await this.describe();
1336
- return {
1337
- models,
1338
- thinkingLevels,
1339
- permissionModes: ANTIGRAVITY_PERMISSION_MODES,
1340
- };
1341
- },
1342
-
1343
- async fork(source, { emit: emitEvent, diagnostic, message }) {
1344
- const derivedId = randomUUID();
1345
- const sourceId = source.nativeSessionId;
1346
- const checkpointId = message?.coreTurn?.nativeTurnRef?.checkpointId;
1347
- const retainedTurnsCount = checkpointId != null && !Number.isNaN(Number(checkpointId)) ? Number(checkpointId) : undefined;
1348
- if (sourceId) {
1349
- await cloneDatabase(sourceId, derivedId, retainedTurnsCount);
1350
- await cloneBrain(sourceId, derivedId);
1351
- }
1352
- const model = source.model || { id: 'gemini-3.8-flash', name: 'Gemini 3.8 Flash', provider: 'google' };
1353
- const session = {
1354
- nativeSessionId: derivedId,
1355
- cwd: source.cwd,
1356
- model,
1357
- thinkingLevel: source.options?.thinking || 'high',
1358
- permissionMode: source.options?.permissionMode || 'default',
1359
- activeTurn: null,
1360
- bridge: null,
1361
- usage: undefined,
1362
- pendingApprovals: new Map(),
1363
- };
1364
- emitEvent({ kind: 'session', nativeSessionId: derivedId, model });
1365
- return { session, nativeSessionId: derivedId };
1366
- },
1367
-
1368
- credits() {
1369
- return cachedQuota;
1370
- },
1371
-
1372
- async refreshCredits() {
1373
- if (!quotaPromise) quotaPromise = fetchAntigravityQuota();
1374
- const request = quotaPromise;
1375
- const quota = await request.finally(() => {
1376
- if (quotaPromise === request) quotaPromise = null;
1377
- });
1378
- if (quota) cachedQuota = quota;
1379
- return cachedQuota;
1380
- },
1381
-
1382
- async inspectAccount() {
1383
- const quota = await this.refreshCredits();
1384
- if (!quota) return null;
1385
- return {
1386
- label: 'Antigravity',
1387
- plan: 'Google Gemini',
1388
- credits: quota,
1389
- };
1390
- },
1391
-
1392
- async close(session) {
1393
- await this.cancel(session);
1394
- },
1395
- };
1396
- }
1397
-
1398
- // https://antigravity.google/docs/skills/#where-skills-live
1399
- // Single assignment: an earlier declaration used to be silently overwritten here,
1400
- // which dropped Antigravity's native skill roots from the manifest.
1401
- manifest.integrations = { mcp: true, skills: { global: ['.gemini/config/skills'], project: ['.agents/skills'] } };
1402
- module.exports = {
1403
- manifest,
1404
- create,
1405
- parseModelsOutput,
1406
- parseUsage,
1407
- formatAntigravityResultError,
1408
- modelSupportsEffort,
1409
- formatPrompt,
1410
- prepareImageAttachments,
1411
- mergePendingStep,
1412
- cloneDatabase,
1413
- fetchAntigravityQuota,
1414
- parseAntigravityUsageCommand,
1415
- ANTIGRAVITY_PERMISSION_MODES,
1416
- ANTIGRAVITY_WORKSPACE_FILE_INSTRUCTION,
1417
- };
1418
-
1
+ const { spawn, execFile } = require('node:child_process');
2
+ const { randomUUID, randomBytes } = require('node:crypto');
3
+ const fs = require('node:fs');
4
+ const http = require('node:http');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const readline = require('node:readline');
8
+ const { recordNative } = require('../harness-adapter/fixture-recorder');
9
+ const { terminateTree, systemProxyEnv } = require('../native/process-utils');
10
+
11
+ const manifest = {
12
+ id: 'antigravity',
13
+ name: 'Antigravity',
14
+ icon: 'antigravity-color.svg',
15
+ aliases: ['agy', 'antigravity'],
16
+ capabilities: {
17
+ collaborationTools: true,
18
+ streaming: true,
19
+ thinking: true,
20
+ tools: true,
21
+ approvals: true,
22
+ questions: true,
23
+ models: true,
24
+ thinkingLevels: true,
25
+ permissionModes: true,
26
+ resume: true,
27
+ fork: true,
28
+ forkFromMessage: true,
29
+ compaction: false,
30
+ usage: true,
31
+ contextUsage: true,
32
+ attachments: true,
33
+ },
34
+ };
35
+
36
+ const ANTIGRAVITY_PERMISSION_MODES = [
37
+ { id: 'default', label: '默认', description: '按 Antigravity 预设规则拦截或执行' },
38
+ { id: 'desktop', label: '桌面确认', description: '在 Harness Mix 桌面端弹出确认工具执行' },
39
+ { id: 'skip', label: '自动放行', description: '自动放行工具执行(--dangerously-skip-permissions)', dangerous: true },
40
+ ];
41
+
42
+ const ANTIGRAVITY_WORKSPACE_FILE_INSTRUCTION =
43
+ '[System Instruction: When creating new files in the workspace, you MUST use the write_to_file tool. When modifying existing files, use the replace_file_content tool. CRITICAL: NEVER include ArtifactMetadata when calling write_to_file for workspace files (ArtifactMetadata is strictly reserved for artifacts in the brain directory, and providing it for workspace files causes a path validation rejection). Do NOT use terminal commands (such as Set-Content, Out-File, echo, or cat) to create or write code files. For clarification, ask_question is connected to the harnessmix Desktop through a Hook. Use single-choice or text questions. The Hook returns the actual user response in its reason while blocking the native auto-skip behavior; do not retry merely because the native tool reports it was blocked.]\n\n';
44
+
45
+ const EFFORT_LABELS = {
46
+ low: 'Low',
47
+ medium: 'Medium',
48
+ high: 'High',
49
+ };
50
+ const EFFORT_SUFFIX_PATTERN = /^(?<base>.+)-(?<effort>low|medium|high)$/u;
51
+ const EFFORT_LABEL_SUFFIX_PATTERN = /\s*\((?:low|medium|high)\)$/iu;
52
+ const ANTIGRAVITY_RESULT_DRAIN_MS = 250;
53
+ const ANTIGRAVITY_RESULT_DRAIN_MAX_MS = 10_000;
54
+ // agy(1.2.x 起)偶发返回 status=SUCCESS 但 response 为空、且全程没有任何
55
+ // agent_response 步骤的回合:工具实际已执行,只是最终 assistant 文本丢失。
56
+ // 这种回合是可恢复的——在同一个原生会话里补发一轮 nudge,让模型把答案补出来,
57
+ // 而不是直接把错误抛给用户。重试次数可用环境变量覆盖(0 = 禁用自动补问)。
58
+ const ANTIGRAVITY_EMPTY_RESULT_RETRIES = 1;
59
+ const ANTIGRAVITY_EMPTY_RESULT_NUDGE =
60
+ '[System Instruction: Your previous turn ended WITHOUT any visible assistant response, even though the turn itself completed. The user saw nothing. Do NOT repeat tool calls that already succeeded. Directly write the final answer, or a summary of what you did, as plain assistant text now.]\n\n';
61
+
62
+ function emptyResultMaxAttempts() {
63
+ const raw = Number(process.env.HARNESSMIX_ANTIGRAVITY_EMPTY_RESULT_RETRIES);
64
+ const retries = Number.isInteger(raw) && raw >= 0 ? raw : ANTIGRAVITY_EMPTY_RESULT_RETRIES;
65
+ return 1 + retries;
66
+ }
67
+
68
+ function positiveDuration(value, fallback) {
69
+ const duration = Number(value);
70
+ return Number.isFinite(duration) && duration > 0 ? duration : fallback;
71
+ }
72
+
73
+ function resolveExecutable() {
74
+ if (process.env.HARNESS_MIX_ANTIGRAVITY_COMMAND) return process.env.HARNESS_MIX_ANTIGRAVITY_COMMAND;
75
+ if (process.env.HARNESSMIX_ANTIGRAVITY_COMMAND) return process.env.HARNESSMIX_ANTIGRAVITY_COMMAND;
76
+ if (process.platform === 'win32') {
77
+ const local = path.join(process.env.LOCALAPPDATA || '', 'agy', 'bin', 'agy.exe');
78
+ if (fs.existsSync(local)) return local;
79
+ }
80
+ return 'agy';
81
+ }
82
+
83
+ // agy 是 Go 程序,只认 HTTP(S)_PROXY 环境变量、读不到 Windows 系统代理(WinINET)。
84
+ // 宿主进程通常没有代理 env,网络受限环境下直连 Google 会黑洞:令牌刷新 10s 超时、
85
+ // OAuth code 交换失败 keyring 里的登录态永远写不回去,agy 每次启动都重新弹登录。
86
+ async function agyChildEnv() {
87
+ return { ...process.env, ...(await systemProxyEnv()) };
88
+ }
89
+
90
+ function parseModelsOutput(output) {
91
+ const grouped = new Map();
92
+ for (const rawLine of String(output || '').split(/\r?\n/)) {
93
+ const line = rawLine.trim();
94
+ if (!line || line.startsWith('Fetching')) continue;
95
+ const tabIdx = line.indexOf('\t');
96
+ if (tabIdx <= 0) continue;
97
+ const id = line.slice(0, tabIdx).trim();
98
+ const label = line.slice(tabIdx + 1).trim();
99
+ if (!id || !label) continue;
100
+ const match = EFFORT_SUFFIX_PATTERN.exec(id);
101
+ const baseId = match?.groups?.base || id;
102
+ const baseLabel = match ? label.replace(EFFORT_LABEL_SUFFIX_PATTERN, '').trim() || label : label;
103
+ const effort = match?.groups?.effort || null;
104
+
105
+ if (!grouped.has(baseId)) {
106
+ grouped.set(baseId, { id: baseId, name: baseLabel, efforts: [] });
107
+ }
108
+ const entry = grouped.get(baseId);
109
+ if (effort && !entry.efforts.some((e) => e.id === effort)) {
110
+ entry.efforts.push({ id: effort, label: EFFORT_LABELS[effort] || effort });
111
+ }
112
+ }
113
+
114
+ const models = [];
115
+ for (const entry of grouped.values()) {
116
+ let provider = 'google';
117
+ if (/claude/i.test(entry.id)) provider = 'anthropic';
118
+ else if (/gpt|openai/i.test(entry.id)) provider = 'openai';
119
+
120
+ const order = ['low', 'medium', 'high'];
121
+ entry.efforts.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
122
+
123
+ models.push({
124
+ id: entry.id,
125
+ name: entry.name,
126
+ provider,
127
+ efforts: entry.efforts,
128
+ defaultEffort: entry.efforts.at(-1)?.id || 'high',
129
+ contextWindow: /claude/i.test(entry.id) ? 200_000 : 1_048_576,
130
+ });
131
+ }
132
+ return models;
133
+ }
134
+
135
+ function resolveContextWindow(modelId) {
136
+ if (modelId && /claude/i.test(modelId)) return 200_000;
137
+ return 1_048_576;
138
+ }
139
+
140
+ function usedPercentFrom(remainingFraction) {
141
+ if (typeof remainingFraction !== 'number' || !Number.isFinite(remainingFraction)) return null;
142
+ const used = (1 - Math.min(1, Math.max(0, remainingFraction))) * 100;
143
+ return Math.round(used * 100) / 100;
144
+ }
145
+
146
+ function parseAntigravityUsageCommand(command, fetchedAt = new Date().toISOString()) {
147
+ if (!command || typeof command !== 'object' || command.name !== 'usage' || !command.data) return null;
148
+ const { groups } = command.data;
149
+ if (!Array.isArray(groups)) return null;
150
+ const buckets = [];
151
+ for (const group of groups) {
152
+ if (!group || !Array.isArray(group.buckets)) continue;
153
+ const groupName = typeof group.name === 'string' ? group.name.trim() : '';
154
+ for (const bucket of group.buckets) {
155
+ if (!bucket || typeof bucket !== 'object') continue;
156
+ const usagePercent = usedPercentFrom(bucket.remaining_fraction);
157
+ if (usagePercent === null) continue;
158
+ const window = typeof bucket.window === 'string' ? bucket.window.trim() : '';
159
+ const label = window === 'weekly' ? 'Weekly window' : window === '5h' ? '5-hour window' : (window || bucket.id);
160
+ const resetsAt = typeof bucket.reset_time === 'string' && bucket.reset_time.trim() ? bucket.reset_time.trim() : undefined;
161
+ buckets.push({
162
+ product: groupName ? `${groupName} · ${label}` : label,
163
+ usagePercent,
164
+ window,
165
+ ...(resetsAt ? { resetsAt } : {}),
166
+ });
167
+ }
168
+ }
169
+ if (!buckets.length) return null;
170
+ const leading = [...buckets].sort((a, b) => {
171
+ if (b.usagePercent !== a.usagePercent) return b.usagePercent - a.usagePercent;
172
+ return Number(b.window === '5h') - Number(a.window === '5h');
173
+ })[0];
174
+ const others = buckets.filter((b) => b !== leading);
175
+ return {
176
+ usedPercent: leading.usagePercent,
177
+ periodType: leading.window === '5h' ? 'five_hour' : 'weekly',
178
+ fetchedAt,
179
+ ...(leading.resetsAt ? { resetsAt: leading.resetsAt } : {}),
180
+ ...(others.length > 0
181
+ ? { productUsage: others.map(({ product, usagePercent, resetsAt }) => ({ product, usagePercent, ...(resetsAt ? { resetsAt } : {}) })) }
182
+ : {}),
183
+ };
184
+ }
185
+
186
+ async function fetchAntigravityQuota(executable = resolveExecutable()) {
187
+ const env = await agyChildEnv();
188
+ return new Promise((resolve) => {
189
+ execFile(executable, ['--print=/usage', '--output-format', 'stream-json'], { env, windowsHide: true, timeout: 15000 }, (err, stdout) => {
190
+ if (err || !stdout) return resolve(null);
191
+ for (const line of stdout.split(/\r?\n/)) {
192
+ if (!line.trim()) continue;
193
+ try {
194
+ const event = JSON.parse(line);
195
+ if (event?.event === 'command_result') {
196
+ const snapshot = parseAntigravityUsageCommand(event.command);
197
+ if (snapshot) return resolve(snapshot);
198
+ }
199
+ } catch {}
200
+ }
201
+ resolve(null);
202
+ });
203
+ });
204
+ }
205
+
206
+ function parseUsage(usageObj, modelId, quota) {
207
+ if (!usageObj) return null;
208
+ const input = usageObj.input_tokens ?? usageObj.inputTokens;
209
+ const output = usageObj.output_tokens ?? usageObj.outputTokens;
210
+ const thinking = usageObj.thinking_tokens ?? usageObj.reasoningOutputTokens;
211
+ const cached = usageObj.cache_read_tokens ?? usageObj.cachedInputTokens;
212
+ const total = usageObj.total_tokens ?? usageObj.totalTokens;
213
+ const contextWindow = resolveContextWindow(modelId);
214
+ const contextUsed = usageObj.context_used_tokens ?? usageObj.estimated_tokens_used ?? input;
215
+ const tokens = typeof contextUsed === 'number' ? contextUsed : (typeof total === 'number' ? total : null);
216
+ const result = {
217
+ tokens,
218
+ contextWindow,
219
+ contextPercent: tokens != null && contextWindow ? Math.min(100, Math.round((100 * tokens / contextWindow) * 10) / 10) : null,
220
+ inputTokens: typeof input === 'number' ? input : null,
221
+ outputTokens: typeof output === 'number' ? output : null,
222
+ reasoningOutputTokens: typeof thinking === 'number' ? thinking : null,
223
+ cachedInputTokens: typeof cached === 'number' ? cached : null,
224
+ totalTokens: typeof total === 'number' ? total : (typeof input === 'number' && typeof output === 'number' ? input + output : null),
225
+ };
226
+ if (quota) {
227
+ if (quota.periodType === 'five_hour') {
228
+ result.planFiveHourUsedPercent = quota.usedPercent;
229
+ if (quota.resetsAt) {
230
+ const unix = Math.floor(Date.parse(quota.resetsAt) / 1000);
231
+ if (Number.isFinite(unix) && unix > 0) result.planFiveHourResetsAtUnix = unix;
232
+ }
233
+ }
234
+ const weekly = quota.productUsage?.find(p => /weekly/i.test(p.product)) || (quota.periodType === 'weekly' ? quota : null);
235
+ if (weekly) {
236
+ result.planSevenDayUsedPercent = weekly.usagePercent;
237
+ if (weekly.resetsAt) {
238
+ const unix = Math.floor(Date.parse(weekly.resetsAt) / 1000);
239
+ if (Number.isFinite(unix) && unix > 0) result.planSevenDayResetsAtUnix = unix;
240
+ }
241
+ }
242
+ }
243
+ return result;
244
+ }
245
+
246
+ function errorText(value) {
247
+ if (value == null) return '';
248
+ if (value instanceof Error) return value.message.trim();
249
+ if (typeof value === 'string') return value.trim();
250
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
251
+ if (Array.isArray(value)) return value.map(errorText).filter(Boolean).join('; ');
252
+ if (typeof value === 'object') {
253
+ const code = value.code ?? value.errorCode ?? value.status;
254
+ const nested = value.message ?? value.errorMessage ?? value.error_message ?? value.detail ?? value.description ?? value.reason ?? value.error;
255
+ const message = errorText(nested);
256
+ if (message) {
257
+ const codeText = code == null ? '' : String(code).trim();
258
+ return codeText && !message.startsWith(`${codeText}:`) ? `${codeText}: ${message}` : message;
259
+ }
260
+ try { return JSON.stringify(value); } catch { return ''; }
261
+ }
262
+ return String(value);
263
+ }
264
+
265
+ function formatAntigravityResultError(result) {
266
+ const detail = [result?.error, result?.errorMessage, result?.error_message, result?.message, result?.detail]
267
+ .map(errorText)
268
+ .find(Boolean);
269
+ if (detail) return `Antigravity 回合失败:${detail}`;
270
+ const status = errorText(result?.status);
271
+ return status ? `Antigravity 回合失败(${status})` : 'Antigravity 回合失败(未知错误)';
272
+ }
273
+
274
+ function resultSucceeded(result) {
275
+ return errorText(result?.status).toUpperCase() === 'SUCCESS';
276
+ }
277
+
278
+ function modelSupportsEffort(model) {
279
+ const id = String(model?.id ?? '').trim();
280
+ if (Array.isArray(model?.efforts)) return model.efforts.length > 0;
281
+ // Claude models expose built-in thinking in agy and reject --effort.
282
+ return !/^claude(?:[-_.]|$)/i.test(id);
283
+ }
284
+
285
+ function formatPrompt(text) {
286
+ if (!text) return '';
287
+ if (text.startsWith('/') || text.includes('ArtifactMetadata')) return text;
288
+ return `${ANTIGRAVITY_WORKSPACE_FILE_INSTRUCTION}${text}`;
289
+ }
290
+
291
+ async function prepareImageAttachments(images, cwd) {
292
+ if (!Array.isArray(images) || !images.length) return { imageEntries: [], extraDirs: [] };
293
+ const imageEntries = [];
294
+ const extraDirs = [];
295
+ let targetDir = null;
296
+ const ensureTargetDir = async () => {
297
+ if (targetDir) return targetDir;
298
+ targetDir = path.join(cwd, '.gemini', 'attachments');
299
+ try {
300
+ await fs.promises.mkdir(targetDir, { recursive: true });
301
+ } catch {
302
+ targetDir = path.join(os.tmpdir(), 'harness-mix-antigravity-attachments');
303
+ await fs.promises.mkdir(targetDir, { recursive: true });
304
+ if (!extraDirs.includes(targetDir)) extraDirs.push(targetDir);
305
+ }
306
+ return targetDir;
307
+ };
308
+
309
+ for (const img of images) {
310
+ if (!img) continue;
311
+ const ext = path.extname(img.name || '') || (img.mime === 'image/jpeg' ? '.jpg' : img.mime === 'image/gif' ? '.gif' : img.mime === 'image/webp' ? '.webp' : img.mime === 'image/svg+xml' ? '.svg' : '.png');
312
+ const safeBase = path.basename(img.name || 'image', ext).replace(/[^a-zA-Z0-9_\-\u4e00-\u9fa5]/g, '_') || 'image';
313
+ const fileName = `${Date.now()}-${randomUUID().slice(0, 6)}-${safeBase}${ext}`;
314
+ const sourcePath = typeof img.path === 'string' && img.path ? path.resolve(cwd, img.path) : null;
315
+ const hasData = typeof img.data === 'string' && img.data.length > 0;
316
+ if (!sourcePath && !hasData) throw new Error(`图片附件「${img.name || 'image'}」缺少本地文件或内容`);
317
+
318
+ // Desktop clipboard images live in %TEMP% and may disappear after this
319
+ // turn. Always materialize a durable copy before putting a path in the
320
+ // native prompt, even when the source still exists right now.
321
+ const durableDir = await ensureTargetDir();
322
+ const filePath = path.join(durableDir, fileName);
323
+ if (hasData) await fs.promises.writeFile(filePath, Buffer.from(img.data, 'base64'));
324
+ else await fs.promises.copyFile(sourcePath, filePath);
325
+
326
+ if (!fs.existsSync(filePath)) {
327
+ throw new Error(`图片附件「${img.name || 'image'}」保存失败`);
328
+ }
329
+ const normalizedPath = path.resolve(filePath).replace(/\\/g, '/');
330
+ imageEntries.push({ name: img.name || path.basename(filePath), path: normalizedPath });
331
+ }
332
+ return { imageEntries, extraDirs };
333
+ }
334
+
335
+ async function restoreLegacyClipboardAttachments(thread) {
336
+ const tempRoot = path.resolve(os.tmpdir());
337
+ const restored = new Set();
338
+ const messages = Array.isArray(thread?.messages) ? thread.messages : [];
339
+ for (const message of messages) {
340
+ const attachments = Array.isArray(message?.attachments) ? message.attachments : [];
341
+ for (const attachment of attachments) {
342
+ if (attachment?.kind !== 'image' || typeof attachment.path !== 'string' || !attachment.path || typeof attachment.data !== 'string' || !attachment.data) continue;
343
+ const filePath = path.resolve(thread.cwd || process.cwd(), attachment.path);
344
+ const relative = path.relative(tempRoot, filePath);
345
+ if (!relative || path.isAbsolute(relative) || relative.includes(path.sep) || !/^codex-clipboard-[a-z0-9-]+\.(?:png|jpe?g|gif|webp)$/i.test(path.basename(filePath))) continue;
346
+ if (fs.existsSync(filePath) || restored.has(filePath)) continue;
347
+ try {
348
+ await fs.promises.writeFile(filePath, Buffer.from(attachment.data, 'base64'));
349
+ restored.add(filePath);
350
+ } catch { /* A missing legacy temp file must not prevent session recovery. */ }
351
+ }
352
+ }
353
+ return restored.size;
354
+ }
355
+
356
+ function toolTitle(name) {
357
+ if (!name) return 'Antigravity 工具';
358
+ if (name === 'run_command') return 'exec_command';
359
+ if (name === 'write_to_file' || name === 'replace_file_content') return 'edit';
360
+ if (name === 'view_file') return 'view_file';
361
+ if (name === 'list_dir') return 'list_dir';
362
+ if (name === 'grep_search') return 'grep_search';
363
+ if (name === 'find_by_name') return 'find_by_name';
364
+ if (name === 'read_url_content') return 'web_fetch';
365
+ if (name === 'search_web') return 'search_web';
366
+ if (name === 'ask_question') return '提问';
367
+ if (name === 'generate_image') return '生成图片';
368
+ if (name === 'schedule') return '调度任务';
369
+ if (name === 'manage_task') return '后台任务';
370
+ if (name === 'invoke_subagent') return '子 Agent';
371
+ return name;
372
+ }
373
+
374
+ function mergePendingStep(pending, step) {
375
+ if (!pending) return step;
376
+ const toolName = step.tool_name ?? pending.tool_name;
377
+ const name = step.tool_info?.name ?? pending.tool_info?.name;
378
+ const parameters = step.tool_info?.parameters ?? pending.tool_info?.parameters;
379
+ const output = step.tool_info?.output ?? pending.tool_info?.output;
380
+ const error = step.tool_info?.error ?? pending.tool_info?.error;
381
+ return {
382
+ ...pending,
383
+ ...step,
384
+ ...(toolName !== undefined ? { tool_name: toolName } : {}),
385
+ ...(step.tool_info !== undefined || pending.tool_info !== undefined
386
+ ? {
387
+ tool_info: {
388
+ ...(name !== undefined ? { name } : {}),
389
+ ...(parameters !== undefined ? { parameters } : {}),
390
+ ...(output !== undefined ? { output } : {}),
391
+ ...(error !== undefined ? { error } : {}),
392
+ },
393
+ }
394
+ : {}),
395
+ };
396
+ }
397
+
398
+ function toolInput(step) {
399
+ const params = step.tool_info?.parameters;
400
+ if (!params) return undefined;
401
+ if (typeof params === 'string') return params;
402
+ if (params.CommandLine) return params.CommandLine;
403
+ if (params.TargetFile) return `${params.TargetFile}\n${params.Description || ''}`.trim();
404
+ if (params.AbsolutePath) return params.AbsolutePath;
405
+ if (params.DirectoryPath) return params.DirectoryPath;
406
+ if (params.Url) return params.Url;
407
+ if (params.query) return params.query;
408
+ if (Array.isArray(params.Subagents)) {
409
+ return params.Subagents.map((s) => `[${s.Role || s.TypeName || 'Agent'}] ${s.Prompt || ''}`.trim()).join('\n');
410
+ }
411
+ return JSON.stringify(params, null, 2);
412
+ }
413
+
414
+ function toolOutput(step) {
415
+ const info = step.tool_info;
416
+ if (!info) return undefined;
417
+ if (info.error) return typeof info.error === 'string' ? info.error : JSON.stringify(info.error, null, 2);
418
+ if (info.output != null) return typeof info.output === 'string' ? info.output : JSON.stringify(info.output, null, 2);
419
+ return undefined;
420
+ }
421
+
422
+ function nativeConversationDbPath(sessionId, homedir = os.homedir()) {
423
+ return path.join(homedir, '.gemini', 'antigravity-cli', 'conversations', `${sessionId}.db`);
424
+ }
425
+
426
+ function nativeBrainDirPath(sessionId, homedir = os.homedir()) {
427
+ return path.join(homedir, '.gemini', 'antigravity-cli', 'brain', sessionId);
428
+ }
429
+
430
+ async function cloneDatabase(sourceId, derivedId, retainedTurnsCountOrHomedir, homedirOption = os.homedir()) {
431
+ let retainedTurnsCount;
432
+ let homedir = homedirOption;
433
+ if (typeof retainedTurnsCountOrHomedir === 'string') {
434
+ homedir = retainedTurnsCountOrHomedir;
435
+ } else if (typeof retainedTurnsCountOrHomedir === 'number') {
436
+ retainedTurnsCount = retainedTurnsCountOrHomedir;
437
+ }
438
+
439
+ const sourceDb = nativeConversationDbPath(sourceId, homedir);
440
+ const targetDb = nativeConversationDbPath(derivedId, homedir);
441
+ try {
442
+ await fs.promises.mkdir(path.dirname(targetDb), { recursive: true });
443
+ await fs.promises.copyFile(sourceDb, targetDb);
444
+ } catch {
445
+ return false;
446
+ }
447
+
448
+ let remainingStepCount = 0;
449
+ try {
450
+ const { DatabaseSync } = require('node:sqlite');
451
+ const db = new DatabaseSync(targetDb);
452
+ try {
453
+ db.prepare('UPDATE trajectory_meta SET cascade_id = ?').run(derivedId);
454
+ if (typeof retainedTurnsCount === 'number' && Number.isInteger(retainedTurnsCount)) {
455
+ try {
456
+ if (retainedTurnsCount <= 0) {
457
+ db.prepare('DELETE FROM steps').run();
458
+ } else {
459
+ const rows = db.prepare('SELECT idx FROM steps WHERE step_type = 14 ORDER BY idx ASC').all();
460
+ const cutoff = rows[retainedTurnsCount];
461
+ if (cutoff && typeof cutoff.idx === 'number') {
462
+ db.prepare('DELETE FROM steps WHERE idx >= ?').run(cutoff.idx);
463
+ }
464
+ }
465
+ } catch {}
466
+ try { db.prepare('DELETE FROM gen_metadata WHERE idx >= ?').run(retainedTurnsCount); } catch {}
467
+ try { db.prepare('DELETE FROM executor_metadata WHERE idx >= ?').run(retainedTurnsCount); } catch {}
468
+ try { db.prepare('DELETE FROM parent_references WHERE idx >= ?').run(retainedTurnsCount); } catch {}
469
+ try { db.prepare('DELETE FROM battle_mode_infos WHERE idx >= ?').run(retainedTurnsCount); } catch {}
470
+ }
471
+ try {
472
+ const countRow = db.prepare('SELECT count(*) as c FROM steps').get();
473
+ if (countRow && typeof countRow.c === 'number') remainingStepCount = countRow.c;
474
+ } catch {}
475
+ } finally {
476
+ db.close();
477
+ }
478
+
479
+ // Register in conversation_summaries.db so `agy` trajectory lookup succeeds
480
+ try {
481
+ const summariesDbPath = path.join(homedir, '.gemini', 'antigravity-cli', 'conversation_summaries.db');
482
+ if (fs.existsSync(summariesDbPath)) {
483
+ const sumDb = new DatabaseSync(summariesDbPath);
484
+ try {
485
+ const row = sumDb.prepare('SELECT * FROM conversation_summaries WHERE conversation_id = ?').get(sourceId);
486
+ if (row) {
487
+ const cols = Object.keys(row);
488
+ const newRow = {
489
+ ...row,
490
+ conversation_id: derivedId,
491
+ last_modified_time: new Date().toISOString(),
492
+ ...(typeof retainedTurnsCount === 'number' ? { step_count: remainingStepCount } : {}),
493
+ };
494
+ const placeholders = cols.map(() => '?').join(', ');
495
+ const values = cols.map((col) => newRow[col]);
496
+ sumDb
497
+ .prepare(
498
+ `INSERT OR REPLACE INTO conversation_summaries (${cols.map((c) => `\`${c}\``).join(', ')}) VALUES (${placeholders})`
499
+ )
500
+ .run(...values);
501
+ }
502
+ } finally {
503
+ sumDb.close();
504
+ }
505
+ }
506
+ } catch {}
507
+ } catch {}
508
+ return true;
509
+ }
510
+
511
+ async function cloneBrain(sourceId, derivedId, homedir = os.homedir()) {
512
+ const sourceBrain = nativeBrainDirPath(sourceId, homedir);
513
+ const targetBrain = nativeBrainDirPath(derivedId, homedir);
514
+ try {
515
+ await fs.promises.cp(sourceBrain, targetBrain, {
516
+ recursive: true,
517
+ filter: (src) => path.basename(src) !== '.system_generated',
518
+ });
519
+ return true;
520
+ } catch {
521
+ return false;
522
+ }
523
+ }
524
+
525
+ const ANTIGRAVITY_QUESTION_HOOK_CLIENT = `
526
+ const http = require("node:http");
527
+ const limit = 131072;
528
+ let input = "";
529
+ let finished = false;
530
+ let request;
531
+ function finish(value) {
532
+ if (finished) return;
533
+ finished = true;
534
+ process.stdout.write(JSON.stringify(value));
535
+ }
536
+ function unavailable() {
537
+ finish({
538
+ decision: "deny",
539
+ reason: "harnessmix question bridge is unavailable. No user answer was received. Do not report that the user skipped or selected an option."
540
+ });
541
+ if (request) request.destroy();
542
+ }
543
+ process.stdin.setEncoding("utf8");
544
+ process.stdin.on("error", unavailable);
545
+ process.stdin.on("data", chunk => {
546
+ input += chunk;
547
+ if (Buffer.byteLength(input) > limit) {
548
+ input = "";
549
+ process.stdin.destroy();
550
+ unavailable();
551
+ }
552
+ });
553
+ process.stdin.on("end", () => {
554
+ if (finished) return;
555
+ try {
556
+ const target = new URL(process.env.HARNESSMIX_AGY_QUESTION_URL);
557
+ if (target.protocol !== "http:" || target.hostname !== "127.0.0.1" ||
558
+ target.pathname !== "/question" || target.username || target.password) {
559
+ return unavailable();
560
+ }
561
+ const payload = JSON.parse(input);
562
+ const approval = process.env.HARNESSMIX_AGY_QUESTION_APPROVALS === "1" &&
563
+ payload.toolCall && payload.toolCall.name !== "ask_question";
564
+ request = http.request(target, {
565
+ method: "POST",
566
+ headers: {
567
+ "content-type": "application/json",
568
+ "authorization": "Bearer " + process.env.HARNESSMIX_AGY_QUESTION_TOKEN,
569
+ "content-length": Buffer.byteLength(input)
570
+ }
571
+ }, response => {
572
+ let body = "";
573
+ response.setEncoding("utf8");
574
+ response.on("error", unavailable);
575
+ response.on("aborted", unavailable);
576
+ response.on("data", chunk => {
577
+ body += chunk;
578
+ if (Buffer.byteLength(body) > limit) unavailable();
579
+ });
580
+ response.on("end", () => {
581
+ try {
582
+ const value = JSON.parse(body);
583
+ if (response.statusCode !== 200 ||
584
+ (value.decision !== "deny" && !(approval && value.decision === "allow")) ||
585
+ typeof value.reason !== "string") return unavailable();
586
+ finish(value);
587
+ } catch { unavailable(); }
588
+ });
589
+ });
590
+ request.setTimeout(Number(process.env.HARNESSMIX_AGY_QUESTION_TIMEOUT_MS) + 5000, unavailable);
591
+ request.on("error", unavailable);
592
+ request.end(input);
593
+ } catch { unavailable(); }
594
+ });
595
+ `;
596
+
597
+ class QuestionBridge {
598
+ constructor({ approvals, emit, timeoutMs = 600000 }) {
599
+ this.approvals = approvals;
600
+ this.emit = emit;
601
+ this.timeoutMs = timeoutMs;
602
+ this.token = randomBytes(32).toString('hex');
603
+ this.pending = new Map();
604
+ this.directory = '';
605
+ this.server = null;
606
+ this.environment = {};
607
+ }
608
+
609
+ static async create(options) {
610
+ const bridge = new QuestionBridge(options);
611
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'harnessmix-agy-question-'));
612
+ bridge.directory = dir;
613
+
614
+ bridge.server = http.createServer((req, res) => bridge.#receive(req, res));
615
+ await new Promise((resolve, reject) => {
616
+ bridge.server.once('error', reject);
617
+ bridge.server.listen(0, '127.0.0.1', () => {
618
+ bridge.server.removeListener('error', reject);
619
+ resolve();
620
+ });
621
+ });
622
+
623
+ const address = bridge.server.address();
624
+ const port = address.port;
625
+ const clientPath = path.join(dir, 'question-hook.cjs');
626
+ await fs.promises.mkdir(path.join(dir, '.agents'), { recursive: true });
627
+ await fs.promises.writeFile(clientPath, ANTIGRAVITY_QUESTION_HOOK_CLIENT, 'utf8');
628
+
629
+ const hookCmd = process.platform === 'win32'
630
+ ? '%HARNESSMIX_AGY_QUESTION_NODE% %HARNESSMIX_AGY_QUESTION_CLIENT%'
631
+ : `"${process.execPath}" "${clientPath}"`;
632
+
633
+ await fs.promises.writeFile(
634
+ path.join(dir, '.agents', 'hooks.json'),
635
+ JSON.stringify({
636
+ 'harnessmix-question-bridge': {
637
+ PreToolUse: [
638
+ {
639
+ matcher: options.approvals ? '.*' : '^ask_question$',
640
+ hooks: [
641
+ {
642
+ type: 'command',
643
+ command: hookCmd,
644
+ timeout: Math.ceil(options.timeoutMs / 1000) + 15,
645
+ },
646
+ ],
647
+ },
648
+ ],
649
+ },
650
+ }, null, 2),
651
+ 'utf8',
652
+ );
653
+
654
+ bridge.environment = {
655
+ HARNESSMIX_AGY_QUESTION_TOKEN: bridge.token,
656
+ HARNESSMIX_AGY_QUESTION_URL: `http://127.0.0.1:${port}/question`,
657
+ HARNESSMIX_AGY_QUESTION_TIMEOUT_MS: String(options.timeoutMs),
658
+ HARNESSMIX_AGY_QUESTION_APPROVALS: options.approvals ? '1' : '0',
659
+ HARNESSMIX_AGY_QUESTION_NODE: `"${process.execPath.replaceAll('\\', '/')}"`,
660
+ HARNESSMIX_AGY_QUESTION_CLIENT: `"${clientPath.replaceAll('\\', '/')}"`,
661
+ };
662
+
663
+ if (options.collaboration || options.managedMcp?.length) {
664
+ const pluginDir = path.join(dir, '.agents', 'plugins', 'harness-mix');
665
+ const plainPluginDir = path.join(dir, 'plugins', 'harness-mix');
666
+ await fs.promises.mkdir(pluginDir, { recursive: true });
667
+ await fs.promises.mkdir(plainPluginDir, { recursive: true });
668
+ const pluginManifest = JSON.stringify({ name: 'harness-mix-collaboration', description: 'Harness Mix Multi-agent Collaboration' }, null, 2);
669
+ const mcpConfig = JSON.stringify({
670
+ mcpServers: require('./managed-mcp').namedServers(options.managedMcp, options.collaboration),
671
+ }, null, 2);
672
+ await fs.promises.writeFile(path.join(pluginDir, 'plugin.json'), pluginManifest, 'utf8');
673
+ await fs.promises.writeFile(path.join(pluginDir, 'mcp_config.json'), mcpConfig, 'utf8');
674
+ await fs.promises.writeFile(path.join(plainPluginDir, 'plugin.json'), pluginManifest, 'utf8');
675
+ await fs.promises.writeFile(path.join(plainPluginDir, 'mcp_config.json'), mcpConfig, 'utf8');
676
+ await fs.promises.writeFile(path.join(dir, '.agents', 'mcp_config.json'), mcpConfig, 'utf8');
677
+ Object.assign(bridge.environment, options.collaboration?.env || {});
678
+ }
679
+
680
+ return bridge;
681
+ }
682
+
683
+ #receive(req, res) {
684
+ const auth = req.headers.authorization || '';
685
+ if (auth !== `Bearer ${this.token}` || req.method !== 'POST' || req.url !== '/question') {
686
+ res.writeHead(403, { 'content-type': 'application/json' });
687
+ res.end(JSON.stringify({ decision: 'deny', reason: 'Forbidden' }));
688
+ return;
689
+ }
690
+ let body = '';
691
+ req.setEncoding('utf8');
692
+ req.on('data', (chunk) => { body += chunk; });
693
+ req.on('end', () => {
694
+ let payload;
695
+ try { payload = JSON.parse(body); }
696
+ catch {
697
+ res.writeHead(400, { 'content-type': 'application/json' });
698
+ res.end(JSON.stringify({ decision: 'deny', reason: 'Invalid JSON' }));
699
+ return;
700
+ }
701
+ this.#handlePayload(payload, res);
702
+ });
703
+ }
704
+
705
+ #handlePayload(payload, res) {
706
+ const toolCall = payload.toolCall || {};
707
+ const toolName = toolCall.name;
708
+ const conversationId = payload.conversationId;
709
+
710
+ if (toolName === 'ask_question') {
711
+ const questions = toolCall.args?.questions || [];
712
+ const q = questions[0] || { question: 'Antigravity 提问' };
713
+ const requestId = `antigravity-q-${randomUUID()}`;
714
+ this.pending.set(requestId, { type: 'question', questions, response: res });
715
+ this.emit({
716
+ kind: 'approval',
717
+ requestId,
718
+ method: q.options?.length ? undefined : 'input',
719
+ title: 'Antigravity 提问',
720
+ message: q.question,
721
+ options: (q.options || []).map((opt) => ({ id: opt, label: opt })),
722
+ placeholder: '请输入或选择…',
723
+ nativeRef: { sessionId: conversationId },
724
+ });
725
+ return;
726
+ }
727
+
728
+ if (this.approvals && toolName) {
729
+ const requestId = `antigravity-app-${randomUUID()}`;
730
+ this.pending.set(requestId, { type: 'approval', toolCall, response: res });
731
+ this.emit({
732
+ kind: 'approval',
733
+ requestId,
734
+ title: `Antigravity: ${toolName}`,
735
+ message: `请求执行工具 ${toolName}\n${JSON.stringify(toolCall.args || {}, null, 2)}`,
736
+ options: [
737
+ { id: 'allow', label: '允许' },
738
+ { id: 'deny', label: '拒绝', kind: 'reject' },
739
+ ],
740
+ nativeRef: { sessionId: conversationId },
741
+ });
742
+ return;
743
+ }
744
+
745
+ res.writeHead(200, { 'content-type': 'application/json' });
746
+ res.end(JSON.stringify({ decision: 'allow' }));
747
+ }
748
+
749
+ respond(requestId, response) {
750
+ const pending = this.pending.get(requestId);
751
+ if (!pending) return false;
752
+ this.pending.delete(requestId);
753
+
754
+ if (pending.type === 'question') {
755
+ const answer = response?.cancelled ? '' : String(response?.optionId ?? response?.value ?? '');
756
+ const result = response?.cancelled
757
+ ? { status: 'cancelled', answers: {} }
758
+ : {
759
+ status: 'answered',
760
+ answers: (pending.questions || []).map((q) => ({
761
+ question: q.question,
762
+ answers: answer ? [answer] : [],
763
+ })),
764
+ };
765
+ const reason =
766
+ 'harnessmix handled this question through the Desktop. Native ask_question is blocked only to prevent automatic skipping. ' +
767
+ 'The following JSON contains the actual user response, not a tool permission decision: ' +
768
+ JSON.stringify(result);
769
+ if (!pending.response.writableEnded) {
770
+ pending.response.writeHead(200, { 'content-type': 'application/json' });
771
+ pending.response.end(JSON.stringify({ decision: 'deny', reason }));
772
+ }
773
+ return true;
774
+ }
775
+
776
+ if (pending.type === 'approval') {
777
+ const allowed = !response?.cancelled && (response?.optionId === 'allow' || response?.confirmed !== false);
778
+ const decision = allowed ? 'allow' : 'deny';
779
+ const reason = allowed ? 'User approved this tool call once.' : 'User denied this tool call.';
780
+ if (!pending.response.writableEnded) {
781
+ pending.response.writeHead(200, { 'content-type': 'application/json' });
782
+ pending.response.end(JSON.stringify({ decision, reason }));
783
+ }
784
+ return true;
785
+ }
786
+ return false;
787
+ }
788
+
789
+ async dispose() {
790
+ for (const pending of this.pending.values()) {
791
+ if (!pending.response.writableEnded) {
792
+ pending.response.writeHead(200, { 'content-type': 'application/json' });
793
+ pending.response.end(JSON.stringify({ decision: 'deny', reason: 'Session closed' }));
794
+ }
795
+ }
796
+ this.pending.clear();
797
+ if (this.server) {
798
+ await new Promise((resolve) => this.server.close(resolve)).catch(() => {});
799
+ this.server = null;
800
+ }
801
+ if (this.directory) {
802
+ await fs.promises.rm(this.directory, { recursive: true, force: true }).catch(() => {});
803
+ this.directory = '';
804
+ }
805
+ }
806
+ }
807
+
808
+ function create(emit, options = {}) {
809
+ let cachedQuota = null;
810
+ let quotaPromise = null;
811
+ const spawnProcess = options.spawnProcess || spawn;
812
+ const resultDrainMs = positiveDuration(
813
+ options.resultDrainMs ?? process.env.HARNESS_MIX_ANTIGRAVITY_RESULT_DRAIN_MS,
814
+ ANTIGRAVITY_RESULT_DRAIN_MS,
815
+ );
816
+ const resultDrainMaxMs = Math.max(
817
+ resultDrainMs,
818
+ positiveDuration(
819
+ options.resultDrainMaxMs ?? process.env.HARNESS_MIX_ANTIGRAVITY_RESULT_DRAIN_MAX_MS,
820
+ ANTIGRAVITY_RESULT_DRAIN_MAX_MS,
821
+ ),
822
+ );
823
+
824
+ return {
825
+ manifest,
826
+
827
+ async inspect() {
828
+ const bin = resolveExecutable();
829
+ return new Promise((resolve) => {
830
+ execFile(bin, ['--version'], { windowsHide: true, timeout: 10000 }, (error, stdout) => {
831
+ if (error) {
832
+ resolve({ available: false, detail: '未找到 Antigravity CLI(agy)' });
833
+ } else {
834
+ if (!cachedQuota) fetchAntigravityQuota().then((q) => { if (q) cachedQuota = q; }).catch(() => {});
835
+ resolve({ available: true, detail: `agy ${String(stdout).trim()}` });
836
+ }
837
+ });
838
+ });
839
+ },
840
+
841
+ async open({ thread, emit: emitEvent, diagnostic, collaboration, managedMcp = [] }) {
842
+ // Do not start a second agy process while a real turn may be starting.
843
+ // Quota is loaded only by the explicit account/usage refresh path below.
844
+ await restoreLegacyClipboardAttachments(thread);
845
+ const model = thread.options?.model ?? { id: 'gemini-3.8-flash', name: 'Gemini 3.8 Flash', provider: 'google' };
846
+ const thinkingLevel = thread.options?.thinking ?? 'high';
847
+ const permissionMode = thread.options?.permissionMode ?? 'default';
848
+ const session = {
849
+ nativeSessionId: thread.nativeSessionId || null,
850
+ cwd: thread.cwd,
851
+ model,
852
+ thinkingLevel,
853
+ permissionMode,
854
+ collaboration: collaboration || null,
855
+ managedMcp,
856
+ collaborationEnabled: !!collaboration,
857
+ activeTurn: null,
858
+ bridge: null,
859
+ usage: undefined,
860
+ pendingApprovals: new Map(),
861
+ };
862
+ if (thread.restore && thread.nativeSessionId) {
863
+ emitEvent({ kind: 'session', nativeSessionId: thread.nativeSessionId, model });
864
+ }
865
+ return session;
866
+ },
867
+
868
+ async send(session, prompt, hooks, attachments) {
869
+ if (session.activeTurn) throw new Error('Antigravity 当前回合尚未结束');
870
+
871
+ const emitEvent = hooks?.emit || emit;
872
+
873
+ let effectivePrompt = prompt ?? '';
874
+ const { imageEntries, extraDirs } = await prepareImageAttachments(attachments?.images, session.cwd);
875
+ if (imageEntries.length) {
876
+ const imageNotice = [
877
+ '[用户上传了图片附件]',
878
+ '[仅使用本轮列出的持久化副本路径;不要复用历史消息中的 codex-clipboard 临时路径。]',
879
+ ...imageEntries.map(e => `- [${e.name}](file:///${e.path}): 请使用 view_file 工具查看并分析该图片。`),
880
+ ].join('\n');
881
+ effectivePrompt = effectivePrompt.trim()
882
+ ? `${imageNotice}\n\n${effectivePrompt}`
883
+ : `${imageNotice}\n\n请查看并分析上述图片附件。`;
884
+ }
885
+
886
+ // 单轮执行:spawn agy、转发 stream-json 事件、等待 result 结算。
887
+ // suppressEmptyResultError 用于自动补问场景——空 SUCCESS 的第一轮不向 UI
888
+ // 报错,仅在最后一次尝试仍然为空时才发出 error 事件。
889
+ const runTurn = async (promptText, { suppressEmptyResultError = false } = {}) => {
890
+ const bin = resolveExecutable();
891
+ const approvals = session.permissionMode === 'desktop' || session.permissionMode === 'desktop-approvals';
892
+ const bridge = await QuestionBridge.create({
893
+ approvals,
894
+ emit: emitEvent,
895
+ collaboration: session.collaboration,
896
+ managedMcp: session.managedMcp,
897
+ });
898
+ session.bridge = bridge;
899
+
900
+ const logPath = path.join(os.tmpdir(), `harness-mix-antigravity-${randomUUID()}.log`);
901
+ const args = [
902
+ '--input-format', 'stream-json',
903
+ '--output-format', 'stream-json',
904
+ '--print-timeout', '30m',
905
+ ];
906
+ if (session.nativeSessionId) {
907
+ args.push('--conversation', session.nativeSessionId);
908
+ }
909
+ if (session.model?.id) {
910
+ args.push('--model', session.model.id);
911
+ }
912
+ if (session.thinkingLevel && modelSupportsEffort(session.model)) {
913
+ args.push('--effort', session.thinkingLevel);
914
+ }
915
+ if (['skip', 'desktop', 'dangerously-skip-permissions', 'desktop-approvals'].includes(session.permissionMode)) {
916
+ args.push('--dangerously-skip-permissions');
917
+ }
918
+ args.push('--add-dir', session.cwd);
919
+ args.push('--add-dir', bridge.directory);
920
+ for (const dir of extraDirs) {
921
+ args.push('--add-dir', dir);
922
+ }
923
+ args.push('--log-file', logPath);
924
+
925
+ const child = spawnProcess(bin, args, {
926
+ cwd: session.cwd,
927
+ env: { ...(await agyChildEnv()), ...bridge.environment },
928
+ windowsHide: true,
929
+ stdio: ['pipe', 'pipe', 'pipe'],
930
+ });
931
+ // 进程崩溃/提前退出时,迟到的 stdin.write/end 在流上异步抛 EPIPE;
932
+ // 无 error 监听的 Writable 会被 Node 当作未捕获异常 crash 宿主进程
933
+ child.stdin.on('error', () => {});
934
+
935
+ let turnResolve, turnReject;
936
+ const settled = new Promise((resolve, reject) => {
937
+ turnResolve = resolve;
938
+ turnReject = reject;
939
+ });
940
+
941
+ const turn = {
942
+ child,
943
+ resolve: turnResolve,
944
+ reject: turnReject,
945
+ logPath,
946
+ resultSeen: false,
947
+ result: null,
948
+ completed: false,
949
+ processClosing: false,
950
+ processClosed: false,
951
+ killTimer: null,
952
+ resultDrainTimer: null,
953
+ resultDrainMaxTimer: null,
954
+ pendingSteps: new Map(),
955
+ };
956
+ session.activeTurn = turn;
957
+ let sawTextSinceLastTool = false;
958
+ let stderrTail = '';
959
+ child.stderr?.setEncoding('utf8')?.on('data', (chunk) => {
960
+ stderrTail = (stderrTail + chunk).slice(-8000);
961
+ });
962
+
963
+ // agy 在 stream-json 模式下产出 result 后进程仍常驻(实测 25s+ 不自行退出),
964
+ // 不主动收尾会让 session.activeTurn 永远悬挂,下一回合 send 被“当前回合尚未结束”拒绝。
965
+ // 但某些工具(尤其 view_file 图片)会先产生没有 output 字段的 DONE,随后才
966
+ // 刷出最终 assistant_response。先结算 Turn 再结束 stdin 会把这段迟到文本丢给
967
+ // 已关闭的 Normalizer,所以空结果时必须先做一个有界 drain。
968
+ const clearResultDrainTimers = () => {
969
+ if (turn.resultDrainTimer) clearTimeout(turn.resultDrainTimer);
970
+ if (turn.resultDrainMaxTimer) clearTimeout(turn.resultDrainMaxTimer);
971
+ turn.resultDrainTimer = null;
972
+ turn.resultDrainMaxTimer = null;
973
+ };
974
+
975
+ const closeProcess = () => {
976
+ if (turn.processClosing || turn.processClosed) return;
977
+ turn.processClosing = true;
978
+ try { child.stdin.end(); } catch {}
979
+ turn.killTimer = setTimeout(() => { void terminateTree(child.pid); }, 10000);
980
+ turn.killTimer.unref?.();
981
+ };
982
+
983
+ const settleResult = () => {
984
+ if (turn.completed || !turn.result) return;
985
+ turn.completed = true;
986
+ clearResultDrainTimers();
987
+ if (session.activeTurn === turn) session.activeTurn = null;
988
+ const { res, nativeRef } = turn.result;
989
+ const completedRef = {
990
+ ...nativeRef,
991
+ turnId: res.num_turns != null ? `turn:${res.num_turns}` : undefined,
992
+ checkpointId: res.num_turns != null ? String(res.num_turns) : undefined,
993
+ };
994
+ const successful = resultSucceeded(res);
995
+ const hasAssistantText = (typeof res.response === 'string'
996
+ ? Boolean(res.response.trim())
997
+ : Boolean(res.response)) || sawTextSinceLastTool;
998
+ if (!successful) {
999
+ const message = formatAntigravityResultError(res);
1000
+ emitEvent({ kind: 'error', message, nativeRef: completedRef });
1001
+ turnReject?.(new Error(message));
1002
+ closeProcess();
1003
+ return;
1004
+ }
1005
+ if (!hasAssistantText) {
1006
+ const message = 'Antigravity 回合返回 SUCCESS,但没有 assistant 文本';
1007
+ const error = new Error(message);
1008
+ // 标记为可自动补问的失败:由 send 的重试循环决定是否向 UI 报错
1009
+ error.code = 'ANTIGRAVITY_EMPTY_ASSISTANT';
1010
+ error.nativeRef = completedRef;
1011
+ if (!suppressEmptyResultError) {
1012
+ emitEvent({ kind: 'error', message, nativeRef: completedRef });
1013
+ }
1014
+ turnReject?.(error);
1015
+ closeProcess();
1016
+ return;
1017
+ }
1018
+ emitEvent({
1019
+ kind: 'completed',
1020
+ finalAnswer: true,
1021
+ stopReason: 'completed',
1022
+ nativeRef: completedRef,
1023
+ });
1024
+ turnResolve?.();
1025
+ closeProcess();
1026
+ };
1027
+
1028
+ const scheduleResultDrain = () => {
1029
+ if (!turn.resultSeen || turn.completed || !turn.result) return;
1030
+ if (!resultSucceeded(turn.result.res)) {
1031
+ settleResult();
1032
+ return;
1033
+ }
1034
+ const hasAssistantText = (typeof turn.result.res.response === 'string'
1035
+ ? Boolean(turn.result.res.response.trim())
1036
+ : Boolean(turn.result.res.response)) || sawTextSinceLastTool;
1037
+ if (hasAssistantText) {
1038
+ if (turn.resultDrainMaxTimer) clearTimeout(turn.resultDrainMaxTimer);
1039
+ turn.resultDrainMaxTimer = null;
1040
+ if (turn.resultDrainTimer) clearTimeout(turn.resultDrainTimer);
1041
+ turn.resultDrainTimer = setTimeout(settleResult, resultDrainMs);
1042
+ return;
1043
+ }
1044
+ // No text at result time is not proof that the native turn is finished.
1045
+ // Keep the Core turn active until a late assistant response arrives, but
1046
+ // always retain a hard ceiling for a genuinely tool-only/empty turn.
1047
+ if (!turn.resultDrainMaxTimer) {
1048
+ turn.resultDrainMaxTimer = setTimeout(settleResult, resultDrainMaxMs);
1049
+ }
1050
+ };
1051
+
1052
+ const rl = readline.createInterface({ input: child.stdout });
1053
+ rl.on('line', (line) => {
1054
+ let event;
1055
+ try { event = JSON.parse(line.trim()); } catch { return; }
1056
+ recordNative(manifest.id, event);
1057
+
1058
+ const nativeRef = {
1059
+ sessionId: session.nativeSessionId || event.conversation_id || event.result?.conversation_id,
1060
+ };
1061
+
1062
+ if (event.event === 'init') {
1063
+ session.nativeSessionId = event.conversation_id;
1064
+ emitEvent({ kind: 'session', nativeSessionId: event.conversation_id, model: session.model });
1065
+ return;
1066
+ }
1067
+
1068
+ if (event.event === 'step_update') {
1069
+ const rawStep = event.step_update || {};
1070
+ const prevStep = turn.pendingSteps.get(rawStep.step_index);
1071
+ const s = mergePendingStep(prevStep, rawStep);
1072
+ if (s.step_index != null) turn.pendingSteps.set(s.step_index, s);
1073
+ const stepRef = { ...nativeRef, itemId: String(s.step_index) };
1074
+
1075
+ if (s.step_type === 'agent_response') {
1076
+ const text = s.text_delta || s.text || s.content || s.message;
1077
+ if (typeof text === 'string' && text) {
1078
+ sawTextSinceLastTool = true;
1079
+ emitEvent({ kind: 'text-delta', text, nativeRef: stepRef });
1080
+ scheduleResultDrain();
1081
+ }
1082
+ return;
1083
+ }
1084
+
1085
+ if (s.step_type === 'thought' || s.step_type === 'thinking') {
1086
+ const thinkingText = s.text_delta || s.text || s.content;
1087
+ if (thinkingText) {
1088
+ emitEvent({ kind: 'thinking-delta', text: thinkingText, nativeRef: stepRef });
1089
+ }
1090
+ return;
1091
+ }
1092
+
1093
+ if (s.step_type === 'subagent') {
1094
+ sawTextSinceLastTool = false;
1095
+ const info = s.subagent_info || {};
1096
+ const subagents = Array.isArray(info.subagents) ? info.subagents : [];
1097
+ const state = s.state === 'DONE' ? 'done' : (s.state === 'ERROR' ? 'error' : 'running');
1098
+ const details = subagents.map((sub) => `[${sub.role || sub.type_name || 'Subagent'}] ID: ${sub.conversation_id || ''}\n${sub.initial_prompt || ''}`.trim()).join('\n\n');
1099
+ emitEvent({
1100
+ kind: 'tool',
1101
+ toolCallId: String(s.step_index),
1102
+ title: '子 Agent',
1103
+ state,
1104
+ input: details || undefined,
1105
+ output: s.state === 'DONE' ? '子 Agent 启动完成' : undefined,
1106
+ nativeRef: stepRef,
1107
+ });
1108
+ return;
1109
+ }
1110
+
1111
+ if (s.step_type === 'tool') {
1112
+ sawTextSinceLastTool = false;
1113
+ const toolName = s.tool_name || s.tool_info?.name;
1114
+ const state = s.state === 'DONE' ? 'done' : (s.state === 'ERROR' ? 'error' : 'running');
1115
+ emitEvent({
1116
+ kind: 'tool',
1117
+ toolCallId: String(s.step_index),
1118
+ title: toolTitle(toolName),
1119
+ state,
1120
+ input: toolInput(s),
1121
+ output: toolOutput(s),
1122
+ nativeRef: stepRef,
1123
+ });
1124
+
1125
+ // Native file mutations
1126
+ if (s.state === 'DONE' && (toolName === 'write_to_file' || toolName === 'replace_file_content')) {
1127
+ const params = s.tool_info?.parameters || {};
1128
+ // agy stream-json 工具事件只携带 TargetFile,不携带文件内容(实测 ACTIVE/DONE 均如此)。
1129
+ // 保持 complete: false(与 codex.js 同一约定):仅作实时提示,回合结算时由
1130
+ // 工作区快照(ReviewStore)用权威 before/after 覆盖,UI 才能显示真实增删统计。
1131
+ const target = typeof params.TargetFile === 'string' ? params.TargetFile : null;
1132
+ const relative = target ? path.relative(session.cwd, path.resolve(session.cwd, target)).replace(/\\/g, '/') : '';
1133
+ // 只投影工作区内的变更;脑目录等 --add-dir 目录不属于本轮文件审查
1134
+ if (relative && relative !== '..' && !relative.startsWith('../') && !path.isAbsolute(relative)) {
1135
+ emitEvent({
1136
+ kind: 'file-change',
1137
+ source: 'native',
1138
+ changes: [{
1139
+ path: target,
1140
+ changeType: toolName === 'write_to_file' ? (params.Overwrite ? 'modified' : 'added') : 'modified',
1141
+ complete: false,
1142
+ nativeRef: stepRef,
1143
+ }],
1144
+ nativeRef: stepRef,
1145
+ });
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ if (s.usage) {
1151
+ const u = parseUsage(s.usage, session.model?.id, cachedQuota);
1152
+ if (u) {
1153
+ session.usage = u;
1154
+ emitEvent({ kind: 'usage', usage: u, nativeRef: stepRef });
1155
+ }
1156
+ }
1157
+ return;
1158
+ }
1159
+
1160
+ if (event.event === 'result') {
1161
+ if (turn.resultSeen || turn.completed) return;
1162
+ const res = event.result || {};
1163
+ if (res.conversation_id && !session.nativeSessionId) {
1164
+ session.nativeSessionId = res.conversation_id;
1165
+ }
1166
+ if (res.usage) {
1167
+ const u = parseUsage(res.usage, session.model?.id, cachedQuota);
1168
+ if (u) {
1169
+ session.usage = u;
1170
+ emitEvent({ kind: 'usage', usage: u, nativeRef });
1171
+ }
1172
+ }
1173
+ if (res.response && !sawTextSinceLastTool) {
1174
+ if (typeof res.response === 'string') {
1175
+ sawTextSinceLastTool = true;
1176
+ emitEvent({ kind: 'text-delta', text: res.response, nativeRef });
1177
+ }
1178
+ }
1179
+ turn.resultSeen = true;
1180
+ turn.result = { res, nativeRef };
1181
+ scheduleResultDrain();
1182
+ }
1183
+ });
1184
+
1185
+ child.on('error', (err) => {
1186
+ if (turn.resultSeen) {
1187
+ settleResult();
1188
+ return;
1189
+ }
1190
+ if (turn.killTimer) clearTimeout(turn.killTimer);
1191
+ clearResultDrainTimers();
1192
+ turn.completed = true;
1193
+ if (session.activeTurn === turn) session.activeTurn = null;
1194
+ void fs.promises.unlink(logPath).catch(() => {});
1195
+ void bridge.dispose();
1196
+ if (session.bridge === bridge) session.bridge = null;
1197
+ emitEvent({ kind: 'error', message: `Antigravity 进程错误: ${err.message}` });
1198
+ turnReject?.(err);
1199
+ });
1200
+
1201
+ child.on('close', (code) => {
1202
+ turn.processClosed = true;
1203
+ if (turn.killTimer) clearTimeout(turn.killTimer);
1204
+ clearResultDrainTimers();
1205
+ void fs.promises.unlink(logPath).catch(() => {});
1206
+ void bridge.dispose();
1207
+ if (session.bridge === bridge) session.bridge = null;
1208
+ if (turn.resultSeen) {
1209
+ // A real process close means stdout can no longer deliver a late
1210
+ // assistant event; settle the already received result now.
1211
+ settleResult();
1212
+ } else if (!turn.completed) {
1213
+ turn.completed = true;
1214
+ if (session.activeTurn === turn) session.activeTurn = null;
1215
+ const cleanErr = stderrTail.trim();
1216
+ const message = cleanErr
1217
+ ? `Antigravity CLI 未返回 result(退出码 ${code ?? 'unknown'}):${cleanErr}`
1218
+ : `Antigravity CLI 未返回 result(退出码 ${code ?? 'unknown'})`;
1219
+ // 进程未产出 result 即退出(崩溃、认证失败或协议中断):明确失败,
1220
+ // 不能把 code 0 的空 stdout 伪装成成功回合。
1221
+ emitEvent({ kind: 'error', message });
1222
+ turnReject?.(new Error(message));
1223
+ }
1224
+ });
1225
+
1226
+ try {
1227
+ if (child.stdin.writable) {
1228
+ child.stdin.write(JSON.stringify({ event: 'user', message: { content: promptText } }) + '\n');
1229
+ }
1230
+ } catch (err) {
1231
+ const message = `Antigravity 输入失败:${err.message}`;
1232
+ turn.completed = true;
1233
+ if (session.activeTurn === turn) session.activeTurn = null;
1234
+ void bridge.dispose();
1235
+ if (session.bridge === bridge) session.bridge = null;
1236
+ void terminateTree(child.pid);
1237
+ emitEvent({ kind: 'error', message });
1238
+ turnReject?.(new Error(message));
1239
+ }
1240
+
1241
+ return settled;
1242
+ };
1243
+
1244
+ const fullPrompt = formatPrompt(effectivePrompt);
1245
+ const maxAttempts = emptyResultMaxAttempts();
1246
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1247
+ const lastAttempt = attempt === maxAttempts;
1248
+ try {
1249
+ // eslint-disable-next-line no-await-in-loop
1250
+ return await runTurn(attempt > 1 ? ANTIGRAVITY_EMPTY_RESULT_NUDGE : fullPrompt, {
1251
+ suppressEmptyResultError: !lastAttempt,
1252
+ });
1253
+ } catch (err) {
1254
+ if (err?.code === 'ANTIGRAVITY_EMPTY_ASSISTANT' && !lastAttempt) {
1255
+ emitEvent({
1256
+ kind: 'status',
1257
+ text: 'Antigravity 回合返回了空结果,正在同一会话中自动补问…',
1258
+ nativeRef: err.nativeRef,
1259
+ });
1260
+ continue;
1261
+ }
1262
+ throw err;
1263
+ }
1264
+ }
1265
+ return undefined;
1266
+ },
1267
+
1268
+ async cancel(session) {
1269
+ if (session.activeTurn) {
1270
+ session.activeTurn.completed = true;
1271
+ if (session.activeTurn.child) {
1272
+ void terminateTree(session.activeTurn.child.pid);
1273
+ }
1274
+ session.activeTurn.resolve?.();
1275
+ session.activeTurn = null;
1276
+ }
1277
+ if (session.bridge) {
1278
+ await session.bridge.dispose().catch(() => {});
1279
+ session.bridge = null;
1280
+ }
1281
+ },
1282
+
1283
+ async respond(session, requestId, response) {
1284
+ if (session.bridge) {
1285
+ session.bridge.respond(requestId, response);
1286
+ }
1287
+ },
1288
+
1289
+ async listModels() {
1290
+ return null;
1291
+ },
1292
+
1293
+ async listModelsFor(_session) {
1294
+ const bin = resolveExecutable();
1295
+ const env = await agyChildEnv();
1296
+ return new Promise((resolve) => {
1297
+ execFile(bin, ['models'], { env, windowsHide: true, timeout: 20000 }, (error, stdout) => {
1298
+ if (error) return resolve([]);
1299
+ resolve(parseModelsOutput(stdout));
1300
+ });
1301
+ });
1302
+ },
1303
+
1304
+ async setModel(session, model) {
1305
+ session.model = { id: model.id, name: model.name || model.id, provider: model.provider || 'google' };
1306
+ return session.model;
1307
+ },
1308
+
1309
+ async setThinkingLevel(session, level) {
1310
+ session.thinkingLevel = level;
1311
+ },
1312
+
1313
+ async setPermissionMode(session, mode) {
1314
+ session.permissionMode = mode;
1315
+ },
1316
+
1317
+ async getContextUsage(session) {
1318
+ return session.usage;
1319
+ },
1320
+
1321
+ async describe() {
1322
+ const bin = resolveExecutable();
1323
+ const env = await agyChildEnv();
1324
+ const stdout = await new Promise((resolve) => {
1325
+ execFile(bin, ['models'], { env, windowsHide: true, timeout: 20000 }, (error, out) => {
1326
+ if (error) return resolve('');
1327
+ resolve(out);
1328
+ });
1329
+ });
1330
+ const models = parseModelsOutput(stdout);
1331
+ return {
1332
+ models,
1333
+ thinkingLevels: [
1334
+ // default: true 是 adapter 的显式声明:该 Harness 的思考档位可在 UI 选择,
1335
+ // 且默认档与 open() 的回退值('high')一致——协议层据此下发可选集合。
1336
+ { id: 'high', label: 'High', default: true },
1337
+ { id: 'medium', label: 'Medium' },
1338
+ { id: 'low', label: 'Low' },
1339
+ ],
1340
+ permissionModes: ANTIGRAVITY_PERMISSION_MODES,
1341
+ };
1342
+ },
1343
+
1344
+ async describeFor(session) {
1345
+ const { models, thinkingLevels } = await this.describe();
1346
+ return {
1347
+ models,
1348
+ thinkingLevels,
1349
+ permissionModes: ANTIGRAVITY_PERMISSION_MODES,
1350
+ };
1351
+ },
1352
+
1353
+ async fork(source, { emit: emitEvent, diagnostic, message }) {
1354
+ const derivedId = randomUUID();
1355
+ const sourceId = source.nativeSessionId;
1356
+ const checkpointId = message?.coreTurn?.nativeTurnRef?.checkpointId;
1357
+ const retainedTurnsCount = checkpointId != null && !Number.isNaN(Number(checkpointId)) ? Number(checkpointId) : undefined;
1358
+ if (sourceId) {
1359
+ await cloneDatabase(sourceId, derivedId, retainedTurnsCount);
1360
+ await cloneBrain(sourceId, derivedId);
1361
+ }
1362
+ const model = source.model || { id: 'gemini-3.8-flash', name: 'Gemini 3.8 Flash', provider: 'google' };
1363
+ const session = {
1364
+ nativeSessionId: derivedId,
1365
+ cwd: source.cwd,
1366
+ model,
1367
+ thinkingLevel: source.options?.thinking || 'high',
1368
+ permissionMode: source.options?.permissionMode || 'default',
1369
+ activeTurn: null,
1370
+ bridge: null,
1371
+ usage: undefined,
1372
+ pendingApprovals: new Map(),
1373
+ };
1374
+ emitEvent({ kind: 'session', nativeSessionId: derivedId, model });
1375
+ return { session, nativeSessionId: derivedId };
1376
+ },
1377
+
1378
+ credits() {
1379
+ return cachedQuota;
1380
+ },
1381
+
1382
+ async refreshCredits() {
1383
+ if (!quotaPromise) quotaPromise = fetchAntigravityQuota();
1384
+ const request = quotaPromise;
1385
+ const quota = await request.finally(() => {
1386
+ if (quotaPromise === request) quotaPromise = null;
1387
+ });
1388
+ if (quota) cachedQuota = quota;
1389
+ return cachedQuota;
1390
+ },
1391
+
1392
+ async inspectAccount() {
1393
+ const quota = await this.refreshCredits();
1394
+ if (!quota) return null;
1395
+ return {
1396
+ label: 'Antigravity',
1397
+ plan: 'Google Gemini',
1398
+ credits: quota,
1399
+ };
1400
+ },
1401
+
1402
+ async close(session) {
1403
+ await this.cancel(session);
1404
+ },
1405
+ };
1406
+ }
1407
+
1408
+ // https://antigravity.google/docs/skills/#where-skills-live
1409
+ // Single assignment: an earlier declaration used to be silently overwritten here,
1410
+ // which dropped Antigravity's native skill roots from the manifest.
1411
+ manifest.integrations = { mcp: true, skills: { global: ['.gemini/config/skills'], project: ['.agents/skills'] } };
1412
+ module.exports = {
1413
+ manifest,
1414
+ create,
1415
+ parseModelsOutput,
1416
+ parseUsage,
1417
+ formatAntigravityResultError,
1418
+ modelSupportsEffort,
1419
+ formatPrompt,
1420
+ prepareImageAttachments,
1421
+ mergePendingStep,
1422
+ cloneDatabase,
1423
+ fetchAntigravityQuota,
1424
+ parseAntigravityUsageCommand,
1425
+ ANTIGRAVITY_PERMISSION_MODES,
1426
+ ANTIGRAVITY_WORKSPACE_FILE_INSTRUCTION,
1427
+ };
1428
+