ai_co_discuss 0.2.97 → 0.2.99
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/.agents/skills/discuss-participant/references/free.md +1 -1
- package/backend/admin.py +1 -1
- package/backend/free_room.py +185 -4
- package/backend/room_import.py +2 -0
- package/backend/schema.sql +32 -1
- package/backend/server.py +37 -9
- package/docs/deployment.md +2 -2
- package/docs/en/deployment.md +2 -2
- package/docs/en/workflow.md +98 -96
- package/docs/workflow.md +98 -96
- package/package.json +1 -1
- package/runner/admin_account.mjs +10 -14
- package/runner/codex_app_client.mjs +13 -0
- package/runner/codiscuss.mjs +2 -2
- package/runner/discussion_adapters.mjs +205 -26
- package/runner/discussion_launch.mjs +136 -15
- package/runner/discussion_models.mjs +89 -1
- package/runner/discussion_runner.mjs +16 -13
- package/runner/discussion_service.mjs +98 -19
- package/runner/expert_prompts.mjs +85 -1
- package/runner/free_runner.mjs +38 -20
- package/runner/scheduler.mjs +4 -4
- package/runner/session_cut.mjs +167 -0
- package/web/app.js +234 -32
- package/web/markdown.css +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +2926 -2517
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
5
|
-
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { connect } from 'node:net';
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
8
|
import { createInterface } from 'node:readline';
|
|
8
9
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
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';
|
|
10
13
|
|
|
11
14
|
const CALL_TIMEOUT_MS = 30_000;
|
|
12
15
|
const CLI_DIAGNOSTIC_CHARS = 1000;
|
|
@@ -14,14 +17,14 @@ const CLI_DIAGNOSTIC_CHARS = 1000;
|
|
|
14
17
|
const CLI_EXIT_GRACE_MS = 1500;
|
|
15
18
|
// A hung CLI keeps its lease renewed forever when the room sets no execution limit, so cap every process.
|
|
16
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;
|
|
17
22
|
const CODEX_PERMISSIONS_MIN_MINOR = 163;
|
|
18
23
|
/** Website subcommands an automatic task may run; service, launch, run, api and setup stay unapproved. */
|
|
19
24
|
export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'execution', 'health', 'schema'];
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
export
|
|
23
|
-
return adapter === 'antigravity_cli' ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;列目录、搜索或读取文件用 list_dir、find_by_name、grep_search、view_file。` : '';
|
|
24
|
-
}
|
|
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 };
|
|
25
28
|
|
|
26
29
|
/** Only failures before a software invocation are safe for automatic redelivery. */
|
|
27
30
|
function not_sent(error) { return Object.assign(error, { delivery_status: 'not_sent' }); }
|
|
@@ -63,6 +66,14 @@ async function codex_delivery(config, binding, task) {
|
|
|
63
66
|
if (binding.session_id) {
|
|
64
67
|
try { bind_access(task.access_path, binding.session_id); }
|
|
65
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
|
+
}
|
|
66
77
|
const receipt = await run_program(config.executable, ['queue', '--thread', binding.session_id, '--message', task.prompt], { timeout: CALL_TIMEOUT_MS });
|
|
67
78
|
return { session_id: binding.session_id, status: 'accepted', receipt };
|
|
68
79
|
}
|
|
@@ -78,6 +89,14 @@ async function codex_delivery(config, binding, task) {
|
|
|
78
89
|
} finally { app.close(); }
|
|
79
90
|
}
|
|
80
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
|
+
|
|
81
100
|
/** Match the real saved project and its directory; a prompt cannot grant file access. */
|
|
82
101
|
export async function codex_target(app, config) {
|
|
83
102
|
if (!config.project_id && !config.project_name && !config.cwd) return { type: 'projectless' };
|
|
@@ -105,6 +124,57 @@ export function claude_exit_error(code, output) {
|
|
|
105
124
|
return code === 0 ? '' : `Claude 退出码 ${code}`;
|
|
106
125
|
}
|
|
107
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
|
+
|
|
108
178
|
/**
|
|
109
179
|
* Drop variables a hosting Claude session injects, so the spawned CLI uses its own login.
|
|
110
180
|
* The host also hides the user's persisted login variables, which are read back on Windows.
|
|
@@ -181,14 +251,18 @@ function agy_project_grants(config, task, resources, user_home) {
|
|
|
181
251
|
.filter(uri => uri?.startsWith('file:')).map(uri => fileURLToPath(uri));
|
|
182
252
|
const projects = [...new Set([project_directory(config), ...folders].filter(directory => directory && !same_path(directory, task.draft_directory)))];
|
|
183
253
|
const protected_paths = [task.directory, task.skills_directory, join(user_home, '.gemini'),
|
|
184
|
-
...(config.project_access === '
|
|
254
|
+
...(config.project_access === 'read' ? projects : [])];
|
|
185
255
|
if (protected_paths.some(directory => within_directory(directory, task.draft_directory))) {
|
|
186
256
|
throw new Error('agy 草稿目录不能位于只读项目、运行目录、技能或 .gemini 配置目录中,未交付讨论任务');
|
|
187
257
|
}
|
|
188
258
|
const write_rule = directory => `write_file(${resolve(directory).replaceAll('\\', '/')})`;
|
|
259
|
+
// 完全放开 also runs commands outside agy's sandbox and allows MCP tools; run data stays protected at every level.
|
|
260
|
+
const full = config.project_access === 'full';
|
|
189
261
|
return { allow: [agy_rule(), write_rule(task.draft_directory),
|
|
190
|
-
...(config.project_access === '
|
|
191
|
-
|
|
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(*)'])] };
|
|
192
266
|
}
|
|
193
267
|
|
|
194
268
|
/** Run agy only inside a private native project this tool created; external conversations keep their own project, so they are refused. */
|
|
@@ -235,12 +309,21 @@ export function remove_agy_projects(directory, user_home = homedir()) {
|
|
|
235
309
|
}
|
|
236
310
|
|
|
237
311
|
/**
|
|
238
|
-
* Allow
|
|
239
|
-
* Claude Code on Windows may run commands through its PowerShell tool
|
|
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.
|
|
240
315
|
*/
|
|
241
316
|
export function claude_permissions(config, task) {
|
|
242
|
-
|
|
243
|
-
|
|
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)] : [])];
|
|
244
327
|
return ['--add-dir', task.directory, task.draft_directory, task.skills_directory, '--allowedTools',
|
|
245
328
|
...commands, ...writable.map(directory => `Edit(${claude_rule_path(directory)}/**)`), ...(config.allowed_tools || [])];
|
|
246
329
|
}
|
|
@@ -438,12 +521,88 @@ function watch_activity(adapter, stream, task) {
|
|
|
438
521
|
});
|
|
439
522
|
}
|
|
440
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 taken-over member's own open window 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
|
+
const marker = `${task.id}.task.json`;
|
|
577
|
+
setTimeout(() => {
|
|
578
|
+
if (!claude_session_mentions(config.session_id, marker)) {
|
|
579
|
+
task.on_note?.('已投递到原窗口,但它还没开始处理:原窗口可能处于跳过审批模式(需在窗口中批准这条消息)或仍在忙。');
|
|
580
|
+
}
|
|
581
|
+
}, INBOX_CHECK_MS).unref();
|
|
582
|
+
return { session_id: config.session_id, status: 'accepted' };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Whether a Claude session's log contains some text, e.g. the task file a delivered prompt names. */
|
|
586
|
+
function claude_session_mentions(session_id, text, env = process.env) {
|
|
587
|
+
const projects = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
|
|
588
|
+
if (!existsSync(projects)) return false;
|
|
589
|
+
const file = readdirSync(projects).map(directory => join(projects, directory, `${session_id}.jsonl`)).find(existsSync);
|
|
590
|
+
try { return Boolean(file) && readFileSync(file, 'utf8').includes(text); } catch { return false; }
|
|
591
|
+
}
|
|
592
|
+
|
|
441
593
|
async function claude_delivery(config, binding, task, spawn_process = spawn) {
|
|
594
|
+
const window = config.project_access === 'own' && binding.session_id && binding.session_id === config.session_id ? claude_window(config.session_id) : null;
|
|
595
|
+
// Only a task written for the window names its credential file; a background process gets it through its environment.
|
|
596
|
+
if (window?.inbox && task.inbox) return claude_inbox_delivery(config, window.inbox, task);
|
|
597
|
+
binding = adopted_fork(config, binding, Boolean(window));
|
|
442
598
|
const session_id = binding.session_id || randomUUID();
|
|
443
599
|
try { bind_access(task.access_path, session_id); }
|
|
444
600
|
catch (error) { throw not_sent(error); }
|
|
445
601
|
// Streamed events show the turn's progress; the final line is still the result that decides success.
|
|
446
|
-
|
|
602
|
+
// A rewound or forked member first forks its earlier session into the ID it is now bound to, cut at `at` when given.
|
|
603
|
+
const fork = binding.fork ? ['--resume', binding.fork.from, '--fork-session', ...(binding.fork.at ? ['--resume-session-at', binding.fork.at] : []), '--session-id', session_id]
|
|
604
|
+
: [binding.session_id ? '--resume' : '--session-id', session_id];
|
|
605
|
+
const args = ['-p', '--output-format', 'stream-json', '--verbose', ...fork,
|
|
447
606
|
...(config.model ? ['--model', config.model] : []), ...(config.effort ? ['--effort', config.effort] : []), ...claude_permissions(config, task)];
|
|
448
607
|
let child;
|
|
449
608
|
try {
|
|
@@ -461,6 +620,8 @@ async function claude_delivery(config, binding, task, spawn_process = spawn) {
|
|
|
461
620
|
watch_activity('claude', child.stdout, task);
|
|
462
621
|
child.once('close', code => {
|
|
463
622
|
if (!spawned) return;
|
|
623
|
+
const denials = claude_denials(output);
|
|
624
|
+
if (denials.length) task.on_denials?.(denials);
|
|
464
625
|
const error = claude_exit_error(code, output);
|
|
465
626
|
if (error) task.on_failure(error);
|
|
466
627
|
else task.on_exit?.();
|
|
@@ -476,15 +637,26 @@ async function claude_delivery(config, binding, task, spawn_process = spawn) {
|
|
|
476
637
|
}
|
|
477
638
|
|
|
478
639
|
/** Build a per-invocation profile without inheriting a user's custom profile of the same name. */
|
|
479
|
-
export function codex_permissions(config, task) {
|
|
640
|
+
export function codex_permissions(config, task, platform = process.platform) {
|
|
641
|
+
// 完全放开 runs without approval or sandbox, so there is no profile to build or check.
|
|
642
|
+
if (config.project_access === 'full') return { name: '', args: ['--dangerously-bypass-approvals-and-sandbox'] };
|
|
480
643
|
const name = `codiscuss-${randomUUID()}`;
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
|
|
644
|
+
// A taken-over manual member writes like 可写: its own config may sandbox read-only and then could not save its answer draft.
|
|
645
|
+
const base = ['auto', 'own'].includes(config.project_access) || (project_directory(config) && config.project_access === 'write')
|
|
646
|
+
? ':workspace' : ':read-only';
|
|
647
|
+
const drafts = resolve(task.draft_directory).replaceAll('\\', '/');
|
|
648
|
+
const filesystem = { ':root': 'read', [resolve(task.directory).replaceAll('\\', '/')]: 'read', [drafts]: 'write' };
|
|
484
649
|
const rules = Object.entries(filesystem).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',');
|
|
485
650
|
const profile = `{extends=${JSON.stringify(base)},filesystem={${rules}},network={enabled=true}}`;
|
|
486
|
-
|
|
487
|
-
|
|
651
|
+
// The Windows sandbox account cannot write the user's TEMP, so PowerShell starts in ConstrainedLanguage and Codex's
|
|
652
|
+
// UTF-8 output setup fails silently, garbling Chinese; a TEMP inside the writable drafts keeps FullLanguage.
|
|
653
|
+
const temp = platform === 'win32' ? ['-c', `shell_environment_policy.set.TEMP=${JSON.stringify(drafts)}`,
|
|
654
|
+
'-c', `shell_environment_policy.set.TMP=${JSON.stringify(drafts)}`] : [];
|
|
655
|
+
// 自动 is what `codex exec --approve-for-me` sets (that flag is missing on `exec resume`): the model asks to escalate
|
|
656
|
+
// and Codex's automatic review answers instead of a person (tested 0.163).
|
|
657
|
+
const approval = config.project_access === 'auto' ? ['-c', 'approval_policy="on-request"', '-c', 'approvals_reviewer="auto_review"']
|
|
658
|
+
: ['-c', 'approval_policy="never"'];
|
|
659
|
+
return { name, args: ['-c', `default_permissions=${JSON.stringify(name)}`, '-c', `permissions.${name}=${profile}`, ...approval, ...temp] };
|
|
488
660
|
}
|
|
489
661
|
|
|
490
662
|
/** Read effective Codex config through its local CLI; no thread or model is started. */
|
|
@@ -583,15 +755,20 @@ function cli_event(adapter, state, event, task) {
|
|
|
583
755
|
}
|
|
584
756
|
if (event.event === 'init') bind_cli_session(state, event.conversation_id, task);
|
|
585
757
|
const step = event.step_update;
|
|
586
|
-
// Headless agy ends its turn when
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
state.
|
|
758
|
+
// Headless agy ends its turn when an operation needs approval; remember which ones so the page can name and allow them.
|
|
759
|
+
const denied = step?.state === 'ERROR' ? agy_denial(step) : null;
|
|
760
|
+
if (denied) {
|
|
761
|
+
if (!state.denials.some(item => item.detail === denied.detail) && state.denials.length < MAX_DENIALS) state.denials.push(denied);
|
|
762
|
+
state.denial = `${denied.tool === 'write_file' ? '写入' : '运行'}了未授权的${denied.tool === 'write_file' ? '文件' : '命令'}「${denied.detail.slice(0, 120)}」,无人值守模式下无法确认权限而被自动拒绝,随后会话结束`;
|
|
590
763
|
}
|
|
591
764
|
if (event.event === 'result') {
|
|
592
765
|
const result = event.result || {};
|
|
593
766
|
if (result.conversation_id) bind_cli_session(state, result.conversation_id, task);
|
|
594
767
|
state.completed = result.status === 'SUCCESS';
|
|
768
|
+
// A refused operation still ends with SUCCESS and an empty response; name it even when no failed step was seen.
|
|
769
|
+
if (!state.denial && Array.isArray(result.denied_actions) && result.denied_actions.length) {
|
|
770
|
+
state.denial = `的${result.denied_actions.map(item => item.display_name || item.action).join('、')}操作未获授权,无人值守模式下被自动拒绝,随后会话结束`;
|
|
771
|
+
}
|
|
595
772
|
if (!state.completed) state.error = `Antigravity CLI ${result.status || '缺少结果状态'}:${result.error?.message || result.response || '执行未成功'}`;
|
|
596
773
|
}
|
|
597
774
|
}
|
|
@@ -623,7 +800,7 @@ function cli_exit_error(state, code, signal) {
|
|
|
623
800
|
|
|
624
801
|
/** Parse complete UTF-8 JSON lines while keeping raw output in the private task log. */
|
|
625
802
|
function monitor_cli(child, config, binding, task) {
|
|
626
|
-
const state = { child, session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '', responses: new Map() };
|
|
803
|
+
const state = { child, session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '', responses: new Map(), denials: [] };
|
|
627
804
|
const log_path = resolve(task.directory, `${config.adapter}-${task.id}.log`);
|
|
628
805
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
629
806
|
const max_run_ms = Number(process.env.CODISCUSS_CLI_MAX_MS) || CLI_MAX_RUN_MS;
|
|
@@ -653,6 +830,7 @@ function monitor_cli(child, config, binding, task) {
|
|
|
653
830
|
clearTimeout(state.limit_timer);
|
|
654
831
|
task.on_trace?.('cli_closed', { code, signal, killed_after_completed: Boolean(state.finished) });
|
|
655
832
|
if (!state.spawned) return;
|
|
833
|
+
if (state.denials.length) task.on_denials?.(state.denials);
|
|
656
834
|
const error = cli_exit_error(state, code, signal);
|
|
657
835
|
if (error) task.on_failure(error);
|
|
658
836
|
else task.on_exit?.(state.denial);
|
|
@@ -669,7 +847,7 @@ async function cli_delivery(config, binding, task, spawn_process, user_home) {
|
|
|
669
847
|
let invocation;
|
|
670
848
|
try { invocation = cli_invocation(config, binding, task, user_home); }
|
|
671
849
|
catch (error) { throw not_sent(error); }
|
|
672
|
-
if (invocation.permissions) {
|
|
850
|
+
if (invocation.permissions?.name) {
|
|
673
851
|
task.on_trace?.('permission_check_start');
|
|
674
852
|
await check_codex_permissions(config, task, invocation.permissions, spawn_process);
|
|
675
853
|
task.on_trace?.('permission_check_done');
|
|
@@ -704,7 +882,8 @@ async function antigravity_delivery(config, binding, task) {
|
|
|
704
882
|
try {
|
|
705
883
|
mkdirSync(directory, { recursive: true });
|
|
706
884
|
const ready = JSON.parse(readFileSync(join(directory, 'ready.json'), 'utf8'));
|
|
707
|
-
|
|
885
|
+
// Only a new conversation is created in the Sidecar's project; an existing one is reached from any project.
|
|
886
|
+
if (!binding.session_id && (config.project_id || config.project_name || config.cwd)) {
|
|
708
887
|
const project = antigravity_project(config);
|
|
709
888
|
if (ready.project_id !== project.id) throw new Error('Sidecar 所属项目与配置不符,请先运行 setup-sidecar');
|
|
710
889
|
}
|