ai_co_discuss 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/backend/free_room.py +2410 -2274
- package/backend/schema.sql +27 -0
- package/backend/server.py +21 -11
- package/docs/architecture.md +2 -1
- package/docs/deployment.md +10 -0
- package/docs/en/deployment.md +10 -0
- package/docs/en/workflow.md +1 -1
- package/docs/runner.md +1 -1
- package/docs/workflow.md +2 -2
- package/package.json +3 -2
- package/runner/adapters/antigravity_app.mjs +91 -0
- package/runner/adapters/antigravity_cli.mjs +320 -0
- package/runner/adapters/antigravity_sidecar.mjs +50 -0
- package/runner/{api_participant.mjs → adapters/api.mjs} +19 -5
- package/runner/adapters/claude.mjs +313 -0
- package/runner/adapters/cli.mjs +126 -0
- package/runner/adapters/codex_app.mjs +70 -0
- package/runner/adapters/codex_cli.mjs +286 -0
- package/runner/adapters/common.mjs +196 -0
- package/runner/adapters/index.mjs +143 -0
- package/runner/adapters/profiles.mjs +116 -0
- package/runner/admin_account.mjs +1 -1
- package/runner/discussion_launch.mjs +69 -79
- package/runner/discussion_local.mjs +1 -1
- package/runner/discussion_runner.mjs +14 -13
- package/runner/discussion_service.mjs +3 -3
- package/runner/discussion_setup.mjs +1 -1
- package/runner/discussion_sidecar.mjs +5 -50
- package/runner/discussion_skills.mjs +1 -1
- package/runner/execution_progress.mjs +99 -0
- package/runner/expert_prompts.mjs +19 -29
- package/runner/files.mjs +10 -0
- package/runner/free_runner.mjs +136 -30
- package/runner/remote_launch.mjs +10 -5
- package/runner/scheduler.mjs +124 -32
- package/runner/session_driver.mjs +5 -3
- package/web/app.js +116 -23
- package/web/backup.js +6 -6
- package/web/index.html +2 -0
- package/web/modern.css +1 -1
- package/web/modern.js +1699 -1353
- package/runner/discussion_adapters.mjs +0 -959
- package/runner/discussion_models.mjs +0 -271
- package/runner/session_cut.mjs +0 -167
- /package/runner/{codex_app_client.mjs → adapters/codex_app_client.mjs} +0 -0
|
@@ -1,959 +0,0 @@
|
|
|
1
|
-
/** Actual software entrypoints; never call model service APIs. */
|
|
2
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
5
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
-
import { connect } from 'node:net';
|
|
7
|
-
import { homedir } from 'node:os';
|
|
8
|
-
import { createInterface } from 'node:readline';
|
|
9
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
-
import { CodexAppClient } from './codex_app_client.mjs';
|
|
11
|
-
import { agy_command_rule, runs_commands } from './expert_prompts.mjs';
|
|
12
|
-
import { session_exists } from './session_cut.mjs';
|
|
13
|
-
|
|
14
|
-
const CALL_TIMEOUT_MS = 30_000;
|
|
15
|
-
const CLI_DIAGNOSTIC_CHARS = 1000;
|
|
16
|
-
/** Codex exec may linger after its final event; the finished turn is already saved, so stop it to free the session. */
|
|
17
|
-
const CLI_EXIT_GRACE_MS = 1500;
|
|
18
|
-
// A hung CLI keeps its lease renewed forever when the room sets no execution limit, so cap every process.
|
|
19
|
-
const CLI_MAX_RUN_MS = 2 * 60 * 60_000;
|
|
20
|
-
// A message posted to an open Claude window that has not started by then is reported, not failed.
|
|
21
|
-
const INBOX_CHECK_MS = 90_000;
|
|
22
|
-
const CODEX_PERMISSIONS_MIN_MINOR = 163;
|
|
23
|
-
/** Website subcommands an automatic task may run; service, launch, run, api and setup stay unapproved. */
|
|
24
|
-
export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'review', 'execution', 'health', 'schema'];
|
|
25
|
-
|
|
26
|
-
// Defined with the fixed task text so the expert prompt dialog shows the same rules; re-exported for existing callers.
|
|
27
|
-
export { agy_command_rule, runs_commands };
|
|
28
|
-
|
|
29
|
-
/** Only failures before a software invocation are safe for automatic redelivery. */
|
|
30
|
-
function not_sent(error) { return Object.assign(error, { delivery_status: 'not_sent' }); }
|
|
31
|
-
|
|
32
|
-
/** Persist small private state atomically without logging its contents. */
|
|
33
|
-
export function atomic_json(path, value) {
|
|
34
|
-
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
35
|
-
writeFileSync(temporary, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
36
|
-
renameSync(temporary, path);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Run an executable directly; user text is always an argv value, never shell code. */
|
|
40
|
-
export function run_program(executable, args, options = {}) {
|
|
41
|
-
return new Promise((resolve_result, reject) => {
|
|
42
|
-
let child;
|
|
43
|
-
try { child = spawn(executable, args, { windowsHide: true, shell: false, ...options }); }
|
|
44
|
-
catch (error) { reject(not_sent(error)); return; }
|
|
45
|
-
let spawned = false;
|
|
46
|
-
child.once('spawn', () => { spawned = true; });
|
|
47
|
-
let output = '';
|
|
48
|
-
let error_text = '';
|
|
49
|
-
child.stdout?.on('data', chunk => { output += chunk; });
|
|
50
|
-
child.stderr?.on('data', chunk => { error_text += chunk; });
|
|
51
|
-
child.once('error', error => reject(spawned ? error : not_sent(error)));
|
|
52
|
-
child.once('close', code => code === 0 ? resolve_result(output) : reject(new Error(`软件退出码 ${code}: ${error_text.slice(-500)}`)));
|
|
53
|
-
if (options.input !== undefined) child.stdin.end(options.input);
|
|
54
|
-
else child.stdin?.end();
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Bind returned software identities back to the private credential file. */
|
|
59
|
-
function bind_access(path, session_id) {
|
|
60
|
-
const access = JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
-
atomic_json(path, { ...access, session_id });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Deliver one task to Codex's real App, with the original chat reused afterward. */
|
|
65
|
-
async function codex_delivery(config, binding, task) {
|
|
66
|
-
if (binding.session_id) {
|
|
67
|
-
try { bind_access(task.access_path, binding.session_id); }
|
|
68
|
-
catch (error) { throw not_sent(error); }
|
|
69
|
-
// `codex queue` reaches only a chat the App has loaded; a fork made after a rewind, or a chat after the App restarted,
|
|
70
|
-
// is not loaded until a message is sent through the App itself (tested on App tools, 2026-10).
|
|
71
|
-
if (await codex_unloaded(config, binding.session_id)) {
|
|
72
|
-
const app = new CodexAppClient(config.pipe_path);
|
|
73
|
-
try { await app.open(); await app.send_message(binding.session_id, task.prompt); }
|
|
74
|
-
finally { app.close(); }
|
|
75
|
-
return { session_id: binding.session_id, status: 'accepted' };
|
|
76
|
-
}
|
|
77
|
-
const receipt = await run_program(config.executable, ['queue', '--thread', binding.session_id, '--message', task.prompt], { timeout: CALL_TIMEOUT_MS });
|
|
78
|
-
return { session_id: binding.session_id, status: 'accepted', receipt };
|
|
79
|
-
}
|
|
80
|
-
const app = new CodexAppClient(config.pipe_path);
|
|
81
|
-
try {
|
|
82
|
-
let target;
|
|
83
|
-
try { await app.open(); target = await codex_target(app, config); }
|
|
84
|
-
catch (error) { throw not_sent(error); }
|
|
85
|
-
const result = await app.create_thread(config.source_thread_id, task.prompt, `CoDiscuss · ${task.participant}`, target);
|
|
86
|
-
task.on_binding(result.thread_id);
|
|
87
|
-
bind_access(task.access_path, result.thread_id);
|
|
88
|
-
return { session_id: result.thread_id, status: 'accepted' };
|
|
89
|
-
} finally { app.close(); }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Whether the running App has not loaded a chat; when the App cannot tell, the queue is used as before. */
|
|
93
|
-
async function codex_unloaded(config, thread_id) {
|
|
94
|
-
const app = new CodexAppClient(config.pipe_path);
|
|
95
|
-
try { await app.open(); return await app.thread_status(thread_id) === 'notLoaded'; }
|
|
96
|
-
catch { return false; }
|
|
97
|
-
finally { app.close(); }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Match the real saved project and its directory; a prompt cannot grant file access. */
|
|
101
|
-
export async function codex_target(app, config) {
|
|
102
|
-
if (!config.project_id && !config.project_name && !config.cwd) return { type: 'projectless' };
|
|
103
|
-
const projects = (await app.list_projects(config.source_thread_id)).filter(item => item.hostId === 'local');
|
|
104
|
-
const matching = projects.filter(item => (!config.project_id || item.projectId === config.project_id)
|
|
105
|
-
&& (!config.project_name || item.label === config.project_name)
|
|
106
|
-
&& (!config.cwd || same_path(item.path, config.cwd)));
|
|
107
|
-
if (matching.length !== 1) throw new Error('Codex 项目与 cwd 无法唯一匹配;请先在 App 添加该目录,核对 project_id/project_name/cwd');
|
|
108
|
-
return { type: 'project', projectId: matching[0].projectId, environment: { type: 'local' } };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Compare local directory identities without assuming case sensitivity on Windows. */
|
|
112
|
-
export function same_path(left, right) {
|
|
113
|
-
if (!left || !right) return false;
|
|
114
|
-
const normalize = value => process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value);
|
|
115
|
-
return normalize(left) === normalize(right);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Use one official Claude process per turn and resume the persisted session. */
|
|
119
|
-
/** Explain why a finished Claude print run failed, including errors reported with exit code 0. */
|
|
120
|
-
export function claude_exit_error(code, output) {
|
|
121
|
-
let result = null;
|
|
122
|
-
try { result = JSON.parse(output.trim().split(/\r?\n/).at(-1)); } catch { /* Non-JSON output keeps the exit code check. */ }
|
|
123
|
-
if (result?.is_error) return `Claude 执行出错:${result.result || result.api_error || result.subtype || '未知错误'}`;
|
|
124
|
-
return code === 0 ? '' : `Claude 退出码 ${code}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const DENIAL_FIELD = 300;
|
|
128
|
-
const MAX_DENIALS = 20;
|
|
129
|
-
const EDIT_TOOL_NAMES = /^(Edit|MultiEdit|Write|NotebookEdit)$/;
|
|
130
|
-
|
|
131
|
-
/** The first program a shell command runs, skipping `cd` and `Set-Location` steps, for a prefix rule that also allows a retry. */
|
|
132
|
-
function command_program(command) {
|
|
133
|
-
const steps = String(command).split(/&&|\|\||;|\|/).map(step => step.trim().split(/\s+/)[0]).filter(Boolean);
|
|
134
|
-
return steps.find(step => !/^(cd|Set-Location|sl|pushd)$/i.test(step)) || steps[0] || '';
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* The operations a headless Claude run refused, from the `permission_denials` of its final result line, each with the
|
|
139
|
-
* `--allowedTools` rule that would let the next run do it: a command prefix, an edit of that folder, or the tool itself.
|
|
140
|
-
*/
|
|
141
|
-
export function claude_denials(output) {
|
|
142
|
-
let result = null;
|
|
143
|
-
try { result = JSON.parse(output.trim().split(/\r?\n/).at(-1)); } catch { return []; }
|
|
144
|
-
const seen = new Set();
|
|
145
|
-
return (Array.isArray(result?.permission_denials) ? result.permission_denials : []).map(denial => {
|
|
146
|
-
const tool = String(denial.tool_name || '').slice(0, 40), input = denial.tool_input || {};
|
|
147
|
-
if (['Bash', 'PowerShell'].includes(tool) && input.command) {
|
|
148
|
-
const program = command_program(input.command);
|
|
149
|
-
return { tool, detail: String(input.command), rule: program ? `${tool}(${program} *)` : '' };
|
|
150
|
-
}
|
|
151
|
-
const path = input.file_path || input.notebook_path;
|
|
152
|
-
if (EDIT_TOOL_NAMES.test(tool) && path) return { tool, detail: String(path), rule: `Edit(${claude_rule_path(dirname(path))}/**)` };
|
|
153
|
-
return { tool, detail: JSON.stringify(input), rule: tool };
|
|
154
|
-
}).filter(item => item.tool && !seen.has(item.detail) && seen.add(item.detail))
|
|
155
|
-
.slice(0, MAX_DENIALS).map(item => ({ ...item, detail: item.detail.slice(0, DENIAL_FIELD), rule: item.rule.length <= DENIAL_FIELD ? item.rule : '' }));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* One operation a headless agy run refused, from the failed tool step, with the project grant that would allow it.
|
|
160
|
-
* agy names the check that failed: a sandboxed or unsandboxed command, or a file write.
|
|
161
|
-
*/
|
|
162
|
-
export function agy_denial(step) {
|
|
163
|
-
const message = step.tool_info?.error?.message || '';
|
|
164
|
-
if (!/permission check failed/.test(message)) return null;
|
|
165
|
-
const parameters = step.tool_info.parameters || {};
|
|
166
|
-
// Older agy did not name the check; a refused step with a command line was a command.
|
|
167
|
-
const kind = message.match(/permission check failed for (\w+)/)?.[1] || (parameters.CommandLine ? 'command' : '');
|
|
168
|
-
if (!kind) return null;
|
|
169
|
-
if (kind === 'write_file') {
|
|
170
|
-
const path = String(parameters.Path || parameters.TargetFile || message.match(/write_file\(([^)]+)\)/)?.[1] || '');
|
|
171
|
-
return { tool: 'write_file', detail: path.slice(0, DENIAL_FIELD), rule: path ? `write_file(${resolve(dirname(path)).replaceAll('\\', '/')})` : '' };
|
|
172
|
-
}
|
|
173
|
-
const command = String(parameters.CommandLine || '');
|
|
174
|
-
const rule = `${kind === 'unsandboxed' ? 'unsandboxed_command' : 'command'}(${command})`;
|
|
175
|
-
return { tool: step.tool_name || 'run_command', detail: command.slice(0, DENIAL_FIELD), rule: command && rule.length <= DENIAL_FIELD ? rule : '' };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Drop variables a hosting Claude session injects, so the spawned CLI uses its own login.
|
|
180
|
-
* The host also hides the user's persisted login variables, which are read back on Windows.
|
|
181
|
-
*/
|
|
182
|
-
export function standalone_claude_env(env = process.env, persisted = persisted_claude_env) {
|
|
183
|
-
if (env.CLAUDECODE !== '1') return env;
|
|
184
|
-
const own = Object.entries(env).filter(([key]) =>
|
|
185
|
-
!/^(CLAUDECODE|CLAUDE_PID|CLAUDE_EFFORT)$|^(CLAUDE_CODE_|CLAUDE_AGENT_SDK_|CLAUDE_PREVIEW_)/.test(key));
|
|
186
|
-
return { ...Object.fromEntries(own), ...persisted() };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
let persisted_cache;
|
|
190
|
-
/** Read Machine then User persisted ANTHROPIC_/CLAUDE variables once; values stay in memory only. */
|
|
191
|
-
function persisted_claude_env() {
|
|
192
|
-
if (process.platform !== 'win32') return {};
|
|
193
|
-
if (!persisted_cache) {
|
|
194
|
-
const script = "$r=@{}; foreach($s in 'Machine','User'){ $v=[Environment]::GetEnvironmentVariables($s);"
|
|
195
|
-
+ " foreach($k in $v.Keys){ if($k -match '^(ANTHROPIC_|CLAUDE)'){ $r[$k]=$v[$k] } } }; $r | ConvertTo-Json -Compress";
|
|
196
|
-
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true });
|
|
197
|
-
try { persisted_cache = result.status === 0 && result.stdout.trim() ? JSON.parse(result.stdout) : {}; }
|
|
198
|
-
catch { persisted_cache = {}; }
|
|
199
|
-
}
|
|
200
|
-
return persisted_cache;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Claude permission rules match Windows paths in POSIX form, e.g. C:\a becomes //c/a. */
|
|
204
|
-
export function claude_rule_path(directory) {
|
|
205
|
-
const path = resolve(directory).replaceAll('\\', '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`);
|
|
206
|
-
return `/${path}`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** The participant's discussion project directory; workdir is the legacy field name. */
|
|
210
|
-
function project_directory(config) { return config.cwd || config.workdir || ''; }
|
|
211
|
-
|
|
212
|
-
/** Keep the requested project as cwd; without a project, use the separate answer directory. */
|
|
213
|
-
export function launch_directory(config, task) {
|
|
214
|
-
return project_directory(config) || task.draft_directory;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Allow only the discussion website commands, without shell separators or expressions. */
|
|
218
|
-
export function agy_rule() {
|
|
219
|
-
const safe = '[^;&|<>$`()\\r\\n]';
|
|
220
|
-
return `command(regex:^codiscuss(\\.cmd)?( --site ${safe.replace('[^', '[^ ')}+)? (${TASK_SUBCOMMANDS.join('|')})( ${safe}*)?$)`;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Read optional native settings; malformed settings must not silently disable a permission check. */
|
|
224
|
-
function read_json(path) { return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {}; }
|
|
225
|
-
|
|
226
|
-
/** Native headless review blocks unapproved shell commands; only a global grant of every command could bypass the project rules. */
|
|
227
|
-
function check_agy_shell(user_home) {
|
|
228
|
-
const cli = read_json(join(user_home, '.gemini', 'antigravity-cli', 'settings.json'));
|
|
229
|
-
const shared = read_json(join(user_home, '.gemini', 'config', 'config.json'));
|
|
230
|
-
const grants = [...(cli.permissions?.allow || []), ...(shared.userSettings?.globalPermissionGrants?.allow || [])];
|
|
231
|
-
if (grants.some(rule => rule === '*' || /^(?:command|unsandboxed|unsandboxed_command)\((?:\*|regex:\.\*)\)$/.test(rule))) {
|
|
232
|
-
throw new Error('agy 全局设置放行了所有命令,会绕过讨论项目的权限;请移除全局 command(*) / unsandboxed(*) 放行,具体命令的放行不受影响(本工具旧版写入的规则可运行 codiscuss setup 清理),未交付讨论任务');
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Test directory containment on the current platform, including Windows drive boundaries. */
|
|
237
|
-
function within_directory(parent, child) {
|
|
238
|
-
const path = relative(resolve(parent), resolve(child));
|
|
239
|
-
return !path || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`));
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Resolve only the user's selected folders; do not inherit another project's grants or settings. */
|
|
243
|
-
function agy_resources(config, task, root) {
|
|
244
|
-
if (config.project_id || config.project_name) return antigravity_project(config, root).projectResources?.resources || [];
|
|
245
|
-
return [{ folder: { folderUri: pathToFileURL(launch_directory(config, task)).href } }];
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** Build native deny rules for protected directories, with separate writable answer storage. */
|
|
249
|
-
function agy_project_grants(config, task, resources, user_home) {
|
|
250
|
-
const folders = resources.map(resource => resource.gitFolder?.folderUri || resource.folder?.folderUri)
|
|
251
|
-
.filter(uri => uri?.startsWith('file:')).map(uri => fileURLToPath(uri));
|
|
252
|
-
const projects = [...new Set([project_directory(config), ...folders].filter(directory => directory && !same_path(directory, task.draft_directory)))];
|
|
253
|
-
const protected_paths = [task.directory, task.skills_directory, join(user_home, '.gemini'),
|
|
254
|
-
...(config.project_access === 'read' ? projects : [])];
|
|
255
|
-
if (protected_paths.some(directory => within_directory(directory, task.draft_directory))) {
|
|
256
|
-
throw new Error('agy 草稿目录不能位于只读项目、运行目录、技能或 .gemini 配置目录中,未交付讨论任务');
|
|
257
|
-
}
|
|
258
|
-
const write_rule = directory => `write_file(${resolve(directory).replaceAll('\\', '/')})`;
|
|
259
|
-
// 全放开 also allows commands outside agy's sandbox and MCP tools; deny rules are still written, but bypass may override them.
|
|
260
|
-
const full = config.project_access === 'full';
|
|
261
|
-
return { allow: [agy_rule(), write_rule(task.draft_directory),
|
|
262
|
-
...(config.project_access === 'read' ? [] : [...projects.map(write_rule), 'command(*)']), ...(full ? ['unsandboxed_command(*)'] : []),
|
|
263
|
-
// Grants the owner allowed from a refused operation on the page.
|
|
264
|
-
...(config.allowed_tools || [])],
|
|
265
|
-
deny: [...new Set(protected_paths.map(write_rule)), ...(full ? [] : ['mcp(*)'])] };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Run agy only inside a private native project this tool created; external conversations keep their own project, so they are refused. */
|
|
269
|
-
export function prepare_agy_project(config, binding, task, user_home = homedir()) {
|
|
270
|
-
const key = createHash('sha256').update(task.participant).digest('hex');
|
|
271
|
-
const record_path = join(task.directory, `agy-project-${key}.json`);
|
|
272
|
-
const record = read_json(record_path);
|
|
273
|
-
if (binding.session_id && !record.project_id) {
|
|
274
|
-
throw new Error('agy 只在本工具新建的讨论会话中自动执行;该会话不是本工具创建的,无法套用讨论项目权限,未交付讨论任务');
|
|
275
|
-
}
|
|
276
|
-
if (record.session_id && record.session_id !== binding.session_id) throw new Error('agy 项目记录与会话绑定不符,未交付讨论任务');
|
|
277
|
-
check_agy_shell(user_home);
|
|
278
|
-
const root = join(user_home, '.gemini', 'config');
|
|
279
|
-
const resources = agy_resources(config, task, root);
|
|
280
|
-
const grants = agy_project_grants(config, task, resources, user_home);
|
|
281
|
-
const project_id = record.project_id || randomUUID();
|
|
282
|
-
if (!/^[a-f0-9-]{36}$/.test(project_id)) throw new Error('agy 项目记录无效,未交付讨论任务');
|
|
283
|
-
const path = join(root, 'projects', `${project_id}.json`);
|
|
284
|
-
const current = read_json(path);
|
|
285
|
-
if (record.project_id && current.id !== project_id) throw new Error('agy 讨论项目已丢失或不匹配,未交付讨论任务');
|
|
286
|
-
mkdirSync(join(root, 'projects'), { recursive: true });
|
|
287
|
-
const name = ['CoDiscuss', task.room_title, task.participant].filter(Boolean).join(' · ');
|
|
288
|
-
atomic_json(path, { ...current, id: project_id, name,
|
|
289
|
-
projectResources: { resources }, settings: {}, permissionGrants: { permissionGrants: grants, v2Migrated: true } });
|
|
290
|
-
const saved = { project_id, session_id: record.session_id || '' };
|
|
291
|
-
atomic_json(record_path, saved);
|
|
292
|
-
return { project_id, bind: session_id => atomic_json(record_path, { ...saved, session_id }) };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Delete the agy projects this room created after the room itself is deleted; the user's own projects are never touched.
|
|
297
|
-
* @returns {number} How many project records were cleared.
|
|
298
|
-
*/
|
|
299
|
-
export function remove_agy_projects(directory, user_home = homedir()) {
|
|
300
|
-
if (!existsSync(directory)) return 0;
|
|
301
|
-
const records = readdirSync(directory).filter(name => /^agy-project-[a-f0-9]{64}\.json$/.test(name));
|
|
302
|
-
for (const file of records) {
|
|
303
|
-
const { project_id } = read_json(join(directory, file));
|
|
304
|
-
const path = join(user_home, '.gemini', 'config', 'projects', `${project_id}.json`);
|
|
305
|
-
if (/^[a-f0-9-]{36}$/.test(project_id || '') && read_json(path).id === project_id) rmSync(path);
|
|
306
|
-
rmSync(join(directory, file));
|
|
307
|
-
}
|
|
308
|
-
return records.length;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Allow the listed codiscuss website subcommands and draft writes for this run; a writable member also edits its project
|
|
313
|
-
* and runs any command. User rules are added after. Claude Code on Windows may run commands through its PowerShell tool
|
|
314
|
-
* instead of Bash, so both are listed.
|
|
315
|
-
*/
|
|
316
|
-
export function claude_permissions(config, task) {
|
|
317
|
-
// A headless run cannot ask for approval: 自动 and a taken-over manual member use auto mode, as the original window
|
|
318
|
-
// does, and 全放开 skips every check; the website commands and drafts stay pre-approved either way.
|
|
319
|
-
if (['own', 'auto', 'full'].includes(config.project_access)) {
|
|
320
|
-
const commands = TASK_SUBCOMMANDS.flatMap(command => [`Bash(codiscuss ${command} *)`, `PowerShell(codiscuss ${command} *)`]);
|
|
321
|
-
return ['--permission-mode', config.project_access === 'full' ? 'bypassPermissions' : 'auto', '--add-dir', task.directory, task.draft_directory, task.skills_directory, '--allowedTools',
|
|
322
|
-
...commands, `Edit(${claude_rule_path(task.draft_directory)}/**)`, ...(config.allowed_tools || [])];
|
|
323
|
-
}
|
|
324
|
-
const commands = runs_commands(config) ? ['Bash', 'PowerShell']
|
|
325
|
-
: TASK_SUBCOMMANDS.flatMap(command => [`Bash(codiscuss ${command} *)`, `PowerShell(codiscuss ${command} *)`]);
|
|
326
|
-
const writable = [task.draft_directory, ...(runs_commands(config) ? [project_directory(config)] : [])];
|
|
327
|
-
return ['--add-dir', task.directory, task.draft_directory, task.skills_directory, '--allowedTools',
|
|
328
|
-
...commands, ...writable.map(directory => `Edit(${claude_rule_path(directory)}/**)`), ...(config.allowed_tools || [])];
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Windows leaves a killed process's descendants running, so a stopped Claude turn kept its tool commands working.
|
|
333
|
-
* End the whole tree there; elsewhere, or when taskkill cannot run, use the plain kill.
|
|
334
|
-
*/
|
|
335
|
-
export function kill_process_tree(child, plain_kill, platform = process.platform, run = spawnSync) {
|
|
336
|
-
if (platform === 'win32' && child.pid && child.exitCode === null && child.signalCode === null) {
|
|
337
|
-
try {
|
|
338
|
-
if (run('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: CALL_TIMEOUT_MS }).status === 0) return true;
|
|
339
|
-
} catch { /* Fall through to the plain kill. */ }
|
|
340
|
-
}
|
|
341
|
-
return plain_kill();
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** Make a delivered process's kill end its whole tree on Windows, so a stopped turn leaves no tool commands running. */
|
|
345
|
-
function tree_killable(child) {
|
|
346
|
-
if (typeof child.kill === 'function') {
|
|
347
|
-
const plain_kill = child.kill.bind(child);
|
|
348
|
-
child.kill = () => kill_process_tree(child, plain_kill);
|
|
349
|
-
}
|
|
350
|
-
return child;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const EDIT_TOOLS = /^(Edit|MultiEdit|Write|NotebookEdit|write_file|write_to_file|replace_file_content|multi_replace_file_content)$/;
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* What a streamed CLI event says the agent is doing: thinking, command, tool, editing or writing.
|
|
357
|
-
* Returns '' when the event says nothing about it; the page only shows the latest phase.
|
|
358
|
-
*/
|
|
359
|
-
export function activity_phase(adapter, event) {
|
|
360
|
-
if (adapter === 'claude') {
|
|
361
|
-
if (event.type === 'system' || event.type === 'user') return 'thinking';
|
|
362
|
-
const block = event.type === 'assistant' ? event.message?.content?.at(-1) : null;
|
|
363
|
-
if (block?.type === 'tool_use') return /^(Bash|PowerShell)$/.test(block.name) ? 'command' : EDIT_TOOLS.test(block.name) ? 'editing' : 'tool';
|
|
364
|
-
return block?.type === 'text' ? 'writing' : block?.type === 'thinking' ? 'thinking' : '';
|
|
365
|
-
}
|
|
366
|
-
if (adapter === 'codex_cli') {
|
|
367
|
-
if (event.type === 'turn.started') return 'thinking';
|
|
368
|
-
const kind = event.item?.type;
|
|
369
|
-
if (event.type === 'item.started') {
|
|
370
|
-
return { command_execution: 'command', file_change: 'editing', mcp_tool_call: 'tool', web_search: 'tool', reasoning: 'thinking' }[kind] || '';
|
|
371
|
-
}
|
|
372
|
-
if (event.type === 'item.completed') return kind === 'agent_message' ? 'writing' : 'thinking';
|
|
373
|
-
return '';
|
|
374
|
-
}
|
|
375
|
-
if (event.event === 'init') return 'thinking';
|
|
376
|
-
const step = event.step_update;
|
|
377
|
-
if (!step) return '';
|
|
378
|
-
if (step.step_type === 'agent_response' && step.state === 'ACTIVE') return 'writing';
|
|
379
|
-
if (step.step_type !== 'tool' || ['DONE', 'ERROR'].includes(step.state)) return 'thinking';
|
|
380
|
-
return step.tool_name === 'run_command' ? 'command' : EDIT_TOOLS.test(step.tool_name || '') ? 'editing' : 'tool';
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** The tool an event starts, by name only: never its arguments or command text. Returns '' for any other event. */
|
|
384
|
-
export function activity_tool(adapter, event) {
|
|
385
|
-
if (adapter === 'claude') {
|
|
386
|
-
const block = event.type === 'assistant' ? event.message?.content?.at(-1) : null;
|
|
387
|
-
return block?.type === 'tool_use' ? String(block.name || '') : '';
|
|
388
|
-
}
|
|
389
|
-
if (adapter === 'codex_cli') {
|
|
390
|
-
if (event.type !== 'item.started') return '';
|
|
391
|
-
return event.item?.type === 'mcp_tool_call' ? String(event.item.tool || '') : event.item?.type === 'web_search' ? 'web_search' : '';
|
|
392
|
-
}
|
|
393
|
-
const step = event.step_update;
|
|
394
|
-
return step?.step_type === 'tool' && !['DONE', 'ERROR'].includes(step.state) ? String(step.tool_name || '') : '';
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** Segments one execution may record, how many one report carries, and a tool name's length; the website enforces the same. */
|
|
398
|
-
export const MAX_EXECUTION_SEGMENTS = 400;
|
|
399
|
-
export const MAX_SEGMENTS_PER_REPORT = 40;
|
|
400
|
-
export const MAX_SEGMENT_TOOL = 60;
|
|
401
|
-
/** Progress notes kept per execution, each cut to this length, and how many one report carries; the website enforces the same. */
|
|
402
|
-
export const MAX_EXECUTION_NOTES = 50;
|
|
403
|
-
export const MAX_NOTE_LENGTH = 500;
|
|
404
|
-
export const MAX_NOTES_PER_REPORT = 10;
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* What each execution's software showed while it worked, waiting to be reported: the phases it went through, as
|
|
408
|
-
* millisecond offsets from its delivery on this machine's clock, and its progress notes. Consecutive events of the same
|
|
409
|
-
* phase and tool extend one segment. The open segment is reported as far as it has run and again once it runs further;
|
|
410
|
-
* numbering makes a resent report harmless.
|
|
411
|
-
*/
|
|
412
|
-
export class SegmentLog {
|
|
413
|
-
constructor(now = Date.now) {
|
|
414
|
-
this.now = now;
|
|
415
|
-
this.executions = new Map();
|
|
416
|
-
}
|
|
417
|
-
/** Begin timing one execution at its delivery; a repeated start keeps the first origin. */
|
|
418
|
-
start(id) {
|
|
419
|
-
if (!this.executions.has(id)) this.executions.set(id, { origin: this.now(), last: 0, open: null, closed: [], next_seq: 0,
|
|
420
|
-
notes: [], next_note: 0, open_sent: null, reported: 0 });
|
|
421
|
-
}
|
|
422
|
-
/** Queue one progress note; notes past the cap are dropped. Cut by UTF-16 units like the website's check, never mid-pair. */
|
|
423
|
-
note(id, text) {
|
|
424
|
-
const log = this.executions.get(id);
|
|
425
|
-
if (!log || log.next_note >= MAX_EXECUTION_NOTES) return;
|
|
426
|
-
log.next_note += 1;
|
|
427
|
-
log.notes.push({ seq: log.next_note, text: text.slice(0, MAX_NOTE_LENGTH).replace(/[\uD800-\uDBFF]$/, '') });
|
|
428
|
-
}
|
|
429
|
-
/** The software produced output now; a phase, when the output names one, starts a new segment unless it continues the open one. */
|
|
430
|
-
mark(id, phase = '', tool = '') {
|
|
431
|
-
const log = this.executions.get(id);
|
|
432
|
-
if (!log) return;
|
|
433
|
-
log.last = this.now() - log.origin;
|
|
434
|
-
if (log.open) log.open.end_ms = log.last;
|
|
435
|
-
if (!phase) return;
|
|
436
|
-
tool = tool.slice(0, MAX_SEGMENT_TOOL).replace(/[\uD800-\uDBFF]$/, '');
|
|
437
|
-
if (log.open?.phase === phase && log.open.tool === tool) return;
|
|
438
|
-
if (log.open) log.closed.push(log.open);
|
|
439
|
-
log.open = null;
|
|
440
|
-
if (log.next_seq >= MAX_EXECUTION_SEGMENTS) return;
|
|
441
|
-
log.next_seq += 1;
|
|
442
|
-
log.open = { seq: log.next_seq, phase, tool, start_ms: log.last, end_ms: log.last };
|
|
443
|
-
}
|
|
444
|
-
/** Whether something new waits to be reported (an open segment only once it ran further) and the last report is `interval_ms` old. */
|
|
445
|
-
due(id, interval_ms) {
|
|
446
|
-
const log = this.executions.get(id);
|
|
447
|
-
const open_moved = log?.open && !(log.open_sent?.seq === log.open.seq && log.open_sent.end_ms === log.open.end_ms);
|
|
448
|
-
return Boolean(log && (log.closed.length || log.notes.length || open_moved) && this.now() - log.reported >= interval_ms);
|
|
449
|
-
}
|
|
450
|
-
/** The next report: closed segments first, then the open one as it stands, and the queued notes. `final` closes the open segment. */
|
|
451
|
-
batch(id, final = false) {
|
|
452
|
-
const log = this.executions.get(id);
|
|
453
|
-
if (!log) return { segments: [], notes: [] };
|
|
454
|
-
if (final && log.open) { log.closed.push(log.open); log.open = null; }
|
|
455
|
-
const segments = log.closed.slice(0, MAX_SEGMENTS_PER_REPORT);
|
|
456
|
-
if (log.open && segments.length < MAX_SEGMENTS_PER_REPORT) segments.push({ ...log.open });
|
|
457
|
-
return { segments, notes: log.notes.slice(0, MAX_NOTES_PER_REPORT) };
|
|
458
|
-
}
|
|
459
|
-
/** Forget what a successful report carried in full; a segment that ran on since it was sent stays. */
|
|
460
|
-
sent(id, batch) {
|
|
461
|
-
const log = this.executions.get(id);
|
|
462
|
-
if (!log) return;
|
|
463
|
-
const ends = new Map(batch.segments.map(segment => [segment.seq, segment.end_ms]));
|
|
464
|
-
log.closed = log.closed.filter(segment => !(ends.has(segment.seq) && segment.end_ms <= ends.get(segment.seq)));
|
|
465
|
-
const notes = new Set(batch.notes.map(note => note.seq));
|
|
466
|
-
log.notes = log.notes.filter(note => !notes.has(note.seq));
|
|
467
|
-
const open = log.open && batch.segments.find(segment => segment.seq === log.open.seq);
|
|
468
|
-
if (open) log.open_sent = { seq: open.seq, end_ms: open.end_ms };
|
|
469
|
-
log.reported = this.now();
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* Send the execution's segments and notes with `send({segments, notes})`. `final` closes the open segment, sends
|
|
473
|
-
* everything left and forgets the execution. A refused report drops them and any other failure keeps them for the
|
|
474
|
-
* next try; it never throws, so a progress report cannot fail the execution it describes.
|
|
475
|
-
*/
|
|
476
|
-
async report(id, final, send) {
|
|
477
|
-
do {
|
|
478
|
-
const batch = this.batch(id, final);
|
|
479
|
-
if (!batch.segments.length && !batch.notes.length) break;
|
|
480
|
-
try { await send(batch); }
|
|
481
|
-
catch (error) {
|
|
482
|
-
if (/HTTP 4\d\d/.test(error.message)) this.drop(id);
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
this.sent(id, batch);
|
|
486
|
-
} while (final && (this.executions.get(id)?.closed.length || this.executions.get(id)?.notes.length));
|
|
487
|
-
if (final) this.drop(id);
|
|
488
|
-
}
|
|
489
|
-
drop(id) { this.executions.delete(id); }
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* Text the agent wrote to the user in one streamed event, such as a progress remark between tool calls.
|
|
494
|
-
* agy streams a response in deltas, so its text is collected per step in `responses` and returned when the step ends.
|
|
495
|
-
*/
|
|
496
|
-
export function progress_note(adapter, event, responses = new Map()) {
|
|
497
|
-
if (adapter === 'claude') {
|
|
498
|
-
if (event.type !== 'assistant') return '';
|
|
499
|
-
return (event.message?.content || []).filter(block => block.type === 'text').map(block => block.text).join('\n').trim();
|
|
500
|
-
}
|
|
501
|
-
if (adapter === 'codex_cli') return event.type === 'item.completed' && event.item?.type === 'agent_message' ? String(event.item.text || '').trim() : '';
|
|
502
|
-
const step = event.step_update;
|
|
503
|
-
if (step?.step_type !== 'agent_response') return '';
|
|
504
|
-
responses.set(step.step_index, (responses.get(step.step_index) || '') + (step.text_delta || ''));
|
|
505
|
-
if (step.state === 'ACTIVE') return '';
|
|
506
|
-
const text = responses.get(step.step_index).trim();
|
|
507
|
-
responses.delete(step.step_index);
|
|
508
|
-
return text;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/** Report the phase and any progress note of each complete JSON line; malformed lines are left to the caller's own parsing. */
|
|
512
|
-
function watch_activity(adapter, stream, task) {
|
|
513
|
-
if (!task.on_activity && !task.on_note) return;
|
|
514
|
-
createInterface({ input: stream, crlfDelay: Infinity }).on('line', line => {
|
|
515
|
-
let event;
|
|
516
|
-
try { event = JSON.parse(line); } catch { return; }
|
|
517
|
-
const phase = activity_phase(adapter, event);
|
|
518
|
-
if (phase) task.on_activity?.(phase, activity_tool(adapter, event));
|
|
519
|
-
const note = progress_note(adapter, event);
|
|
520
|
-
if (note) task.on_note?.(note);
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* The open Claude Code window holding one session, with its cross-session inbox, or null when no live session holds it.
|
|
526
|
-
* Read from the registry each running session writes; `inbox` is null on a version without one. Not a public interface:
|
|
527
|
-
* the peer token and message line are what Claude's own sessions use (tested on 2.1.312).
|
|
528
|
-
*/
|
|
529
|
-
export function claude_window(session_id, env = process.env) {
|
|
530
|
-
const directory = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'sessions');
|
|
531
|
-
if (!session_id || !existsSync(directory)) return null;
|
|
532
|
-
for (const name of readdirSync(directory).filter(item => /^\d+\.json$/.test(item))) {
|
|
533
|
-
let record;
|
|
534
|
-
try { record = JSON.parse(readFileSync(join(directory, name), 'utf8')); } catch { continue; }
|
|
535
|
-
if (record.sessionId !== session_id) continue;
|
|
536
|
-
try { process.kill(Number(record.pid), 0); } catch { continue; }
|
|
537
|
-
const key = readdirSync(directory).find(item => item.startsWith(`${record.pid}.`) && item.endsWith('.key'));
|
|
538
|
-
let token = '';
|
|
539
|
-
try { token = key ? JSON.parse(readFileSync(join(directory, key), 'utf8')).peerToken || '' : ''; } catch { /* no inbox credential */ }
|
|
540
|
-
return { pid: Number(record.pid), inbox: record.messagingSocketPath && token ? { socket: record.messagingSocketPath, token } : null };
|
|
541
|
-
}
|
|
542
|
-
return null;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/** Whether this execution goes into a manual member's own open window (taken over with `own` permissions) rather than a background process. */
|
|
546
|
-
export function claude_inbox_ready(profile, binding) {
|
|
547
|
-
return profile.adapter === 'claude' && profile.project_access === 'own' && Boolean(profile.session_id)
|
|
548
|
-
&& binding.session_id === profile.session_id && Boolean(claude_window(profile.session_id)?.inbox);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* A taken-over manual Claude member whose session a window without an inbox still holds: resuming that session from
|
|
553
|
-
* here would split its file into two branches neither side sees in full, so its first hosted turn forks it and later
|
|
554
|
-
* turns resume the fork; a fork whose session was never written is retried under the same ID. Once no window holds
|
|
555
|
-
* the session, it is resumed as it is.
|
|
556
|
-
*/
|
|
557
|
-
export function adopted_fork(config, binding, held, exists = id => session_exists('claude', id)) {
|
|
558
|
-
if (config.project_access !== 'own' || !config.session_id || binding.fork || !binding.session_id) return binding;
|
|
559
|
-
if (binding.session_id !== config.session_id) return exists(binding.session_id) ? binding : { ...binding, fork: { from: config.session_id, at: '' } };
|
|
560
|
-
return held ? { ...binding, session_id: randomUUID(), fork: { from: config.session_id, at: '' } } : binding;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/** Post one task into an open Claude window; it starts a turn there, or between tool calls of the turn it is in. */
|
|
564
|
-
async function claude_inbox_delivery(config, inbox, task) {
|
|
565
|
-
try { bind_access(task.access_path, config.session_id); }
|
|
566
|
-
catch (error) { throw not_sent(error); }
|
|
567
|
-
const text = `CoDiscuss 本机服务代房间转达(你已注册为该房间成员,这是房间分派给你的任务):\n${task.prompt}`;
|
|
568
|
-
const lines = [{ type: 'auth', token: inbox.token }, { type: 'user', message: { role: 'user', content: text } }];
|
|
569
|
-
await new Promise((resolve_sent, reject) => {
|
|
570
|
-
let written = false;
|
|
571
|
-
const socket = connect(inbox.socket, () => {
|
|
572
|
-
socket.end(lines.map(line => `${JSON.stringify(line)}\n`).join(''), () => { written = true; resolve_sent(); });
|
|
573
|
-
});
|
|
574
|
-
socket.on('error', error => written ? undefined : reject(not_sent(error)));
|
|
575
|
-
});
|
|
576
|
-
// The discussion runner enters the execution once the session is known, as it does when a process reports it.
|
|
577
|
-
task.on_binding?.(config.session_id);
|
|
578
|
-
const marker = `${task.id}.task.json`;
|
|
579
|
-
setTimeout(() => {
|
|
580
|
-
if (!claude_session_mentions(config.session_id, marker)) {
|
|
581
|
-
task.on_note?.('已投递到原窗口,但它还没开始处理:原窗口可能处于跳过审批模式(需在窗口中批准这条消息)或仍在忙。');
|
|
582
|
-
}
|
|
583
|
-
}, INBOX_CHECK_MS).unref();
|
|
584
|
-
return { session_id: config.session_id, status: 'accepted' };
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/** Whether a Claude session's log contains some text, e.g. the task file a delivered prompt names. */
|
|
588
|
-
function claude_session_mentions(session_id, text, env = process.env) {
|
|
589
|
-
const projects = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
|
|
590
|
-
if (!existsSync(projects)) return false;
|
|
591
|
-
const file = readdirSync(projects).map(directory => join(projects, directory, `${session_id}.jsonl`)).find(existsSync);
|
|
592
|
-
try { return Boolean(file) && readFileSync(file, 'utf8').includes(text); } catch { return false; }
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
async function claude_delivery(config, binding, task, spawn_process = spawn) {
|
|
596
|
-
const window = config.project_access === 'own' && binding.session_id && binding.session_id === config.session_id ? claude_window(config.session_id) : null;
|
|
597
|
-
// Only a task written for the window names its credential file; a background process gets it through its environment.
|
|
598
|
-
if (window?.inbox && task.inbox) return claude_inbox_delivery(config, window.inbox, task);
|
|
599
|
-
binding = adopted_fork(config, binding, Boolean(window));
|
|
600
|
-
const session_id = binding.session_id || randomUUID();
|
|
601
|
-
try { bind_access(task.access_path, session_id); }
|
|
602
|
-
catch (error) { throw not_sent(error); }
|
|
603
|
-
// Streamed events show the turn's progress; the final line is still the result that decides success.
|
|
604
|
-
// A rewound or forked member first forks its earlier session into the ID it is now bound to, cut at `at` when given.
|
|
605
|
-
const fork = binding.fork ? ['--resume', binding.fork.from, '--fork-session', ...(binding.fork.at ? ['--resume-session-at', binding.fork.at] : []), '--session-id', session_id]
|
|
606
|
-
: [binding.session_id ? '--resume' : '--session-id', session_id];
|
|
607
|
-
const args = ['-p', '--output-format', 'stream-json', '--verbose', ...fork,
|
|
608
|
-
...(config.model ? ['--model', config.model] : []), ...(config.effort ? ['--effort', config.effort] : []), ...claude_permissions(config, task)];
|
|
609
|
-
let child;
|
|
610
|
-
try {
|
|
611
|
-
child = spawn_process(config.executable, [...args, '--', task.prompt], {
|
|
612
|
-
cwd: launch_directory(config, task), windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'],
|
|
613
|
-
env: { ...standalone_claude_env(), DISCUSS_ACCESS_FILE: task.access_path },
|
|
614
|
-
});
|
|
615
|
-
} catch (error) { throw not_sent(error); }
|
|
616
|
-
tree_killable(child);
|
|
617
|
-
let spawned = false;
|
|
618
|
-
const log_path = resolve(task.directory, `claude-${task.id}.log`);
|
|
619
|
-
let output = '';
|
|
620
|
-
child.stdout.on('data', chunk => { output += chunk; task.on_output(log_path, chunk); });
|
|
621
|
-
child.stderr.on('data', chunk => task.on_output(log_path, chunk));
|
|
622
|
-
watch_activity('claude', child.stdout, task);
|
|
623
|
-
child.once('close', code => {
|
|
624
|
-
if (!spawned) return;
|
|
625
|
-
const denials = claude_denials(output);
|
|
626
|
-
if (denials.length) task.on_denials?.(denials);
|
|
627
|
-
const error = claude_exit_error(code, output);
|
|
628
|
-
if (error) task.on_failure(error);
|
|
629
|
-
else task.on_exit?.();
|
|
630
|
-
});
|
|
631
|
-
task.on_process(child);
|
|
632
|
-
await new Promise((resolve_spawn, reject) => {
|
|
633
|
-
child.once('spawn', () => { spawned = true; resolve_spawn(); });
|
|
634
|
-
child.once('error', error => spawned ? task.on_failure(error.message) : reject(not_sent(error)));
|
|
635
|
-
});
|
|
636
|
-
// A failed spawn creates no session; only a successfully started process can be resumed.
|
|
637
|
-
task.on_binding(session_id);
|
|
638
|
-
return { session_id, status: 'accepted' };
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/** Build a per-invocation profile without inheriting a user's custom profile of the same name. */
|
|
642
|
-
export function codex_permissions(config, task, platform = process.platform) {
|
|
643
|
-
// 全放开 runs without approval or sandbox, so there is no profile to build or check.
|
|
644
|
-
if (config.project_access === 'full') return { name: '', args: ['--dangerously-bypass-approvals-and-sandbox'] };
|
|
645
|
-
const name = `codiscuss-${randomUUID()}`;
|
|
646
|
-
// A taken-over manual member writes like 可写: its own config may sandbox read-only and then could not save its answer draft.
|
|
647
|
-
const base = ['auto', 'own'].includes(config.project_access) || (project_directory(config) && config.project_access === 'write')
|
|
648
|
-
? ':workspace' : ':read-only';
|
|
649
|
-
const drafts = resolve(task.draft_directory).replaceAll('\\', '/');
|
|
650
|
-
const filesystem = { ':root': 'read', [resolve(task.directory).replaceAll('\\', '/')]: 'read', [drafts]: 'write' };
|
|
651
|
-
const rules = Object.entries(filesystem).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',');
|
|
652
|
-
const profile = `{extends=${JSON.stringify(base)},filesystem={${rules}},network={enabled=true}}`;
|
|
653
|
-
// The Windows sandbox account cannot write the user's TEMP, so PowerShell starts in ConstrainedLanguage and Codex's
|
|
654
|
-
// UTF-8 output setup fails silently, garbling Chinese; a TEMP inside the writable drafts keeps FullLanguage.
|
|
655
|
-
const temp = platform === 'win32' ? ['-c', `shell_environment_policy.set.TEMP=${JSON.stringify(drafts)}`,
|
|
656
|
-
'-c', `shell_environment_policy.set.TMP=${JSON.stringify(drafts)}`] : [];
|
|
657
|
-
// 自动 is what `codex exec --approve-for-me` sets (that flag is missing on `exec resume`): the model asks to escalate
|
|
658
|
-
// and Codex's automatic review answers instead of a person (tested 0.163).
|
|
659
|
-
const approval = config.project_access === 'auto' ? ['-c', 'approval_policy="on-request"', '-c', 'approvals_reviewer="auto_review"']
|
|
660
|
-
: ['-c', 'approval_policy="never"'];
|
|
661
|
-
return { name, args: ['-c', `default_permissions=${JSON.stringify(name)}`, '-c', `permissions.${name}=${profile}`, ...approval, ...temp] };
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/** Read effective Codex config through its local CLI; no thread or model is started. */
|
|
665
|
-
function read_codex_config(config, task, permissions, spawn_process) {
|
|
666
|
-
return new Promise((resolve_config, reject) => {
|
|
667
|
-
const child = spawn_process(config.executable, ['app-server', ...permissions.args], {
|
|
668
|
-
cwd: launch_directory(config, task), windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'],
|
|
669
|
-
});
|
|
670
|
-
task.on_process(child);
|
|
671
|
-
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
672
|
-
let settled = false;
|
|
673
|
-
const finish = (error, value) => {
|
|
674
|
-
if (settled) return;
|
|
675
|
-
settled = true;
|
|
676
|
-
clearTimeout(timer); lines.close();
|
|
677
|
-
const complete = () => error ? reject(not_sent(error)) : resolve_config(value);
|
|
678
|
-
if (child.exitCode !== null || child.signalCode !== null) complete();
|
|
679
|
-
else { child.once('close', complete); child.kill(); }
|
|
680
|
-
};
|
|
681
|
-
const timer = setTimeout(() => finish(new Error('Codex 权限配置检查超时,未交付讨论任务')), CALL_TIMEOUT_MS);
|
|
682
|
-
const send = (id, method, params) => child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
|
|
683
|
-
child.once('error', error => finish(error));
|
|
684
|
-
child.once('close', () => finish(new Error('Codex 无法读取权限配置,请使用支持 permission profiles 的 CLI')));
|
|
685
|
-
child.stdin.on('error', error => finish(error));
|
|
686
|
-
child.stderr.resume();
|
|
687
|
-
lines.on('line', line => {
|
|
688
|
-
let response;
|
|
689
|
-
try { response = JSON.parse(line); } catch { return; }
|
|
690
|
-
if (![1, 2].includes(response.id)) return;
|
|
691
|
-
if (response.error) return finish(new Error(`Codex 权限配置检查失败:${response.error.message}`));
|
|
692
|
-
if (response.id === 1) {
|
|
693
|
-
const version = response.result?.userAgent?.match(/\/(\d+)\.(\d+)\.\d+\b/);
|
|
694
|
-
if (!version || (Number(version[1]) === 0 && Number(version[2]) < CODEX_PERMISSIONS_MIN_MINOR)) {
|
|
695
|
-
return finish(new Error('本次权限配置需要 Codex CLI 0.163.0 或更新版本,未交付讨论任务'));
|
|
696
|
-
}
|
|
697
|
-
send(2, 'config/read', { cwd: launch_directory(config, task), includeLayers: false });
|
|
698
|
-
}
|
|
699
|
-
else finish(null, response.result?.config);
|
|
700
|
-
});
|
|
701
|
-
send(1, 'initialize', { clientInfo: { name: 'codiscuss', version: '0.1.0' } });
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
/** Reject legacy overrides that would silently replace the requested filesystem policy. */
|
|
706
|
-
async function check_codex_permissions(config, task, permissions, spawn_process) {
|
|
707
|
-
const effective = await read_codex_config(config, task, permissions, spawn_process).catch(error => { throw not_sent(error); });
|
|
708
|
-
if (effective?.sandbox_mode != null || effective?.sandbox_workspace_write != null) {
|
|
709
|
-
throw not_sent(new Error('Codex 配置含旧 sandbox_mode / sandbox_workspace_write,会覆盖本次权限;请移除旧配置后重试,未交付讨论任务'));
|
|
710
|
-
}
|
|
711
|
-
if (effective?.default_permissions !== permissions.name || !effective?.permissions?.[permissions.name]) {
|
|
712
|
-
throw not_sent(new Error('Codex 未采用本次 permission profile,未交付讨论任务;请更新 CLI'));
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/** Build native CLI arguments; every continuation names the exact bound conversation. */
|
|
717
|
-
function cli_invocation(config, binding, task, user_home) {
|
|
718
|
-
if (config.adapter === 'codex_cli') {
|
|
719
|
-
const permissions = codex_permissions(config, task);
|
|
720
|
-
const args = ['exec', '--cd', launch_directory(config, task)];
|
|
721
|
-
if (binding.session_id) args.push('resume', binding.session_id);
|
|
722
|
-
args.push(...permissions.args, '--json', '--skip-git-repo-check', ...(config.model ? ['-m', config.model] : []),
|
|
723
|
-
...(config.effort ? ['-c', `model_reasoning_effort="${config.effort}"`] : []), '-');
|
|
724
|
-
return { args, input: task.prompt, permissions };
|
|
725
|
-
}
|
|
726
|
-
// An agy expert the owner chose to host keeps the project and permissions of its own conversation; only new conversations get the discussion project.
|
|
727
|
-
const project = config.session_id ? null : prepare_agy_project(config, binding, task, user_home);
|
|
728
|
-
const args = ['--output-format', 'stream-json', '--print-timeout', '0', '--add-dir', task.directory,
|
|
729
|
-
'--add-dir', task.draft_directory, '--add-dir', task.skills_directory];
|
|
730
|
-
// Headless agy refuses `--mode auto` ("not available for this account", 1.4.10–1.4.11) though its interactive window
|
|
731
|
-
// offers it; 全放开 uses the flag its own denial message suggests on top of the open project grants.
|
|
732
|
-
args.push('--mode', 'accept-edits', ...(config.project_access === 'full' ? ['--dangerously-skip-permissions'] : []),
|
|
733
|
-
...(project ? ['--project', project.project_id] : []));
|
|
734
|
-
if (binding.session_id) args.push('--conversation', binding.session_id);
|
|
735
|
-
if (config.model) args.push('--model', config.model);
|
|
736
|
-
if (config.effort) args.push('--effort', config.effort);
|
|
737
|
-
args.push('-p', task.prompt);
|
|
738
|
-
return { args, project };
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
/** Persist an emitted identity before the agent reads its execution credential file. */
|
|
742
|
-
function bind_cli_session(state, session_id, task) {
|
|
743
|
-
if (typeof session_id !== 'string' || !session_id) throw new Error('CLI 未返回实际会话 ID');
|
|
744
|
-
if (state.session_id && state.session_id !== session_id) throw new Error('CLI 返回的会话 ID 与绑定不符,已停止本次执行');
|
|
745
|
-
task.on_binding(session_id);
|
|
746
|
-
bind_access(task.access_path, session_id);
|
|
747
|
-
state.session_id = session_id;
|
|
748
|
-
state.initialized = true;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/** Consume only documented lifecycle events, not text produced by agent tools. */
|
|
752
|
-
function cli_event(adapter, state, event, task) {
|
|
753
|
-
if (adapter === 'codex_cli') {
|
|
754
|
-
if (event.type === 'thread.started') bind_cli_session(state, event.thread_id, task);
|
|
755
|
-
if (event.type === 'turn.completed') { state.completed = true; state.finish_timer = setTimeout(() => { state.finished = true; task.on_trace?.('kill_after_completed'); state.child.kill(); }, CLI_EXIT_GRACE_MS); }
|
|
756
|
-
if (event.type === 'turn.failed') state.error = event.error?.message || 'Codex CLI 执行失败';
|
|
757
|
-
if (event.type === 'error') state.diagnostic = event.message || 'Codex CLI 返回错误';
|
|
758
|
-
remember_writer_conflict(state, state.error || state.diagnostic);
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
if (event.event === 'init') bind_cli_session(state, event.conversation_id, task);
|
|
762
|
-
const step = event.step_update;
|
|
763
|
-
// Headless agy ends its turn when an operation needs approval; remember which ones so the page can name and allow them.
|
|
764
|
-
const denied = step?.state === 'ERROR' ? agy_denial(step) : null;
|
|
765
|
-
if (denied) {
|
|
766
|
-
if (!state.denials.some(item => item.detail === denied.detail) && state.denials.length < MAX_DENIALS) state.denials.push(denied);
|
|
767
|
-
state.denial = `${denied.tool === 'write_file' ? '写入' : '运行'}了未授权的${denied.tool === 'write_file' ? '文件' : '命令'}「${denied.detail.slice(0, 120)}」,无人值守模式下无法确认权限而被自动拒绝,随后会话结束`;
|
|
768
|
-
}
|
|
769
|
-
if (event.event === 'result') {
|
|
770
|
-
const result = event.result || {};
|
|
771
|
-
if (result.conversation_id) bind_cli_session(state, result.conversation_id, task);
|
|
772
|
-
state.completed = result.status === 'SUCCESS';
|
|
773
|
-
// A refused operation still ends with SUCCESS and an empty response; name it even when no failed step was seen.
|
|
774
|
-
if (!state.denial && Array.isArray(result.denied_actions) && result.denied_actions.length) {
|
|
775
|
-
state.denial = `的${result.denied_actions.map(item => item.display_name || item.action).join('、')}操作未获授权,无人值守模式下被自动拒绝,随后会话结束`;
|
|
776
|
-
}
|
|
777
|
-
if (!state.completed) state.error = `Antigravity CLI ${result.status || '缺少结果状态'}:${result.error?.message || result.response || '执行未成功'}`;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
/** Record lifecycle events and tool commands with timestamps so slow stages can be located from the trace file. */
|
|
782
|
-
function trace_cli_event(event, task) {
|
|
783
|
-
if (!task.on_trace) return;
|
|
784
|
-
const item = event.item;
|
|
785
|
-
const command = item?.command ? String(item.command).replace(/^.*?-Command\s+/, '').slice(0, 100) : '';
|
|
786
|
-
const type = event.type ?? event.event ?? 'unknown';
|
|
787
|
-
task.on_trace(item ? `${type}:${item.type}` : type, command ? { command } : {});
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/** Keep the actual resume failure even when later model-refresh logs replace the diagnostic tail. */
|
|
791
|
-
function remember_writer_conflict(state, diagnostic) {
|
|
792
|
-
if (/thread-store conflict:[\s\S]*already has an active writer/.test(diagnostic)) state.writer_conflict = true;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/** Distinguish process completion from successful CLI output and saved website work. */
|
|
796
|
-
function cli_exit_error(state, code, signal) {
|
|
797
|
-
if (state.writer_conflict) return `Codex 会话 ${state.session_id} 已被另一进程持有(active writer),CLI 无法接管。若为 App 中的聊天,请使用 Codex App 接入;独立 CLI 须使用未被占用的会话。重复点击重试不会解除占用`;
|
|
798
|
-
if (state.error) return state.error;
|
|
799
|
-
if (state.finished && state.completed) return '';
|
|
800
|
-
if (code !== 0) return `CLI ${signal ? `被信号 ${signal} 终止` : `退出码 ${code}`}:${state.diagnostic}`;
|
|
801
|
-
if (!state.initialized) return `CLI 未确认实际会话 ID:${state.diagnostic}`;
|
|
802
|
-
if (!state.completed) return `CLI 未返回成功结束事件:${state.diagnostic}`;
|
|
803
|
-
return '';
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/** Parse complete UTF-8 JSON lines while keeping raw output in the private task log. */
|
|
807
|
-
function monitor_cli(child, config, binding, task) {
|
|
808
|
-
const state = { child, session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '', responses: new Map(), denials: [] };
|
|
809
|
-
const log_path = resolve(task.directory, `${config.adapter}-${task.id}.log`);
|
|
810
|
-
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
811
|
-
const max_run_ms = Number(process.env.CODISCUSS_CLI_MAX_MS) || CLI_MAX_RUN_MS;
|
|
812
|
-
state.limit_timer = setTimeout(() => { state.error = `CLI 运行超过 ${Math.round(max_run_ms / 1000)} 秒仍未结束,已终止`; child.kill(); }, max_run_ms);
|
|
813
|
-
child.stdout.on('data', chunk => task.on_output(log_path, chunk));
|
|
814
|
-
child.stderr.setEncoding('utf8');
|
|
815
|
-
child.stderr.on('data', chunk => {
|
|
816
|
-
if (config.adapter === 'codex_cli') remember_writer_conflict(state, state.diagnostic + chunk);
|
|
817
|
-
state.diagnostic = (state.diagnostic + chunk).slice(-CLI_DIAGNOSTIC_CHARS).trim();
|
|
818
|
-
task.on_output(log_path, chunk);
|
|
819
|
-
});
|
|
820
|
-
lines.on('line', line => {
|
|
821
|
-
if (!line.trim()) return;
|
|
822
|
-
let event;
|
|
823
|
-
try { event = JSON.parse(line); }
|
|
824
|
-
catch { state.diagnostic = 'CLI 输出中存在无效 JSON,详情见本机日志'; return; }
|
|
825
|
-
trace_cli_event(event, task);
|
|
826
|
-
const phase = activity_phase(config.adapter, event);
|
|
827
|
-
if (phase) task.on_activity?.(phase, activity_tool(config.adapter, event));
|
|
828
|
-
const note = progress_note(config.adapter, event, state.responses);
|
|
829
|
-
if (note) task.on_note?.(note);
|
|
830
|
-
try { cli_event(config.adapter, state, event, task); }
|
|
831
|
-
catch (error) { state.error = error.message; child.kill(); }
|
|
832
|
-
});
|
|
833
|
-
child.once('close', (code, signal) => {
|
|
834
|
-
clearTimeout(state.finish_timer);
|
|
835
|
-
clearTimeout(state.limit_timer);
|
|
836
|
-
task.on_trace?.('cli_closed', { code, signal, killed_after_completed: Boolean(state.finished) });
|
|
837
|
-
if (!state.spawned) return;
|
|
838
|
-
if (state.denials.length) task.on_denials?.(state.denials);
|
|
839
|
-
const error = cli_exit_error(state, code, signal);
|
|
840
|
-
if (error) task.on_failure(error);
|
|
841
|
-
else task.on_exit?.(state.denial);
|
|
842
|
-
});
|
|
843
|
-
return state;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
/** Start one headless CLI turn; native agy permissions belong to a separate discussion project. */
|
|
847
|
-
async function cli_delivery(config, binding, task, spawn_process, user_home) {
|
|
848
|
-
if (binding.session_id) {
|
|
849
|
-
try { bind_access(task.access_path, binding.session_id); }
|
|
850
|
-
catch (error) { throw not_sent(error); }
|
|
851
|
-
}
|
|
852
|
-
let invocation;
|
|
853
|
-
try { invocation = cli_invocation(config, binding, task, user_home); }
|
|
854
|
-
catch (error) { throw not_sent(error); }
|
|
855
|
-
if (invocation.permissions?.name) {
|
|
856
|
-
task.on_trace?.('permission_check_start');
|
|
857
|
-
await check_codex_permissions(config, task, invocation.permissions, spawn_process);
|
|
858
|
-
task.on_trace?.('permission_check_done');
|
|
859
|
-
}
|
|
860
|
-
if (task.before_model_start) {
|
|
861
|
-
try { await task.before_model_start(); }
|
|
862
|
-
catch (error) { throw not_sent(error); }
|
|
863
|
-
}
|
|
864
|
-
if (invocation.project) {
|
|
865
|
-
const on_binding = task.on_binding;
|
|
866
|
-
task = { ...task, on_binding: session_id => { invocation.project.bind(session_id); on_binding(session_id); } };
|
|
867
|
-
}
|
|
868
|
-
let child;
|
|
869
|
-
try {
|
|
870
|
-
child = spawn_process(config.executable, invocation.args, {
|
|
871
|
-
cwd: launch_directory(config, task), windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'],
|
|
872
|
-
env: { ...process.env, DISCUSS_ACCESS_FILE: task.access_path },
|
|
873
|
-
});
|
|
874
|
-
} catch (error) { throw not_sent(error); }
|
|
875
|
-
tree_killable(child);
|
|
876
|
-
const state = monitor_cli(child, config, binding, task);
|
|
877
|
-
task.on_process(child);
|
|
878
|
-
await new Promise((resolve_spawn, reject) => {
|
|
879
|
-
child.once('spawn', () => { state.spawned = true; task.on_trace?.('cli_spawned'); resolve_spawn(); });
|
|
880
|
-
child.once('error', error => state.spawned ? task.on_failure(error.message) : reject(not_sent(error)));
|
|
881
|
-
});
|
|
882
|
-
child.stdin.on('error', error => { if (error.code !== 'EPIPE') task.on_failure(error.message); });
|
|
883
|
-
child.stdin.end(invocation.input);
|
|
884
|
-
task.on_trace?.('prompt_sent');
|
|
885
|
-
return { session_id: state.session_id, status: 'accepted' };
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
/** Queue one immutable request for the official App-managed Sidecar. */
|
|
889
|
-
async function antigravity_delivery(config, binding, task) {
|
|
890
|
-
if (!task.id.startsWith('free-')) throw new Error('Sidecar 只支持自由协作执行请求,旧讨论事件已停用');
|
|
891
|
-
const directory = resolve(config.ipc_directory);
|
|
892
|
-
try {
|
|
893
|
-
mkdirSync(directory, { recursive: true });
|
|
894
|
-
const ready = JSON.parse(readFileSync(join(directory, 'ready.json'), 'utf8'));
|
|
895
|
-
// Only a new conversation is created in the Sidecar's project; an existing one is reached from any project.
|
|
896
|
-
if (!binding.session_id && (config.project_id || config.project_name || config.cwd)) {
|
|
897
|
-
const project = antigravity_project(config);
|
|
898
|
-
if (ready.project_id !== project.id) throw new Error('Sidecar 所属项目与配置不符,请先运行 setup-sidecar');
|
|
899
|
-
}
|
|
900
|
-
try { process.kill(ready.pid, 0); } catch { throw new Error('Antigravity Sidecar 尚未运行'); }
|
|
901
|
-
if (binding.session_id) bind_access(task.access_path, binding.session_id);
|
|
902
|
-
} catch (error) { throw not_sent(error); }
|
|
903
|
-
const path = join(directory, `${task.id}.request.json`);
|
|
904
|
-
if (existsSync(path)) throw new Error('Sidecar 请求已存在;必须核对回执,不自动重复发送');
|
|
905
|
-
atomic_json(path, { id: task.id, mode: 'free',
|
|
906
|
-
event_id: Number(task.id.replace(/^free-/, '')), prompt: task.prompt,
|
|
907
|
-
session_id: binding.session_id || '', access_path: task.access_path });
|
|
908
|
-
return { session_id: binding.session_id || '', status: 'sidecar_pending' };
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
/** Resolve Antigravity's real project resources without reading any conversation. */
|
|
912
|
-
export function antigravity_project(config, root = join(homedir(), '.gemini', 'config')) {
|
|
913
|
-
if (!config.project_id && !config.project_name && !config.cwd) return { id: 'outside-of-project' };
|
|
914
|
-
const directory = join(root, 'projects');
|
|
915
|
-
const projects = readdirSync(directory).filter(name => name.endsWith('.json')).map(name => JSON.parse(readFileSync(join(directory, name), 'utf8')));
|
|
916
|
-
const matching = projects.filter(project => (!config.project_id || project.id === config.project_id)
|
|
917
|
-
&& (!config.project_name || project.name === config.project_name)
|
|
918
|
-
&& (!config.cwd || (project.projectResources?.resources || []).some(resource => {
|
|
919
|
-
const uri = resource.gitFolder?.folderUri || resource.folder?.folderUri;
|
|
920
|
-
return uri?.startsWith('file:') && same_path(fileURLToPath(uri), config.cwd);
|
|
921
|
-
})));
|
|
922
|
-
if (matching.length !== 1) throw new Error('Antigravity 项目与 cwd 无法唯一匹配;请先在 App 添加目录并核对配置');
|
|
923
|
-
return matching[0];
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
/** Explicitly install an App-managed Sidecar; preserve unrelated application settings. */
|
|
927
|
-
export function setup_sidecar(config, root = join(homedir(), '.gemini', 'config')) {
|
|
928
|
-
const project = antigravity_project(config, root);
|
|
929
|
-
const directory = resolve(config.ipc_directory);
|
|
930
|
-
mkdirSync(directory, { recursive: true });
|
|
931
|
-
const id = `codiscuss-${createHash('sha256').update(directory).digest('hex').slice(0, 12)}`;
|
|
932
|
-
const sidecar = join(root, 'sidecars', id);
|
|
933
|
-
mkdirSync(sidecar, { recursive: true });
|
|
934
|
-
atomic_json(join(sidecar, 'sidecar.json'), { command: process.execPath,
|
|
935
|
-
args: [fileURLToPath(new URL('./discussion_sidecar.mjs', import.meta.url))], restart_policy: 'on-failure',
|
|
936
|
-
display_name: 'CoDiscuss', env: { CODISCUSS_IPC_DIRECTORY: directory } });
|
|
937
|
-
const path = join(root, 'config.json');
|
|
938
|
-
const current = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
|
|
939
|
-
// App 2.23 upgrades legacy entries by cloning their project. Use its observed current
|
|
940
|
-
// schema so the explicitly selected existing project remains the actual destination.
|
|
941
|
-
atomic_json(path, { ...current, sidecars: { ...current.sidecars,
|
|
942
|
-
[id]: { ...current.sidecars?.[id], enabled: true, projectId: project.id, schemaVersion: 1 } } });
|
|
943
|
-
return { sidecar_id: id, project_id: project.id, cwd: config.cwd || null };
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
/** Select only allowlisted adapter types from local configuration. */
|
|
947
|
-
export async function deliver_task(config, binding, task, spawn_process = spawn, user_home = homedir()) {
|
|
948
|
-
if (config.adapter === 'api') return (await import('./api_participant.mjs')).api_delivery(config, binding, task);
|
|
949
|
-
const adapters = { codex: codex_delivery, claude: claude_delivery, antigravity: antigravity_delivery,
|
|
950
|
-
codex_cli: cli_delivery, antigravity_cli: cli_delivery };
|
|
951
|
-
if (!adapters[config.adapter]) throw new Error('不支持的软件适配器');
|
|
952
|
-
return adapters[config.adapter](config, binding, task, spawn_process, user_home);
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/** Read a Sidecar's durable receipt without resubmitting an uncertain request. */
|
|
956
|
-
export function sidecar_receipt(config, id) {
|
|
957
|
-
const path = join(resolve(config.ipc_directory), `${id}.result.json`);
|
|
958
|
-
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
959
|
-
}
|