ai_co_discuss 0.3.39 → 0.3.41

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.
@@ -1,324 +1,324 @@
1
- /**
2
- * agy (Antigravity CLI) adapter: one headless `agy -p` turn per execution, run inside a private native project this tool
3
- * creates so its permission grants apply. Sessions are conversation databases, branched by copying them (not an
4
- * official interface; tested on agy 1.4.9). Antigravity's project registry is shared with the App adapter.
5
- */
6
- import { createHash, randomUUID } from 'node:crypto';
7
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
8
- import { homedir } from 'node:os';
9
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
10
- import { fileURLToPath, pathToFileURL } from 'node:url';
11
- import { DatabaseSync } from 'node:sqlite';
12
- import { atomic_json } from '../files.mjs';
13
- import { cli_delivery } from './cli.mjs';
14
- import { DENIAL_FIELD, EDIT_TOOLS, json_line, launch_directory, MAX_DENIALS, model_entry, MODEL_PATTERN, project_directory,
15
- query_catalog, read_json, same_path, TASK_SUBCOMMANDS } from './common.mjs';
16
-
17
- // agy stores its generation settings as protobuf; field 28 (tag bytes 0xe2 0x01) holds the model actually used.
18
- const AGY_MODEL_TAG = [0xe2, 0x01];
19
-
20
- /**
21
- * One operation a headless agy run refused, from the failed tool step, with the project grant that would allow it.
22
- * agy names the check that failed: a sandboxed or unsandboxed command, or a file write.
23
- */
24
- export function agy_denial(step) {
25
- const message = step.tool_info?.error?.message || '';
26
- if (!/permission check failed/.test(message)) return null;
27
- const parameters = step.tool_info.parameters || {};
28
- // Older agy did not name the check; a refused step with a command line was a command.
29
- const kind = message.match(/permission check failed for (\w+)/)?.[1] || (parameters.CommandLine ? 'command' : '');
30
- if (!kind) return null;
31
- if (kind === 'write_file') {
32
- const path = String(parameters.Path || parameters.TargetFile || message.match(/write_file\(([^)]+)\)/)?.[1] || '');
33
- return { tool: 'write_file', detail: path.slice(0, DENIAL_FIELD), rule: path ? `write_file(${resolve(dirname(path)).replaceAll('\\', '/')})` : '' };
34
- }
35
- const command = String(parameters.CommandLine || '');
36
- const rule = `${kind === 'unsandboxed' ? 'unsandboxed_command' : 'command'}(${command})`;
37
- return { tool: step.tool_name || 'run_command', detail: command.slice(0, DENIAL_FIELD), rule: command && rule.length <= DENIAL_FIELD ? rule : '' };
38
- }
39
-
40
- /** Allow only the discussion website commands, without shell separators or expressions. */
41
- export function agy_rule() {
42
- const safe = '[^;&|<>$`()\\r\\n]';
43
- return `command(regex:^codiscuss(\\.cmd)?( --site ${safe.replace('[^', '[^ ')}+)? (${TASK_SUBCOMMANDS.join('|')})( ${safe}*)?$)`;
44
- }
45
-
46
- /** Native headless review blocks unapproved shell commands; only a global grant of every command could bypass the project rules. */
47
- function check_agy_shell(user_home) {
48
- const cli = read_json(join(user_home, '.gemini', 'antigravity-cli', 'settings.json'));
49
- const shared = read_json(join(user_home, '.gemini', 'config', 'config.json'));
50
- const grants = [...(cli.permissions?.allow || []), ...(shared.userSettings?.globalPermissionGrants?.allow || [])];
51
- if (grants.some(rule => rule === '*' || /^(?:command|unsandboxed|unsandboxed_command)\((?:\*|regex:\.\*)\)$/.test(rule))) {
52
- throw new Error('agy 全局设置放行了所有命令,会绕过讨论项目的权限;请移除全局 command(*) / unsandboxed(*) 放行,具体命令的放行不受影响(本工具旧版写入的规则可运行 codiscuss setup 清理),未交付讨论任务');
53
- }
54
- }
55
-
56
- /** Test directory containment on the current platform, including Windows drive boundaries. */
57
- function within_directory(parent, child) {
58
- const path = relative(resolve(parent), resolve(child));
59
- return !path || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`));
60
- }
61
-
62
- /** Resolve Antigravity's real project resources without reading any conversation; shared with the App adapter. */
63
- export function antigravity_project(config, root = join(homedir(), '.gemini', 'config')) {
64
- if (!config.project_id && !config.project_name && !config.cwd) return { id: 'outside-of-project' };
65
- const directory = join(root, 'projects');
66
- const projects = readdirSync(directory).filter(name => name.endsWith('.json')).map(name => JSON.parse(readFileSync(join(directory, name), 'utf8')));
67
- const matching = projects.filter(project => (!config.project_id || project.id === config.project_id)
68
- && (!config.project_name || project.name === config.project_name)
69
- && (!config.cwd || (project.projectResources?.resources || []).some(resource => {
70
- const uri = resource.gitFolder?.folderUri || resource.folder?.folderUri;
71
- return uri?.startsWith('file:') && same_path(fileURLToPath(uri), config.cwd);
72
- })));
73
- if (matching.length !== 1) throw new Error('Antigravity 项目与 cwd 无法唯一匹配;请先在 App 添加目录并核对配置');
74
- return matching[0];
75
- }
76
-
77
- /** Resolve only the user's selected folders; do not inherit another project's grants or settings. */
78
- function agy_resources(config, task, root) {
79
- if (config.project_id || config.project_name) return antigravity_project(config, root).projectResources?.resources || [];
80
- return [{ folder: { folderUri: pathToFileURL(launch_directory(config, task)).href } }];
81
- }
82
-
83
- /** Build native deny rules for protected directories, with separate writable answer storage. */
84
- function agy_project_grants(config, task, resources, user_home) {
85
- const folders = resources.map(resource => resource.gitFolder?.folderUri || resource.folder?.folderUri)
86
- .filter(uri => uri?.startsWith('file:')).map(uri => fileURLToPath(uri));
87
- const projects = [...new Set([project_directory(config), ...folders].filter(directory => directory && !same_path(directory, task.draft_directory)))];
88
- const protected_paths = [task.directory, task.skills_directory, join(user_home, '.gemini'),
89
- ...(config.project_access === 'read' ? projects : [])];
90
- if (protected_paths.some(directory => within_directory(directory, task.draft_directory))) {
91
- throw new Error('agy 草稿目录不能位于只读项目、运行目录、技能或 .gemini 配置目录中,未交付讨论任务');
92
- }
93
- const write_rule = directory => `write_file(${resolve(directory).replaceAll('\\', '/')})`;
94
- // 全放开 also allows commands outside agy's sandbox and MCP tools; deny rules are still written, but bypass may override them.
95
- const full = config.project_access === 'full';
96
- return { allow: [agy_rule(), write_rule(task.draft_directory),
97
- ...(config.project_access === 'read' ? [] : [...projects.map(write_rule), 'command(*)']), ...(full ? ['unsandboxed_command(*)'] : []),
98
- // Grants the owner allowed from a refused operation on the page.
99
- ...(config.allowed_tools || [])],
100
- deny: [...new Set(protected_paths.map(write_rule)), ...(full ? [] : ['mcp(*)'])] };
101
- }
102
-
103
- /** Run agy only inside a private native project this tool created; external conversations keep their own project, so they are refused. */
104
- export function prepare_agy_project(config, binding, task, user_home = homedir()) {
105
- const key = createHash('sha256').update(task.participant).digest('hex');
106
- const record_path = join(task.directory, `agy-project-${key}.json`);
107
- const record = read_json(record_path);
108
- if (binding.session_id && !record.project_id) {
109
- throw new Error('agy 只在本工具新建的讨论会话中自动执行;该会话不是本工具创建的,无法套用讨论项目权限,未交付讨论任务');
110
- }
111
- if (record.session_id && record.session_id !== binding.session_id) throw new Error('agy 项目记录与会话绑定不符,未交付讨论任务');
112
- check_agy_shell(user_home);
113
- const root = join(user_home, '.gemini', 'config');
114
- const resources = agy_resources(config, task, root);
115
- const grants = agy_project_grants(config, task, resources, user_home);
116
- const project_id = record.project_id || randomUUID();
117
- if (!/^[a-f0-9-]{36}$/.test(project_id)) throw new Error('agy 项目记录无效,未交付讨论任务');
118
- const path = join(root, 'projects', `${project_id}.json`);
119
- const current = read_json(path);
120
- if (record.project_id && current.id !== project_id) throw new Error('agy 讨论项目已丢失或不匹配,未交付讨论任务');
121
- mkdirSync(join(root, 'projects'), { recursive: true });
122
- const name = ['CoDiscuss', task.room_title, task.participant].filter(Boolean).join(' · ');
123
- atomic_json(path, { ...current, id: project_id, name,
124
- projectResources: { resources }, settings: {}, permissionGrants: { permissionGrants: grants, v2Migrated: true } });
125
- const saved = { project_id, session_id: record.session_id || '' };
126
- atomic_json(record_path, saved);
127
- return { project_id, bind: session_id => atomic_json(record_path, { ...saved, session_id }) };
128
- }
129
-
130
- /**
131
- * Delete the agy projects this room created after the room itself is deleted; the user's own projects are never touched.
132
- * @returns {number} How many project records were cleared.
133
- */
134
- export function remove_agy_projects(directory, user_home = homedir()) {
135
- if (!existsSync(directory)) return 0;
136
- const records = readdirSync(directory).filter(name => /^agy-project-[a-f0-9]{64}\.json$/.test(name));
137
- for (const file of records) {
138
- const { project_id } = read_json(join(directory, file));
139
- const path = join(user_home, '.gemini', 'config', 'projects', `${project_id}.json`);
140
- if (/^[a-f0-9-]{36}$/.test(project_id || '') && read_json(path).id === project_id) rmSync(path);
141
- rmSync(join(directory, file));
142
- }
143
- return records.length;
144
- }
145
-
146
- /** Native agy arguments; an expert the owner chose to host keeps its own conversation's project and permissions. */
147
- function invocation(config, binding, task, user_home) {
148
- const project = config.session_id ? null : prepare_agy_project(config, binding, task, user_home);
149
- const args = ['--output-format', 'stream-json', '--print-timeout', '0', '--add-dir', task.directory,
150
- '--add-dir', task.draft_directory, '--add-dir', task.skills_directory];
151
- // Headless agy refuses `--mode auto` ("not available for this account", 1.4.10–1.4.11) though its interactive window
152
- // offers it; 全放开 uses the flag its own denial message suggests on top of the open project grants.
153
- args.push('--mode', 'accept-edits', ...(config.project_access === 'full' ? ['--dangerously-skip-permissions'] : []),
154
- ...(project ? ['--project', project.project_id] : []));
155
- if (binding.session_id) args.push('--conversation', binding.session_id);
156
- if (config.model) args.push('--model', config.model);
157
- if (config.effort) args.push('--effort', config.effort);
158
- args.push('-p', task.prompt);
159
- return { args, ...(project ? { on_session: project.bind } : {}) };
160
- }
161
-
162
- /** Consume only documented lifecycle events, and remember refused operations so the page can name and allow them. */
163
- function lifecycle(state, event, task, bind) {
164
- if (event.event === 'init') bind(event.conversation_id);
165
- const step = event.step_update;
166
- // Headless agy ends its turn when an operation needs approval; remember which ones so the page can name and allow them.
167
- const denied = step?.state === 'ERROR' ? agy_denial(step) : null;
168
- if (denied) {
169
- if (!state.denials.some(item => item.detail === denied.detail) && state.denials.length < MAX_DENIALS) state.denials.push(denied);
170
- state.denial = `${denied.tool === 'write_file' ? '写入' : '运行'}了未授权的${denied.tool === 'write_file' ? '文件' : '命令'}「${denied.detail.slice(0, 120)}」,无人值守模式下无法确认权限而被自动拒绝,随后会话结束`;
171
- }
172
- if (event.event === 'result') {
173
- const result = event.result || {};
174
- if (result.conversation_id) bind(result.conversation_id);
175
- state.completed = result.status === 'SUCCESS';
176
- // A refused operation still ends with SUCCESS and an empty response; name it even when no failed step was seen.
177
- if (!state.denial && Array.isArray(result.denied_actions) && result.denied_actions.length) {
178
- state.denial = `的${result.denied_actions.map(item => item.display_name || item.action).join('、')}操作未获授权,无人值守模式下被自动拒绝,随后会话结束`;
179
- }
180
- // agy reports `error` as a plain string (1.4.x), older builds as { message }.
181
- const reason = typeof result.error === 'string' ? result.error : result.error?.message;
182
- if (!state.completed) state.error = `Antigravity CLI ${result.status || '缺少结果状态'}:${reason || result.response || '执行未成功'}`;
183
- // Failed before opening a conversation (sign-in, eligibility check): no turn ran, so delivering again repeats nothing.
184
- if (!state.completed && !state.initialized && !result.conversation_id && !result.num_turns) state.not_started = true;
185
- }
186
- }
187
-
188
- /**
189
- * What agy's stream events say the agent is doing and which tool it starts. agy streams a response in deltas, so its
190
- * text is collected per step in `responses` and returned as a note when the step ends.
191
- */
192
- const events = {
193
- phase(event) {
194
- if (event.event === 'init') return 'thinking';
195
- const step = event.step_update;
196
- if (!step) return '';
197
- if (step.step_type === 'agent_response' && step.state === 'ACTIVE') return 'writing';
198
- if (step.step_type !== 'tool' || ['DONE', 'ERROR'].includes(step.state)) return 'thinking';
199
- return step.tool_name === 'run_command' ? 'command' : EDIT_TOOLS.test(step.tool_name || '') ? 'editing' : 'tool';
200
- },
201
- tool(event) {
202
- const step = event.step_update;
203
- return step?.step_type === 'tool' && !['DONE', 'ERROR'].includes(step.state) ? String(step.tool_name || '') : '';
204
- },
205
- note(event, responses = new Map()) {
206
- const step = event.step_update;
207
- if (step?.step_type !== 'agent_response') return '';
208
- responses.set(step.step_index, (responses.get(step.step_index) || '') + (step.text_delta || ''));
209
- if (step.state === 'ACTIVE') return '';
210
- const text = responses.get(step.step_index).trim();
211
- responses.delete(step.step_index);
212
- return text;
213
- },
214
- };
215
-
216
- /** Where agy keeps one conversation database, or '' when this machine has none by that ID. */
217
- export function session_path(session_id, env = process.env, home = homedir()) {
218
- const file = join(home, '.gemini', 'antigravity-cli', 'conversations', `${session_id}.db`);
219
- return existsSync(file) ? file : '';
220
- }
221
-
222
- /** Whether agy stored a conversation by that ID on this machine. */
223
- export function session_exists(session_id, { env = process.env, home = homedir() } = {}) {
224
- return Boolean(session_id && session_path(session_id, env, home));
225
- }
226
-
227
- /** Copy the conversation database under a new ID, without the steps from the first marked one on. */
228
- function branch(profile, session_id, markers, { always, home }) {
229
- const file = session_path(session_id, process.env, home);
230
- if (!file) throw new Error(`本机找不到 agy 会话 ${session_id}`);
231
- const encoded = markers.map(marker => Buffer.from(marker, 'utf8'));
232
- const source = new DatabaseSync(file, { readOnly: true });
233
- const target = join(file, '..', `${randomUUID()}.db`);
234
- let cut;
235
- try {
236
- // The prompt is stored inside the step's protobuf payload, as UTF-8 text.
237
- cut = source.prepare('SELECT idx,step_payload FROM steps ORDER BY idx').all()
238
- .find(row => row.step_payload && encoded.some(marker => Buffer.from(row.step_payload).includes(marker)))?.idx;
239
- if (cut === undefined && !always) return null;
240
- if (cut === 0) return { session_id: '' };
241
- source.exec(`VACUUM INTO '${target.replaceAll("'", "''")}'`);
242
- } finally { source.close(); }
243
- const id = target.slice(-39, -3);
244
- const copy = new DatabaseSync(target);
245
- try {
246
- if (cut !== undefined) copy.prepare('DELETE FROM steps WHERE idx>=?').run(cut);
247
- copy.prepare('UPDATE trajectory_meta SET cascade_id=?').run(id);
248
- } finally { copy.close(); }
249
- return { session_id: id };
250
- }
251
-
252
- /** The model string after agy's model tag in one protobuf blob. */
253
- function agy_model(blob) {
254
- const bytes = Buffer.from(blob);
255
- for (let at = bytes.indexOf(Buffer.from(AGY_MODEL_TAG)); at >= 0; at = bytes.indexOf(Buffer.from(AGY_MODEL_TAG), at + 1)) {
256
- const length = bytes[at + 2], text = bytes.subarray(at + 3, at + 3 + length).toString('latin1');
257
- if (length && text.length === length && MODEL_PATTERN.test(text)) return text;
258
- }
259
- return '';
260
- }
261
-
262
- /** The model a conversation last ran with, from its database; agy names the effort inside the model ID (gemini-…-medium). */
263
- export function session_settings(session_id, { env = process.env, home = homedir() } = {}) {
264
- return database_settings(session_path(session_id, env, home));
265
- }
266
-
267
- /** The model a conversation database last ran with; agy and the Antigravity App store it alike. */
268
- export function database_settings(file) {
269
- if (!file) return null;
270
- const db = new DatabaseSync(file, { readOnly: true });
271
- try {
272
- for (const { data } of db.prepare('SELECT data FROM gen_metadata ORDER BY idx DESC LIMIT 5').all()) {
273
- const model = data ? agy_model(data) : '';
274
- if (model) return { model, effort: '' };
275
- }
276
- } finally { db.close(); }
277
- return null;
278
- }
279
-
280
- /** agy's native slash commands answer without an agent turn: /effort lists levels, /model names the current model. */
281
- function slash_command(profile, command, options) {
282
- const args = ['-p', `/${command}`, '--output-format', 'json', ...(command === 'effort' && profile.model ? ['--model', profile.model] : [])];
283
- return query_catalog(profile, args, (send, finish) => ({ start: () => {}, line: line => {
284
- const reply = json_line(line), data = reply?.command?.data;
285
- if (reply?.status !== 'SUCCESS' || reply.command?.name !== command || !data) return;
286
- if (command === 'effort' && Array.isArray(data.available)) finish(null, data.adjustable ? data.available.filter(level => typeof level === 'string') : []);
287
- if (command === 'model') finish(null, { model: MODEL_PATTERN.test(data.id || '') ? data.id : '', effort: typeof data.effort === 'string' ? data.effort : '' });
288
- } }), options);
289
- }
290
-
291
- /** `agy models` prints tab-separated ids and labels. */
292
- function models(profile, options) {
293
- return query_catalog(profile, ['models'], () => {
294
- const list = [];
295
- return { start: () => {}, complete: () => list, line: line => {
296
- const fields = line.trim().split(/\t+| {2,}/);
297
- if (fields.length >= 2) list.push(model_entry(fields[0], fields.slice(1).join(' ')));
298
- } };
299
- }, options);
300
- }
301
-
302
- /** The effort levels agy offers for the selected model. */
303
- function effort(profile, options) {
304
- if (!isAbsolute(profile.executable || '') || (profile.model && !MODEL_PATTERN.test(profile.model))) return Promise.reject(new Error('无法读取所选模型的思考档位'));
305
- return slash_command(profile, 'effort', options);
306
- }
307
-
308
- /** What agy runs with when the card leaves model or effort empty, from its native /model. */
309
- async function defaults(profile, options = {}) {
310
- if (!isAbsolute(profile.executable || '')) return { model: '', effort: '' };
311
- try { return await slash_command(profile, 'model', options); }
312
- catch { return { model: '', effort: '' }; }
313
- }
314
-
315
- /** agy's behaviour; its static facts are in profiles.mjs. */
316
- export const antigravity_cli = {
317
- id: 'antigravity_cli', catalog_source: 'antigravity-cli',
318
- // A deleted room's private agy projects are removed with it.
319
- cleanup: remove_agy_projects,
320
- deliver: (config, binding, task, spawn_process, user_home) => cli_delivery(antigravity_cli, config, binding, task, spawn_process, user_home),
321
- invocation, lifecycle, events,
322
- session: { path: session_path, exists: session_exists, owns: session_exists, branch, settings: session_settings },
323
- models: { list: models, effort, defaults },
324
- };
1
+ /**
2
+ * agy (Antigravity CLI) adapter: one headless `agy -p` turn per execution, run inside a private native project this tool
3
+ * creates so its permission grants apply. Sessions are conversation databases, branched by copying them (not an
4
+ * official interface; tested on agy 1.4.9). Antigravity's project registry is shared with the App adapter.
5
+ */
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+ import { DatabaseSync } from 'node:sqlite';
12
+ import { atomic_json } from '../files.mjs';
13
+ import { cli_delivery } from './cli.mjs';
14
+ import { DENIAL_FIELD, EDIT_TOOLS, json_line, launch_directory, MAX_DENIALS, model_entry, MODEL_PATTERN, project_directory,
15
+ query_catalog, read_json, same_path, TASK_SUBCOMMANDS } from './common.mjs';
16
+
17
+ // agy stores its generation settings as protobuf; field 28 (tag bytes 0xe2 0x01) holds the model actually used.
18
+ const AGY_MODEL_TAG = [0xe2, 0x01];
19
+
20
+ /**
21
+ * One operation a headless agy run refused, from the failed tool step, with the project grant that would allow it.
22
+ * agy names the check that failed: a sandboxed or unsandboxed command, or a file write.
23
+ */
24
+ export function agy_denial(step) {
25
+ const message = step.tool_info?.error?.message || '';
26
+ if (!/permission check failed/.test(message)) return null;
27
+ const parameters = step.tool_info.parameters || {};
28
+ // Older agy did not name the check; a refused step with a command line was a command.
29
+ const kind = message.match(/permission check failed for (\w+)/)?.[1] || (parameters.CommandLine ? 'command' : '');
30
+ if (!kind) return null;
31
+ if (kind === 'write_file') {
32
+ const path = String(parameters.Path || parameters.TargetFile || message.match(/write_file\(([^)]+)\)/)?.[1] || '');
33
+ return { tool: 'write_file', detail: path.slice(0, DENIAL_FIELD), rule: path ? `write_file(${resolve(dirname(path)).replaceAll('\\', '/')})` : '' };
34
+ }
35
+ const command = String(parameters.CommandLine || '');
36
+ const rule = `${kind === 'unsandboxed' ? 'unsandboxed_command' : 'command'}(${command})`;
37
+ return { tool: step.tool_name || 'run_command', detail: command.slice(0, DENIAL_FIELD), rule: command && rule.length <= DENIAL_FIELD ? rule : '' };
38
+ }
39
+
40
+ /** Allow only the discussion website commands, without shell separators or expressions. */
41
+ export function agy_rule() {
42
+ const safe = '[^;&|<>$`()\\r\\n]';
43
+ return `command(regex:^codiscuss(\\.cmd)?( --site ${safe.replace('[^', '[^ ')}+)? (${TASK_SUBCOMMANDS.join('|')})( ${safe}*)?$)`;
44
+ }
45
+
46
+ /** Native headless review blocks unapproved shell commands; only a global grant of every command could bypass the project rules. */
47
+ function check_agy_shell(user_home) {
48
+ const cli = read_json(join(user_home, '.gemini', 'antigravity-cli', 'settings.json'));
49
+ const shared = read_json(join(user_home, '.gemini', 'config', 'config.json'));
50
+ const grants = [...(cli.permissions?.allow || []), ...(shared.userSettings?.globalPermissionGrants?.allow || [])];
51
+ if (grants.some(rule => rule === '*' || /^(?:command|unsandboxed|unsandboxed_command)\((?:\*|regex:\.\*)\)$/.test(rule))) {
52
+ throw new Error('agy 全局设置放行了所有命令,会绕过讨论项目的权限;请移除全局 command(*) / unsandboxed(*) 放行,具体命令的放行不受影响(本工具旧版写入的规则可运行 codiscuss setup 清理),未交付讨论任务');
53
+ }
54
+ }
55
+
56
+ /** Test directory containment on the current platform, including Windows drive boundaries. */
57
+ function within_directory(parent, child) {
58
+ const path = relative(resolve(parent), resolve(child));
59
+ return !path || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`));
60
+ }
61
+
62
+ /** Resolve Antigravity's real project resources without reading any conversation; shared with the App adapter. */
63
+ export function antigravity_project(config, root = join(homedir(), '.gemini', 'config')) {
64
+ if (!config.project_id && !config.project_name && !config.cwd) return { id: 'outside-of-project' };
65
+ const directory = join(root, 'projects');
66
+ const projects = readdirSync(directory).filter(name => name.endsWith('.json')).map(name => JSON.parse(readFileSync(join(directory, name), 'utf8')));
67
+ const matching = projects.filter(project => (!config.project_id || project.id === config.project_id)
68
+ && (!config.project_name || project.name === config.project_name)
69
+ && (!config.cwd || (project.projectResources?.resources || []).some(resource => {
70
+ const uri = resource.gitFolder?.folderUri || resource.folder?.folderUri;
71
+ return uri?.startsWith('file:') && same_path(fileURLToPath(uri), config.cwd);
72
+ })));
73
+ if (matching.length !== 1) throw new Error('Antigravity 项目与 cwd 无法唯一匹配;请先在 App 添加目录并核对配置');
74
+ return matching[0];
75
+ }
76
+
77
+ /** Resolve only the user's selected folders; do not inherit another project's grants or settings. */
78
+ function agy_resources(config, task, root) {
79
+ if (config.project_id || config.project_name) return antigravity_project(config, root).projectResources?.resources || [];
80
+ return [{ folder: { folderUri: pathToFileURL(launch_directory(config, task)).href } }];
81
+ }
82
+
83
+ /** Build native deny rules for protected directories, with separate writable answer storage. */
84
+ function agy_project_grants(config, task, resources, user_home) {
85
+ const folders = resources.map(resource => resource.gitFolder?.folderUri || resource.folder?.folderUri)
86
+ .filter(uri => uri?.startsWith('file:')).map(uri => fileURLToPath(uri));
87
+ const projects = [...new Set([project_directory(config), ...folders].filter(directory => directory && !same_path(directory, task.draft_directory)))];
88
+ const protected_paths = [task.directory, ...task.skill_directories, join(user_home, '.gemini'),
89
+ ...(config.project_access === 'read' ? projects : [])];
90
+ if (protected_paths.some(directory => within_directory(directory, task.draft_directory))) {
91
+ throw new Error('agy 草稿目录不能位于只读项目、运行目录、技能或 .gemini 配置目录中,未交付讨论任务');
92
+ }
93
+ const write_rule = directory => `write_file(${resolve(directory).replaceAll('\\', '/')})`;
94
+ // 全放开 also allows commands outside agy's sandbox and MCP tools; deny rules are still written, but bypass may override them.
95
+ const full = config.project_access === 'full';
96
+ return { allow: [agy_rule(), write_rule(task.draft_directory),
97
+ ...(config.project_access === 'read' ? [] : [...projects.map(write_rule), 'command(*)']), ...(full ? ['unsandboxed_command(*)'] : []),
98
+ // Grants the owner allowed from a refused operation on the page.
99
+ ...(config.allowed_tools || [])],
100
+ deny: [...new Set(protected_paths.map(write_rule)), ...(full ? [] : ['mcp(*)'])] };
101
+ }
102
+
103
+ /** Run agy only inside a private native project this tool created; external conversations keep their own project, so they are refused. */
104
+ export function prepare_agy_project(config, binding, task, user_home = homedir()) {
105
+ const key = createHash('sha256').update(task.participant).digest('hex');
106
+ const record_path = join(task.directory, `agy-project-${key}.json`);
107
+ const record = read_json(record_path);
108
+ if (binding.session_id && !record.project_id) {
109
+ throw new Error('agy 只在本工具新建的讨论会话中自动执行;该会话不是本工具创建的,无法套用讨论项目权限,未交付讨论任务');
110
+ }
111
+ if (record.session_id && record.session_id !== binding.session_id) throw new Error('agy 项目记录与会话绑定不符,未交付讨论任务');
112
+ check_agy_shell(user_home);
113
+ const root = join(user_home, '.gemini', 'config');
114
+ const resources = agy_resources(config, task, root);
115
+ const grants = agy_project_grants(config, task, resources, user_home);
116
+ const project_id = record.project_id || randomUUID();
117
+ if (!/^[a-f0-9-]{36}$/.test(project_id)) throw new Error('agy 项目记录无效,未交付讨论任务');
118
+ const path = join(root, 'projects', `${project_id}.json`);
119
+ const current = read_json(path);
120
+ if (record.project_id && current.id !== project_id) throw new Error('agy 讨论项目已丢失或不匹配,未交付讨论任务');
121
+ mkdirSync(join(root, 'projects'), { recursive: true });
122
+ const name = ['CoDiscuss', task.room_title, task.participant].filter(Boolean).join(' · ');
123
+ atomic_json(path, { ...current, id: project_id, name,
124
+ projectResources: { resources }, settings: {}, permissionGrants: { permissionGrants: grants, v2Migrated: true } });
125
+ const saved = { project_id, session_id: record.session_id || '' };
126
+ atomic_json(record_path, saved);
127
+ return { project_id, bind: session_id => atomic_json(record_path, { ...saved, session_id }) };
128
+ }
129
+
130
+ /**
131
+ * Delete the agy projects this room created after the room itself is deleted; the user's own projects are never touched.
132
+ * @returns {number} How many project records were cleared.
133
+ */
134
+ export function remove_agy_projects(directory, user_home = homedir()) {
135
+ if (!existsSync(directory)) return 0;
136
+ const records = readdirSync(directory).filter(name => /^agy-project-[a-f0-9]{64}\.json$/.test(name));
137
+ for (const file of records) {
138
+ const { project_id } = read_json(join(directory, file));
139
+ const path = join(user_home, '.gemini', 'config', 'projects', `${project_id}.json`);
140
+ if (/^[a-f0-9-]{36}$/.test(project_id || '') && read_json(path).id === project_id) rmSync(path);
141
+ rmSync(join(directory, file));
142
+ }
143
+ return records.length;
144
+ }
145
+
146
+ /** Native agy arguments; an expert the owner chose to host keeps its own conversation's project and permissions. */
147
+ function invocation(config, binding, task, user_home) {
148
+ const project = config.session_id ? null : prepare_agy_project(config, binding, task, user_home);
149
+ const args = ['--output-format', 'stream-json', '--print-timeout', '0', '--add-dir', task.directory,
150
+ '--add-dir', task.draft_directory, ...task.skill_directories.flatMap(directory => ['--add-dir', directory])];
151
+ // Headless agy refuses `--mode auto` ("not available for this account", 1.4.10–1.4.11) though its interactive window
152
+ // offers it; 全放开 uses the flag its own denial message suggests on top of the open project grants.
153
+ args.push('--mode', 'accept-edits', ...(config.project_access === 'full' ? ['--dangerously-skip-permissions'] : []),
154
+ ...(project ? ['--project', project.project_id] : []));
155
+ if (binding.session_id) args.push('--conversation', binding.session_id);
156
+ if (config.model) args.push('--model', config.model);
157
+ if (config.effort) args.push('--effort', config.effort);
158
+ args.push('-p', task.prompt);
159
+ return { args, ...(project ? { on_session: project.bind } : {}) };
160
+ }
161
+
162
+ /** Consume only documented lifecycle events, and remember refused operations so the page can name and allow them. */
163
+ function lifecycle(state, event, task, bind) {
164
+ if (event.event === 'init') bind(event.conversation_id);
165
+ const step = event.step_update;
166
+ // Headless agy ends its turn when an operation needs approval; remember which ones so the page can name and allow them.
167
+ const denied = step?.state === 'ERROR' ? agy_denial(step) : null;
168
+ if (denied) {
169
+ if (!state.denials.some(item => item.detail === denied.detail) && state.denials.length < MAX_DENIALS) state.denials.push(denied);
170
+ state.denial = `${denied.tool === 'write_file' ? '写入' : '运行'}了未授权的${denied.tool === 'write_file' ? '文件' : '命令'}「${denied.detail.slice(0, 120)}」,无人值守模式下无法确认权限而被自动拒绝,随后会话结束`;
171
+ }
172
+ if (event.event === 'result') {
173
+ const result = event.result || {};
174
+ if (result.conversation_id) bind(result.conversation_id);
175
+ state.completed = result.status === 'SUCCESS';
176
+ // A refused operation still ends with SUCCESS and an empty response; name it even when no failed step was seen.
177
+ if (!state.denial && Array.isArray(result.denied_actions) && result.denied_actions.length) {
178
+ state.denial = `的${result.denied_actions.map(item => item.display_name || item.action).join('、')}操作未获授权,无人值守模式下被自动拒绝,随后会话结束`;
179
+ }
180
+ // agy reports `error` as a plain string (1.4.x), older builds as { message }.
181
+ const reason = typeof result.error === 'string' ? result.error : result.error?.message;
182
+ if (!state.completed) state.error = `Antigravity CLI ${result.status || '缺少结果状态'}:${reason || result.response || '执行未成功'}`;
183
+ // Failed before opening a conversation (sign-in, eligibility check): no turn ran, so delivering again repeats nothing.
184
+ if (!state.completed && !state.initialized && !result.conversation_id && !result.num_turns) state.not_started = true;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * What agy's stream events say the agent is doing and which tool it starts. agy streams a response in deltas, so its
190
+ * text is collected per step in `responses` and returned as a note when the step ends.
191
+ */
192
+ const events = {
193
+ phase(event) {
194
+ if (event.event === 'init') return 'thinking';
195
+ const step = event.step_update;
196
+ if (!step) return '';
197
+ if (step.step_type === 'agent_response' && step.state === 'ACTIVE') return 'writing';
198
+ if (step.step_type !== 'tool' || ['DONE', 'ERROR'].includes(step.state)) return 'thinking';
199
+ return step.tool_name === 'run_command' ? 'command' : EDIT_TOOLS.test(step.tool_name || '') ? 'editing' : 'tool';
200
+ },
201
+ tool(event) {
202
+ const step = event.step_update;
203
+ return step?.step_type === 'tool' && !['DONE', 'ERROR'].includes(step.state) ? String(step.tool_name || '') : '';
204
+ },
205
+ note(event, responses = new Map()) {
206
+ const step = event.step_update;
207
+ if (step?.step_type !== 'agent_response') return '';
208
+ responses.set(step.step_index, (responses.get(step.step_index) || '') + (step.text_delta || ''));
209
+ if (step.state === 'ACTIVE') return '';
210
+ const text = responses.get(step.step_index).trim();
211
+ responses.delete(step.step_index);
212
+ return text;
213
+ },
214
+ };
215
+
216
+ /** Where agy keeps one conversation database, or '' when this machine has none by that ID. */
217
+ export function session_path(session_id, env = process.env, home = homedir()) {
218
+ const file = join(home, '.gemini', 'antigravity-cli', 'conversations', `${session_id}.db`);
219
+ return existsSync(file) ? file : '';
220
+ }
221
+
222
+ /** Whether agy stored a conversation by that ID on this machine. */
223
+ export function session_exists(session_id, { env = process.env, home = homedir() } = {}) {
224
+ return Boolean(session_id && session_path(session_id, env, home));
225
+ }
226
+
227
+ /** Copy the conversation database under a new ID, without the steps from the first marked one on. */
228
+ function branch(profile, session_id, markers, { always, home }) {
229
+ const file = session_path(session_id, process.env, home);
230
+ if (!file) throw new Error(`本机找不到 agy 会话 ${session_id}`);
231
+ const encoded = markers.map(marker => Buffer.from(marker, 'utf8'));
232
+ const source = new DatabaseSync(file, { readOnly: true });
233
+ const target = join(file, '..', `${randomUUID()}.db`);
234
+ let cut;
235
+ try {
236
+ // The prompt is stored inside the step's protobuf payload, as UTF-8 text.
237
+ cut = source.prepare('SELECT idx,step_payload FROM steps ORDER BY idx').all()
238
+ .find(row => row.step_payload && encoded.some(marker => Buffer.from(row.step_payload).includes(marker)))?.idx;
239
+ if (cut === undefined && !always) return null;
240
+ if (cut === 0) return { session_id: '' };
241
+ source.exec(`VACUUM INTO '${target.replaceAll("'", "''")}'`);
242
+ } finally { source.close(); }
243
+ const id = target.slice(-39, -3);
244
+ const copy = new DatabaseSync(target);
245
+ try {
246
+ if (cut !== undefined) copy.prepare('DELETE FROM steps WHERE idx>=?').run(cut);
247
+ copy.prepare('UPDATE trajectory_meta SET cascade_id=?').run(id);
248
+ } finally { copy.close(); }
249
+ return { session_id: id };
250
+ }
251
+
252
+ /** The model string after agy's model tag in one protobuf blob. */
253
+ function agy_model(blob) {
254
+ const bytes = Buffer.from(blob);
255
+ for (let at = bytes.indexOf(Buffer.from(AGY_MODEL_TAG)); at >= 0; at = bytes.indexOf(Buffer.from(AGY_MODEL_TAG), at + 1)) {
256
+ const length = bytes[at + 2], text = bytes.subarray(at + 3, at + 3 + length).toString('latin1');
257
+ if (length && text.length === length && MODEL_PATTERN.test(text)) return text;
258
+ }
259
+ return '';
260
+ }
261
+
262
+ /** The model a conversation last ran with, from its database; agy names the effort inside the model ID (gemini-…-medium). */
263
+ export function session_settings(session_id, { env = process.env, home = homedir() } = {}) {
264
+ return database_settings(session_path(session_id, env, home));
265
+ }
266
+
267
+ /** The model a conversation database last ran with; agy and the Antigravity App store it alike. */
268
+ export function database_settings(file) {
269
+ if (!file) return null;
270
+ const db = new DatabaseSync(file, { readOnly: true });
271
+ try {
272
+ for (const { data } of db.prepare('SELECT data FROM gen_metadata ORDER BY idx DESC LIMIT 5').all()) {
273
+ const model = data ? agy_model(data) : '';
274
+ if (model) return { model, effort: '' };
275
+ }
276
+ } finally { db.close(); }
277
+ return null;
278
+ }
279
+
280
+ /** agy's native slash commands answer without an agent turn: /effort lists levels, /model names the current model. */
281
+ function slash_command(profile, command, options) {
282
+ const args = ['-p', `/${command}`, '--output-format', 'json', ...(command === 'effort' && profile.model ? ['--model', profile.model] : [])];
283
+ return query_catalog(profile, args, (send, finish) => ({ start: () => {}, line: line => {
284
+ const reply = json_line(line), data = reply?.command?.data;
285
+ if (reply?.status !== 'SUCCESS' || reply.command?.name !== command || !data) return;
286
+ if (command === 'effort' && Array.isArray(data.available)) finish(null, data.adjustable ? data.available.filter(level => typeof level === 'string') : []);
287
+ if (command === 'model') finish(null, { model: MODEL_PATTERN.test(data.id || '') ? data.id : '', effort: typeof data.effort === 'string' ? data.effort : '' });
288
+ } }), options);
289
+ }
290
+
291
+ /** `agy models` prints tab-separated ids and labels. */
292
+ function models(profile, options) {
293
+ return query_catalog(profile, ['models'], () => {
294
+ const list = [];
295
+ return { start: () => {}, complete: () => list, line: line => {
296
+ const fields = line.trim().split(/\t+| {2,}/);
297
+ if (fields.length >= 2) list.push(model_entry(fields[0], fields.slice(1).join(' ')));
298
+ } };
299
+ }, options);
300
+ }
301
+
302
+ /** The effort levels agy offers for the selected model. */
303
+ function effort(profile, options) {
304
+ if (!isAbsolute(profile.executable || '') || (profile.model && !MODEL_PATTERN.test(profile.model))) return Promise.reject(new Error('无法读取所选模型的思考档位'));
305
+ return slash_command(profile, 'effort', options);
306
+ }
307
+
308
+ /** What agy runs with when the card leaves model or effort empty, from its native /model. */
309
+ async function defaults(profile, options = {}) {
310
+ if (!isAbsolute(profile.executable || '')) return { model: '', effort: '' };
311
+ try { return await slash_command(profile, 'model', options); }
312
+ catch { return { model: '', effort: '' }; }
313
+ }
314
+
315
+ /** agy's behaviour; its static facts are in profiles.mjs. */
316
+ export const antigravity_cli = {
317
+ id: 'antigravity_cli', catalog_source: 'antigravity-cli',
318
+ // A deleted room's private agy projects are removed with it.
319
+ cleanup: remove_agy_projects,
320
+ deliver: (config, binding, task, spawn_process, user_home) => cli_delivery(antigravity_cli, config, binding, task, spawn_process, user_home),
321
+ invocation, lifecycle, events,
322
+ session: { path: session_path, exists: session_exists, owns: session_exists, branch, settings: session_settings },
323
+ models: { list: models, effort, defaults },
324
+ };