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
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code adapter: one headless `claude -p` process per turn resuming the bound session, or, for a taken-over manual
|
|
3
|
+
* member whose window is open, a task posted into that window. Sessions are branched by Claude's own --fork-session.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { connect } from 'node:net';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { runs_commands } from '../expert_prompts.mjs';
|
|
12
|
+
import { bind_access, DENIAL_FIELD, EDIT_TOOLS, launch_directory, log_entries, marked, MAX_DENIALS, model_entry, json_line,
|
|
13
|
+
newest_record, not_sent, project_directory, prompt_text, query_catalog, TASK_SUBCOMMANDS, tree_killable, watch_events } from './common.mjs';
|
|
14
|
+
|
|
15
|
+
// A message posted to an open Claude window that has not started by then is reported, not failed.
|
|
16
|
+
const INBOX_CHECK_MS = 90_000;
|
|
17
|
+
const EDIT_TOOL_NAMES = /^(Edit|MultiEdit|Write|NotebookEdit)$/;
|
|
18
|
+
// Claude Code logs side queries (titles, compaction, subagents) too; only these sources are the member's own turns.
|
|
19
|
+
const CLAUDE_MAIN_SOURCES = ['sdk', 'repl_main_thread'];
|
|
20
|
+
// A background process can be stopped and streams its progress; a turn in the member's own window does neither.
|
|
21
|
+
const PROCESS = { stoppable: true, progress: true };
|
|
22
|
+
const WINDOW = { stoppable: false, progress: false };
|
|
23
|
+
|
|
24
|
+
/** Explain why a finished Claude print run failed, including errors reported with exit code 0. */
|
|
25
|
+
export function claude_exit_error(code, output) {
|
|
26
|
+
let result = null;
|
|
27
|
+
try { result = JSON.parse(output.trim().split(/\r?\n/).at(-1)); } catch { /* Non-JSON output keeps the exit code check. */ }
|
|
28
|
+
if (result?.is_error) return `Claude 执行出错:${result.result || result.api_error || result.subtype || '未知错误'}`;
|
|
29
|
+
return code === 0 ? '' : `Claude 退出码 ${code}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The first program a shell command runs, skipping `cd` and `Set-Location` steps, for a prefix rule that also allows a retry. */
|
|
33
|
+
function command_program(command) {
|
|
34
|
+
const steps = String(command).split(/&&|\|\||;|\|/).map(step => step.trim().split(/\s+/)[0]).filter(Boolean);
|
|
35
|
+
return steps.find(step => !/^(cd|Set-Location|sl|pushd)$/i.test(step)) || steps[0] || '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The operations a headless Claude run refused, from the `permission_denials` of its final result line, each with the
|
|
40
|
+
* `--allowedTools` rule that would let the next run do it: a command prefix, an edit of that folder, or the tool itself.
|
|
41
|
+
*/
|
|
42
|
+
export function claude_denials(output) {
|
|
43
|
+
let result = null;
|
|
44
|
+
try { result = JSON.parse(output.trim().split(/\r?\n/).at(-1)); } catch { return []; }
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
return (Array.isArray(result?.permission_denials) ? result.permission_denials : []).map(denial => {
|
|
47
|
+
const tool = String(denial.tool_name || '').slice(0, 40), input = denial.tool_input || {};
|
|
48
|
+
if (['Bash', 'PowerShell'].includes(tool) && input.command) {
|
|
49
|
+
const program = command_program(input.command);
|
|
50
|
+
return { tool, detail: String(input.command), rule: program ? `${tool}(${program} *)` : '' };
|
|
51
|
+
}
|
|
52
|
+
const path = input.file_path || input.notebook_path;
|
|
53
|
+
if (EDIT_TOOL_NAMES.test(tool) && path) return { tool, detail: String(path), rule: `Edit(${claude_rule_path(dirname(path))}/**)` };
|
|
54
|
+
return { tool, detail: JSON.stringify(input), rule: tool };
|
|
55
|
+
}).filter(item => item.tool && !seen.has(item.detail) && seen.add(item.detail))
|
|
56
|
+
.slice(0, MAX_DENIALS).map(item => ({ ...item, detail: item.detail.slice(0, DENIAL_FIELD), rule: item.rule.length <= DENIAL_FIELD ? item.rule : '' }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Drop variables a hosting Claude session injects, so the spawned CLI uses its own login.
|
|
61
|
+
* The host also hides the user's persisted login variables, which are read back on Windows.
|
|
62
|
+
*/
|
|
63
|
+
export function standalone_claude_env(env = process.env, persisted = persisted_claude_env) {
|
|
64
|
+
if (env.CLAUDECODE !== '1') return env;
|
|
65
|
+
const own = Object.entries(env).filter(([key]) =>
|
|
66
|
+
!/^(CLAUDECODE|CLAUDE_PID|CLAUDE_EFFORT)$|^(CLAUDE_CODE_|CLAUDE_AGENT_SDK_|CLAUDE_PREVIEW_)/.test(key));
|
|
67
|
+
return { ...Object.fromEntries(own), ...persisted() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let persisted_cache;
|
|
71
|
+
/** Read Machine then User persisted ANTHROPIC_/CLAUDE variables once; values stay in memory only. */
|
|
72
|
+
function persisted_claude_env() {
|
|
73
|
+
if (process.platform !== 'win32') return {};
|
|
74
|
+
if (!persisted_cache) {
|
|
75
|
+
const script = "$r=@{}; foreach($s in 'Machine','User'){ $v=[Environment]::GetEnvironmentVariables($s);"
|
|
76
|
+
+ " foreach($k in $v.Keys){ if($k -match '^(ANTHROPIC_|CLAUDE)'){ $r[$k]=$v[$k] } } }; $r | ConvertTo-Json -Compress";
|
|
77
|
+
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true });
|
|
78
|
+
try { persisted_cache = result.status === 0 && result.stdout.trim() ? JSON.parse(result.stdout) : {}; }
|
|
79
|
+
catch { persisted_cache = {}; }
|
|
80
|
+
}
|
|
81
|
+
return persisted_cache;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Claude permission rules match Windows paths in POSIX form, e.g. C:\a becomes //c/a. */
|
|
85
|
+
export function claude_rule_path(directory) {
|
|
86
|
+
const path = resolve(directory).replaceAll('\\', '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`);
|
|
87
|
+
return `/${path}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Allow the listed codiscuss website subcommands and draft writes for this run; a writable member also edits its project
|
|
92
|
+
* and runs any command. User rules are added after. Claude Code on Windows may run commands through its PowerShell tool
|
|
93
|
+
* instead of Bash, so both are listed.
|
|
94
|
+
*/
|
|
95
|
+
export function claude_permissions(config, task) {
|
|
96
|
+
// A headless run cannot ask for approval: 自动 and a taken-over manual member use auto mode, as the original window
|
|
97
|
+
// does, and 全放开 skips every check; the website commands and drafts stay pre-approved either way.
|
|
98
|
+
if (['own', 'auto', 'full'].includes(config.project_access)) {
|
|
99
|
+
const commands = TASK_SUBCOMMANDS.flatMap(command => [`Bash(codiscuss ${command} *)`, `PowerShell(codiscuss ${command} *)`]);
|
|
100
|
+
return ['--permission-mode', config.project_access === 'full' ? 'bypassPermissions' : 'auto', '--add-dir', task.directory, task.draft_directory, task.skills_directory, '--allowedTools',
|
|
101
|
+
...commands, `Edit(${claude_rule_path(task.draft_directory)}/**)`, ...(config.allowed_tools || [])];
|
|
102
|
+
}
|
|
103
|
+
const commands = runs_commands(config) ? ['Bash', 'PowerShell']
|
|
104
|
+
: TASK_SUBCOMMANDS.flatMap(command => [`Bash(codiscuss ${command} *)`, `PowerShell(codiscuss ${command} *)`]);
|
|
105
|
+
const writable = [task.draft_directory, ...(runs_commands(config) ? [project_directory(config)] : [])];
|
|
106
|
+
return ['--add-dir', task.directory, task.draft_directory, task.skills_directory, '--allowedTools',
|
|
107
|
+
...commands, ...writable.map(directory => `Edit(${claude_rule_path(directory)}/**)`), ...(config.allowed_tools || [])];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What Claude's stream-json events say the agent is doing, which tool it starts, and text it wrote to the user. */
|
|
111
|
+
const events = {
|
|
112
|
+
phase(event) {
|
|
113
|
+
if (event.type === 'system' || event.type === 'user') return 'thinking';
|
|
114
|
+
const block = event.type === 'assistant' ? event.message?.content?.at(-1) : null;
|
|
115
|
+
if (block?.type === 'tool_use') return /^(Bash|PowerShell)$/.test(block.name) ? 'command' : EDIT_TOOLS.test(block.name) ? 'editing' : 'tool';
|
|
116
|
+
return block?.type === 'text' ? 'writing' : block?.type === 'thinking' ? 'thinking' : '';
|
|
117
|
+
},
|
|
118
|
+
tool(event) {
|
|
119
|
+
const block = event.type === 'assistant' ? event.message?.content?.at(-1) : null;
|
|
120
|
+
return block?.type === 'tool_use' ? String(block.name || '') : '';
|
|
121
|
+
},
|
|
122
|
+
note(event) {
|
|
123
|
+
if (event.type !== 'assistant') return '';
|
|
124
|
+
return (event.message?.content || []).filter(block => block.type === 'text').map(block => block.text).join('\n').trim();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The open Claude Code window holding one session, with its cross-session inbox, or null when no live session holds it.
|
|
130
|
+
* Read from the registry each running session writes; `inbox` is null on a version without one. Not a public interface:
|
|
131
|
+
* the peer token and message line are what Claude's own sessions use (tested on 2.1.312).
|
|
132
|
+
*/
|
|
133
|
+
export function claude_window(session_id, env = process.env) {
|
|
134
|
+
const directory = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'sessions');
|
|
135
|
+
if (!session_id || !existsSync(directory)) return null;
|
|
136
|
+
for (const name of readdirSync(directory).filter(item => /^\d+\.json$/.test(item))) {
|
|
137
|
+
let record;
|
|
138
|
+
try { record = JSON.parse(readFileSync(join(directory, name), 'utf8')); } catch { continue; }
|
|
139
|
+
if (record.sessionId !== session_id) continue;
|
|
140
|
+
try { process.kill(Number(record.pid), 0); } catch { continue; }
|
|
141
|
+
const key = readdirSync(directory).find(item => item.startsWith(`${record.pid}.`) && item.endsWith('.key'));
|
|
142
|
+
let token = '';
|
|
143
|
+
try { token = key ? JSON.parse(readFileSync(join(directory, key), 'utf8')).peerToken || '' : ''; } catch { /* no inbox credential */ }
|
|
144
|
+
return { pid: Number(record.pid), inbox: record.messagingSocketPath && token ? { socket: record.messagingSocketPath, token } : null };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether this execution goes into a manual member's own open window (taken over with `own` permissions) rather than a background process. */
|
|
150
|
+
export function claude_inbox_ready(profile, binding) {
|
|
151
|
+
return profile.adapter === 'claude' && profile.project_access === 'own' && Boolean(profile.session_id)
|
|
152
|
+
&& binding.session_id === profile.session_id && Boolean(claude_window(profile.session_id)?.inbox);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A taken-over manual Claude member whose session a window without an inbox still holds: resuming that session from
|
|
157
|
+
* here would split its file into two branches neither side sees in full, so its first hosted turn forks it and later
|
|
158
|
+
* turns resume the fork; a fork whose session was never written is retried under the same ID. Once no window holds
|
|
159
|
+
* the session, it is resumed as it is.
|
|
160
|
+
*/
|
|
161
|
+
export function adopted_fork(config, binding, held, exists = id => session_exists(id)) {
|
|
162
|
+
if (config.project_access !== 'own' || !config.session_id || binding.fork || !binding.session_id) return binding;
|
|
163
|
+
if (binding.session_id !== config.session_id) return exists(binding.session_id) ? binding : { ...binding, fork: { from: config.session_id, at: '' } };
|
|
164
|
+
return held ? { ...binding, session_id: randomUUID(), fork: { from: config.session_id, at: '' } } : binding;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Post one task into an open Claude window; it starts a turn there, or between tool calls of the turn it is in. */
|
|
168
|
+
async function inbox_delivery(config, inbox, task) {
|
|
169
|
+
try { bind_access(task.access_path, config.session_id); }
|
|
170
|
+
catch (error) { throw not_sent(error); }
|
|
171
|
+
const text = `CoDiscuss 本机服务代房间转达(你已注册为该房间成员,这是房间分派给你的任务):\n${task.prompt}`;
|
|
172
|
+
const lines = [{ type: 'auth', token: inbox.token }, { type: 'user', message: { role: 'user', content: text } }];
|
|
173
|
+
await new Promise((resolve_sent, reject) => {
|
|
174
|
+
let written = false;
|
|
175
|
+
const socket = connect(inbox.socket, () => {
|
|
176
|
+
socket.end(lines.map(line => `${JSON.stringify(line)}\n`).join(''), () => { written = true; resolve_sent(); });
|
|
177
|
+
});
|
|
178
|
+
socket.on('error', error => written ? undefined : reject(not_sent(error)));
|
|
179
|
+
});
|
|
180
|
+
// The discussion runner enters the execution once the session is known, as it does when a process reports it.
|
|
181
|
+
task.on_binding?.(config.session_id);
|
|
182
|
+
const marker = `${task.id}.task.json`;
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
if (!session_mentions(config.session_id, marker)) {
|
|
185
|
+
task.on_note?.('已投递到原窗口,但它还没开始处理:原窗口可能处于跳过审批模式(需在窗口中批准这条消息)或仍在忙。');
|
|
186
|
+
}
|
|
187
|
+
}, INBOX_CHECK_MS).unref();
|
|
188
|
+
return { session_id: config.session_id, status: 'accepted', capabilities: WINDOW };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Whether a Claude session's log contains some text, e.g. the task file a delivered prompt names. */
|
|
192
|
+
function session_mentions(session_id, text, env = process.env) {
|
|
193
|
+
const file = session_path(session_id, env, homedir());
|
|
194
|
+
try { return Boolean(file) && readFileSync(file, 'utf8').includes(text); } catch { return false; }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Use one official Claude process per turn and resume the persisted session, or post into the member's open window. */
|
|
198
|
+
async function deliver(config, binding, task, spawn_process = spawn) {
|
|
199
|
+
const window = config.project_access === 'own' && binding.session_id && binding.session_id === config.session_id ? claude_window(config.session_id) : null;
|
|
200
|
+
// Only a task written for the window names its credential file; a background process gets it through its environment.
|
|
201
|
+
if (window?.inbox && task.inbox) return inbox_delivery(config, window.inbox, task);
|
|
202
|
+
binding = adopted_fork(config, binding, Boolean(window));
|
|
203
|
+
const session_id = binding.session_id || randomUUID();
|
|
204
|
+
try { bind_access(task.access_path, session_id); }
|
|
205
|
+
catch (error) { throw not_sent(error); }
|
|
206
|
+
// Streamed events show the turn's progress; the final line is still the result that decides success.
|
|
207
|
+
// A rewound or forked member first forks its earlier session into the ID it is now bound to, cut at `at` when given.
|
|
208
|
+
const fork = binding.fork ? ['--resume', binding.fork.from, '--fork-session', ...(binding.fork.at ? ['--resume-session-at', binding.fork.at] : []), '--session-id', session_id]
|
|
209
|
+
: [binding.session_id ? '--resume' : '--session-id', session_id];
|
|
210
|
+
const args = ['-p', '--output-format', 'stream-json', '--verbose', ...fork,
|
|
211
|
+
...(config.model ? ['--model', config.model] : []), ...(config.effort ? ['--effort', config.effort] : []), ...claude_permissions(config, task)];
|
|
212
|
+
let child;
|
|
213
|
+
try {
|
|
214
|
+
child = spawn_process(config.executable, [...args, '--', task.prompt], {
|
|
215
|
+
cwd: launch_directory(config, task), windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'],
|
|
216
|
+
env: { ...standalone_claude_env(), DISCUSS_ACCESS_FILE: task.access_path },
|
|
217
|
+
});
|
|
218
|
+
} catch (error) { throw not_sent(error); }
|
|
219
|
+
tree_killable(child);
|
|
220
|
+
let spawned = false;
|
|
221
|
+
const log_path = resolve(task.directory, `claude-${task.id}.log`);
|
|
222
|
+
let output = '';
|
|
223
|
+
child.stdout.on('data', chunk => { output += chunk; task.on_output(log_path, chunk); });
|
|
224
|
+
child.stderr.on('data', chunk => task.on_output(log_path, chunk));
|
|
225
|
+
watch_events(claude, child.stdout, task);
|
|
226
|
+
child.once('close', code => {
|
|
227
|
+
if (!spawned) return;
|
|
228
|
+
const denials = claude_denials(output);
|
|
229
|
+
if (denials.length) task.on_denials?.(denials);
|
|
230
|
+
const error = claude_exit_error(code, output);
|
|
231
|
+
if (error) task.on_failure(error);
|
|
232
|
+
else task.on_exit?.();
|
|
233
|
+
});
|
|
234
|
+
task.on_process(child);
|
|
235
|
+
await new Promise((resolve_spawn, reject) => {
|
|
236
|
+
child.once('spawn', () => { spawned = true; resolve_spawn(); });
|
|
237
|
+
child.once('error', error => spawned ? task.on_failure(error.message) : reject(not_sent(error)));
|
|
238
|
+
});
|
|
239
|
+
// A failed spawn creates no session; only a successfully started process can be resumed.
|
|
240
|
+
task.on_binding(session_id);
|
|
241
|
+
return { session_id, status: 'accepted', capabilities: PROCESS };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Where Claude keeps one session's log, or '' when this machine has none by that ID. */
|
|
245
|
+
function session_path(session_id, env = process.env, home = homedir()) {
|
|
246
|
+
const projects = join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects');
|
|
247
|
+
if (!existsSync(projects)) return '';
|
|
248
|
+
return readdirSync(projects).map(dir => join(projects, dir, `${session_id}.jsonl`)).find(existsSync) || '';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Whether Claude stored a session by that ID on this machine, e.g. whether a pending fork already happened. */
|
|
252
|
+
function session_exists(session_id, { env = process.env, home = homedir() } = {}) {
|
|
253
|
+
return Boolean(session_id && session_path(session_id, env, home));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The directory a session was made in, from its log; Claude resumes a session only there. '' when it recorded none. */
|
|
257
|
+
function session_cwd(session_id, { env = process.env, home = homedir() } = {}) {
|
|
258
|
+
const file = session_path(session_id, env, home);
|
|
259
|
+
if (!file) return '';
|
|
260
|
+
return readFileSync(file, 'utf8').split('\n').map(line => { try { return JSON.parse(line).cwd; } catch { return undefined; } }).find(Boolean) || '';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Fork on the next turn at the last answer before the first marked prompt; a cut before any answer starts anew. */
|
|
264
|
+
function branch(profile, session_id, markers, { always, env, home }) {
|
|
265
|
+
const file = session_path(session_id, env, home);
|
|
266
|
+
if (!file) throw new Error(`本机找不到 Claude 会话 ${session_id}`);
|
|
267
|
+
const entries = log_entries(file);
|
|
268
|
+
const index = entries.findIndex(entry => entry.type === 'user' && marked(prompt_text(entry.message?.content), markers));
|
|
269
|
+
if (index < 0) return always ? { session_id: randomUUID(), fork: { from: session_id, at: '' } } : null;
|
|
270
|
+
const at = entries.slice(0, index).findLast(entry => entry.type === 'assistant' && entry.uuid)?.uuid;
|
|
271
|
+
return at ? { session_id: randomUUID(), fork: { from: session_id, at } } : { session_id: '' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Claude's user settings: what it runs with when the card leaves model or effort empty. */
|
|
275
|
+
function defaults(profile, { env = process.env, home = homedir() } = {}) {
|
|
276
|
+
try {
|
|
277
|
+
const settings = JSON.parse(readFileSync(join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'settings.json'), 'utf8'));
|
|
278
|
+
return { model: typeof settings.model === 'string' ? settings.model : '', effort: typeof settings.effortLevel === 'string' ? settings.effortLevel : '' };
|
|
279
|
+
} catch { return { model: '', effort: '' }; }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The model and effort a session last ran with, from its own log; no turn is started. */
|
|
283
|
+
function session_settings(session_id, { env = process.env, home = homedir() } = {}) {
|
|
284
|
+
const file = session_path(session_id, env, home);
|
|
285
|
+
if (!file) return null;
|
|
286
|
+
return newest_record(file, '"type":"api-request"', record => record.type === 'api-request' && CLAUDE_MAIN_SOURCES.includes(record.querySource)
|
|
287
|
+
&& typeof record.params?.model === 'string' ? { model: record.params.model, effort: String(record.params.output_config?.effort || '') } : null);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Claude's initialization exposes its model picker; the query process never persists a session or loads MCP servers. */
|
|
291
|
+
function models(profile, options) {
|
|
292
|
+
const args = ['--safe-mode', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json',
|
|
293
|
+
'--verbose', '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'];
|
|
294
|
+
return query_catalog(profile, args, (send, finish) => ({
|
|
295
|
+
start: () => send({ type: 'control_request', request_id: 'catalog', request: { subtype: 'initialize' } }),
|
|
296
|
+
line: line => {
|
|
297
|
+
const reply = json_line(line);
|
|
298
|
+
if (reply?.type !== 'control_response' || reply.response?.request_id !== 'catalog') return;
|
|
299
|
+
if (reply.response.subtype !== 'success') return finish(new Error('Claude 模型目录查询失败,请检查 CLI 版本与登录'));
|
|
300
|
+
finish(null, (reply.response.response?.models || []).map(model => model_entry(model.value, model.displayName,
|
|
301
|
+
model.supportedEffortLevels || (model.supportsEffort ? undefined : []))));
|
|
302
|
+
},
|
|
303
|
+
}), options);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Claude Code's behaviour; its static facts are in profiles.mjs. */
|
|
307
|
+
export const claude = {
|
|
308
|
+
id: 'claude', catalog_source: 'claude-cli',
|
|
309
|
+
into_window: claude_inbox_ready,
|
|
310
|
+
deliver, events,
|
|
311
|
+
session: { path: session_path, exists: session_exists, owns: session_exists, cwd: session_cwd, branch, settings: session_settings },
|
|
312
|
+
models: { list: models, defaults },
|
|
313
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One headless CLI turn, shared by the CLI adapters (Codex CLI, agy CLI). The adapter supplies what differs:
|
|
3
|
+
* `invocation(config, binding, task, user_home)` → `{args, input?, check?, on_session?}`, `lifecycle(state, event, task, bind)`
|
|
4
|
+
* for its documented events, `events` for progress, and optionally `on_stderr(state, text)` and `exit_error(state)`.
|
|
5
|
+
*/
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { bind_access, launch_directory, not_sent, tree_killable } from './common.mjs';
|
|
9
|
+
import { adapter_capabilities } from './profiles.mjs';
|
|
10
|
+
|
|
11
|
+
const CLI_DIAGNOSTIC_CHARS = 1000;
|
|
12
|
+
// A hung CLI keeps its lease renewed forever when the room sets no execution limit, so cap every process.
|
|
13
|
+
const CLI_MAX_RUN_MS = 2 * 60 * 60_000;
|
|
14
|
+
|
|
15
|
+
/** Persist an emitted identity before the agent reads its execution credential file. */
|
|
16
|
+
function bind_cli_session(state, session_id, task) {
|
|
17
|
+
if (typeof session_id !== 'string' || !session_id) throw new Error('CLI 未返回实际会话 ID');
|
|
18
|
+
if (state.session_id && state.session_id !== session_id) throw new Error('CLI 返回的会话 ID 与绑定不符,已停止本次执行');
|
|
19
|
+
task.on_binding(session_id);
|
|
20
|
+
bind_access(task.access_path, session_id);
|
|
21
|
+
state.session_id = session_id;
|
|
22
|
+
state.initialized = true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Record lifecycle events and tool commands with timestamps so slow stages can be located from the trace file. */
|
|
26
|
+
function trace_cli_event(event, task) {
|
|
27
|
+
if (!task.on_trace) return;
|
|
28
|
+
const item = event.item;
|
|
29
|
+
const command = item?.command ? String(item.command).replace(/^.*?-Command\s+/, '').slice(0, 100) : '';
|
|
30
|
+
const type = event.type ?? event.event ?? 'unknown';
|
|
31
|
+
task.on_trace(item ? `${type}:${item.type}` : type, command ? { command } : {});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Distinguish process completion from successful CLI output and saved website work. */
|
|
35
|
+
function cli_exit_error(adapter, state, code, signal) {
|
|
36
|
+
const own = adapter.exit_error?.(state);
|
|
37
|
+
if (own) return own;
|
|
38
|
+
if (state.error) return state.error;
|
|
39
|
+
if (state.finished && state.completed) return '';
|
|
40
|
+
if (code !== 0) return `CLI ${signal ? `被信号 ${signal} 终止` : `退出码 ${code}`}:${state.diagnostic}`;
|
|
41
|
+
if (!state.initialized) return `CLI 未确认实际会话 ID:${state.diagnostic}`;
|
|
42
|
+
if (!state.completed) return `CLI 未返回成功结束事件:${state.diagnostic}`;
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse complete UTF-8 JSON lines while keeping raw output in the private task log. */
|
|
47
|
+
function monitor_cli(adapter, child, binding, task) {
|
|
48
|
+
const state = { child, session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '', responses: new Map(), denials: [] };
|
|
49
|
+
const log_path = resolve(task.directory, `${adapter.id}-${task.id}.log`);
|
|
50
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
51
|
+
const max_run_ms = Number(process.env.CODISCUSS_CLI_MAX_MS) || CLI_MAX_RUN_MS;
|
|
52
|
+
state.limit_timer = setTimeout(() => { state.error = `CLI 运行超过 ${Math.round(max_run_ms / 1000)} 秒仍未结束,已终止`; child.kill(); }, max_run_ms);
|
|
53
|
+
child.stdout.on('data', chunk => task.on_output(log_path, chunk));
|
|
54
|
+
child.stderr.setEncoding('utf8');
|
|
55
|
+
child.stderr.on('data', chunk => {
|
|
56
|
+
adapter.on_stderr?.(state, state.diagnostic + chunk);
|
|
57
|
+
state.diagnostic = (state.diagnostic + chunk).slice(-CLI_DIAGNOSTIC_CHARS).trim();
|
|
58
|
+
task.on_output(log_path, chunk);
|
|
59
|
+
});
|
|
60
|
+
lines.on('line', line => {
|
|
61
|
+
if (!line.trim()) return;
|
|
62
|
+
let event;
|
|
63
|
+
try { event = JSON.parse(line); }
|
|
64
|
+
catch { state.diagnostic = 'CLI 输出中存在无效 JSON,详情见本机日志'; return; }
|
|
65
|
+
trace_cli_event(event, task);
|
|
66
|
+
const phase = adapter.events.phase(event);
|
|
67
|
+
if (phase) task.on_activity?.(phase, adapter.events.tool(event));
|
|
68
|
+
const note = adapter.events.note(event, state.responses);
|
|
69
|
+
if (note) task.on_note?.(note);
|
|
70
|
+
try { adapter.lifecycle(state, event, task, session_id => bind_cli_session(state, session_id, task)); }
|
|
71
|
+
catch (error) { state.error = error.message; child.kill(); }
|
|
72
|
+
});
|
|
73
|
+
child.once('close', (code, signal) => {
|
|
74
|
+
clearTimeout(state.finish_timer);
|
|
75
|
+
clearTimeout(state.limit_timer);
|
|
76
|
+
task.on_trace?.('cli_closed', { code, signal, killed_after_completed: Boolean(state.finished) });
|
|
77
|
+
if (!state.spawned) return;
|
|
78
|
+
if (state.denials.length) task.on_denials?.(state.denials);
|
|
79
|
+
const error = cli_exit_error(adapter, state, code, signal);
|
|
80
|
+
if (error) task.on_failure(error);
|
|
81
|
+
else task.on_exit?.(state.denial);
|
|
82
|
+
});
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Start one headless CLI turn as the adapter describes it; resolves once the process runs. */
|
|
87
|
+
export async function cli_delivery(adapter, config, binding, task, spawn_process, user_home) {
|
|
88
|
+
if (binding.session_id) {
|
|
89
|
+
try { bind_access(task.access_path, binding.session_id); }
|
|
90
|
+
catch (error) { throw not_sent(error); }
|
|
91
|
+
}
|
|
92
|
+
let invocation;
|
|
93
|
+
try { invocation = adapter.invocation(config, binding, task, user_home); }
|
|
94
|
+
catch (error) { throw not_sent(error); }
|
|
95
|
+
if (invocation.check) {
|
|
96
|
+
task.on_trace?.('permission_check_start');
|
|
97
|
+
await invocation.check(spawn_process);
|
|
98
|
+
task.on_trace?.('permission_check_done');
|
|
99
|
+
}
|
|
100
|
+
if (task.before_model_start) {
|
|
101
|
+
try { await task.before_model_start(); }
|
|
102
|
+
catch (error) { throw not_sent(error); }
|
|
103
|
+
}
|
|
104
|
+
if (invocation.on_session) {
|
|
105
|
+
const on_binding = task.on_binding;
|
|
106
|
+
task = { ...task, on_binding: session_id => { invocation.on_session(session_id); on_binding(session_id); } };
|
|
107
|
+
}
|
|
108
|
+
let child;
|
|
109
|
+
try {
|
|
110
|
+
child = spawn_process(config.executable, invocation.args, {
|
|
111
|
+
cwd: launch_directory(config, task), windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'],
|
|
112
|
+
env: { ...process.env, DISCUSS_ACCESS_FILE: task.access_path },
|
|
113
|
+
});
|
|
114
|
+
} catch (error) { throw not_sent(error); }
|
|
115
|
+
tree_killable(child);
|
|
116
|
+
const state = monitor_cli(adapter, child, binding, task);
|
|
117
|
+
task.on_process(child);
|
|
118
|
+
await new Promise((resolve_spawn, reject) => {
|
|
119
|
+
child.once('spawn', () => { state.spawned = true; task.on_trace?.('cli_spawned'); resolve_spawn(); });
|
|
120
|
+
child.once('error', error => state.spawned ? task.on_failure(error.message) : reject(not_sent(error)));
|
|
121
|
+
});
|
|
122
|
+
child.stdin.on('error', error => { if (error.code !== 'EPIPE') task.on_failure(error.message); });
|
|
123
|
+
child.stdin.end(invocation.input);
|
|
124
|
+
task.on_trace?.('prompt_sent');
|
|
125
|
+
return { session_id: state.session_id, status: 'accepted', capabilities: adapter_capabilities(adapter.id).delivery };
|
|
126
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex App adapter: tasks go into a chat of the running Codex desktop, through its app-tools pipe or `codex queue`.
|
|
3
|
+
* The turn runs inside the App, so the runner can neither stop it nor see its progress. Threads are stored like the
|
|
4
|
+
* CLI's, so sessions and models come from the Codex CLI adapter.
|
|
5
|
+
*/
|
|
6
|
+
import { CodexAppClient } from './codex_app_client.mjs';
|
|
7
|
+
import { branch, desktop_thread, session_exists, session_path, session_settings } from './codex_cli.mjs';
|
|
8
|
+
import { bind_access, CALL_TIMEOUT_MS, not_sent, run_program, same_path } from './common.mjs';
|
|
9
|
+
|
|
10
|
+
// The desktop has no stop or interrupt tool (its tool list, checked 2026-10-30), and the turn's output stays in the App.
|
|
11
|
+
const IN_APP = { stoppable: false, progress: false };
|
|
12
|
+
|
|
13
|
+
/** Match the real saved project and its directory; a prompt cannot grant file access. */
|
|
14
|
+
export async function codex_target(app, config) {
|
|
15
|
+
if (!config.project_id && !config.project_name && !config.cwd) return { type: 'projectless' };
|
|
16
|
+
const projects = (await app.list_projects(config.source_thread_id)).filter(item => item.hostId === 'local');
|
|
17
|
+
const matching = projects.filter(item => (!config.project_id || item.projectId === config.project_id)
|
|
18
|
+
&& (!config.project_name || item.label === config.project_name)
|
|
19
|
+
&& (!config.cwd || same_path(item.path, config.cwd)));
|
|
20
|
+
if (matching.length !== 1) throw new Error('Codex 项目与 cwd 无法唯一匹配;请先在 App 添加该目录,核对 project_id/project_name/cwd');
|
|
21
|
+
return { type: 'project', projectId: matching[0].projectId, environment: { type: 'local' } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether the running App has not loaded a chat; when the App cannot tell, the queue is used as before. */
|
|
25
|
+
async function unloaded(config, thread_id) {
|
|
26
|
+
const app = new CodexAppClient(config.pipe_path);
|
|
27
|
+
try { await app.open(); return await app.thread_status(thread_id) === 'notLoaded'; }
|
|
28
|
+
catch { return false; }
|
|
29
|
+
finally { app.close(); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Deliver one task to Codex's real App, with the original chat reused afterward. */
|
|
33
|
+
async function deliver(config, binding, task) {
|
|
34
|
+
if (binding.session_id) {
|
|
35
|
+
try { bind_access(task.access_path, binding.session_id); }
|
|
36
|
+
catch (error) { throw not_sent(error); }
|
|
37
|
+
// `codex queue` reaches only a chat the App has loaded; a fork made after a rewind, or a chat after the App restarted,
|
|
38
|
+
// is not loaded until a message is sent through the App itself (tested on App tools, 2026-10).
|
|
39
|
+
if (await unloaded(config, binding.session_id)) {
|
|
40
|
+
const app = new CodexAppClient(config.pipe_path);
|
|
41
|
+
try { await app.open(); await app.send_message(binding.session_id, task.prompt); }
|
|
42
|
+
finally { app.close(); }
|
|
43
|
+
return { session_id: binding.session_id, status: 'accepted', capabilities: IN_APP };
|
|
44
|
+
}
|
|
45
|
+
const receipt = await run_program(config.executable, ['queue', '--thread', binding.session_id, '--message', task.prompt], { timeout: CALL_TIMEOUT_MS });
|
|
46
|
+
return { session_id: binding.session_id, status: 'accepted', receipt, capabilities: IN_APP };
|
|
47
|
+
}
|
|
48
|
+
const app = new CodexAppClient(config.pipe_path);
|
|
49
|
+
try {
|
|
50
|
+
let target;
|
|
51
|
+
try { await app.open(); target = await codex_target(app, config); }
|
|
52
|
+
catch (error) { throw not_sent(error); }
|
|
53
|
+
const result = await app.create_thread(config.source_thread_id, task.prompt, `CoDiscuss · ${task.participant}`, target);
|
|
54
|
+
task.on_binding(result.thread_id);
|
|
55
|
+
bind_access(task.access_path, result.thread_id);
|
|
56
|
+
return { session_id: result.thread_id, status: 'accepted', capabilities: IN_APP };
|
|
57
|
+
} finally { app.close(); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Codex App's behaviour; its static facts are in profiles.mjs. */
|
|
61
|
+
export const codex = {
|
|
62
|
+
id: 'codex',
|
|
63
|
+
/** A new chat is created from an actual source chat in the App. */
|
|
64
|
+
validate(participant) {
|
|
65
|
+
if (!participant.source_thread_id && !participant.session_id) throw new Error('Codex 新建入口需要实际来源聊天 ID');
|
|
66
|
+
},
|
|
67
|
+
deliver,
|
|
68
|
+
session: { path: session_path, exists: session_exists, owns: desktop_thread, branch, settings: session_settings },
|
|
69
|
+
models: null,
|
|
70
|
+
};
|