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.
Files changed (46) hide show
  1. package/.agents/skills/discuss-participant/SKILL.md +1 -1
  2. package/backend/free_room.py +2410 -2274
  3. package/backend/schema.sql +27 -0
  4. package/backend/server.py +21 -11
  5. package/docs/architecture.md +2 -1
  6. package/docs/deployment.md +10 -0
  7. package/docs/en/deployment.md +10 -0
  8. package/docs/en/workflow.md +1 -1
  9. package/docs/runner.md +1 -1
  10. package/docs/workflow.md +2 -2
  11. package/package.json +3 -2
  12. package/runner/adapters/antigravity_app.mjs +91 -0
  13. package/runner/adapters/antigravity_cli.mjs +320 -0
  14. package/runner/adapters/antigravity_sidecar.mjs +50 -0
  15. package/runner/{api_participant.mjs → adapters/api.mjs} +19 -5
  16. package/runner/adapters/claude.mjs +313 -0
  17. package/runner/adapters/cli.mjs +126 -0
  18. package/runner/adapters/codex_app.mjs +70 -0
  19. package/runner/adapters/codex_cli.mjs +286 -0
  20. package/runner/adapters/common.mjs +196 -0
  21. package/runner/adapters/index.mjs +143 -0
  22. package/runner/adapters/profiles.mjs +116 -0
  23. package/runner/admin_account.mjs +1 -1
  24. package/runner/discussion_launch.mjs +69 -79
  25. package/runner/discussion_local.mjs +1 -1
  26. package/runner/discussion_runner.mjs +14 -13
  27. package/runner/discussion_service.mjs +3 -3
  28. package/runner/discussion_setup.mjs +1 -1
  29. package/runner/discussion_sidecar.mjs +5 -50
  30. package/runner/discussion_skills.mjs +1 -1
  31. package/runner/execution_progress.mjs +99 -0
  32. package/runner/expert_prompts.mjs +19 -29
  33. package/runner/files.mjs +10 -0
  34. package/runner/free_runner.mjs +136 -30
  35. package/runner/remote_launch.mjs +10 -5
  36. package/runner/scheduler.mjs +124 -32
  37. package/runner/session_driver.mjs +5 -3
  38. package/web/app.js +116 -23
  39. package/web/backup.js +6 -6
  40. package/web/index.html +2 -0
  41. package/web/modern.css +1 -1
  42. package/web/modern.js +1699 -1353
  43. package/runner/discussion_adapters.mjs +0 -959
  44. package/runner/discussion_models.mjs +0 -271
  45. package/runner/session_cut.mjs +0 -167
  46. /package/runner/{codex_app_client.mjs → adapters/codex_app_client.mjs} +0 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Codex CLI adapter: one headless `codex exec` turn per execution resuming the bound thread, under a per-run permission
3
+ * profile. Its rollout files and app server also serve the Codex App adapter for sessions and models.
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join, resolve } from 'node:path';
10
+ import { createInterface } from 'node:readline';
11
+ import { cli_delivery } from './cli.mjs';
12
+ import { CALL_TIMEOUT_MS, launch_directory, log_entries, marked, model_entry, json_line, newest_record, not_sent,
13
+ project_directory, prompt_text, query_catalog } from './common.mjs';
14
+
15
+ /** Codex exec may linger after its final event; the finished turn is already saved, so stop it to free the session. */
16
+ const CLI_EXIT_GRACE_MS = 1500;
17
+ const CODEX_PERMISSIONS_MIN_MINOR = 163;
18
+ const FORK_TIMEOUT_MS = 60_000;
19
+
20
+ /** Build a per-invocation profile without inheriting a user's custom profile of the same name. */
21
+ export function codex_permissions(config, task, platform = process.platform) {
22
+ // 全放开 runs without approval or sandbox, so there is no profile to build or check.
23
+ if (config.project_access === 'full') return { name: '', args: ['--dangerously-bypass-approvals-and-sandbox'] };
24
+ const name = `codiscuss-${randomUUID()}`;
25
+ // A taken-over manual member writes like 可写: its own config may sandbox read-only and then could not save its answer draft.
26
+ const base = ['auto', 'own'].includes(config.project_access) || (project_directory(config) && config.project_access === 'write')
27
+ ? ':workspace' : ':read-only';
28
+ const drafts = resolve(task.draft_directory).replaceAll('\\', '/');
29
+ const filesystem = { ':root': 'read', [resolve(task.directory).replaceAll('\\', '/')]: 'read', [drafts]: 'write' };
30
+ const rules = Object.entries(filesystem).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',');
31
+ const profile = `{extends=${JSON.stringify(base)},filesystem={${rules}},network={enabled=true}}`;
32
+ // The Windows sandbox account cannot write the user's TEMP, so PowerShell starts in ConstrainedLanguage and Codex's
33
+ // UTF-8 output setup fails silently, garbling Chinese; a TEMP inside the writable drafts keeps FullLanguage.
34
+ const temp = platform === 'win32' ? ['-c', `shell_environment_policy.set.TEMP=${JSON.stringify(drafts)}`,
35
+ '-c', `shell_environment_policy.set.TMP=${JSON.stringify(drafts)}`] : [];
36
+ // 自动 is what `codex exec --approve-for-me` sets (that flag is missing on `exec resume`): the model asks to escalate
37
+ // and Codex's automatic review answers instead of a person (tested 0.163).
38
+ const approval = config.project_access === 'auto' ? ['-c', 'approval_policy="on-request"', '-c', 'approvals_reviewer="auto_review"']
39
+ : ['-c', 'approval_policy="never"'];
40
+ return { name, args: ['-c', `default_permissions=${JSON.stringify(name)}`, '-c', `permissions.${name}=${profile}`, ...approval, ...temp] };
41
+ }
42
+
43
+ /** Read effective Codex config through its local CLI; no thread or model is started. */
44
+ function read_codex_config(config, task, permissions, spawn_process) {
45
+ return new Promise((resolve_config, reject) => {
46
+ const child = spawn_process(config.executable, ['app-server', ...permissions.args], {
47
+ cwd: launch_directory(config, task), windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'],
48
+ });
49
+ task.on_process(child);
50
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
51
+ let settled = false;
52
+ const finish = (error, value) => {
53
+ if (settled) return;
54
+ settled = true;
55
+ clearTimeout(timer); lines.close();
56
+ const complete = () => error ? reject(not_sent(error)) : resolve_config(value);
57
+ if (child.exitCode !== null || child.signalCode !== null) complete();
58
+ else { child.once('close', complete); child.kill(); }
59
+ };
60
+ const timer = setTimeout(() => finish(new Error('Codex 权限配置检查超时,未交付讨论任务')), CALL_TIMEOUT_MS);
61
+ const send = (id, method, params) => child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
62
+ child.once('error', error => finish(error));
63
+ child.once('close', () => finish(new Error('Codex 无法读取权限配置,请使用支持 permission profiles 的 CLI')));
64
+ child.stdin.on('error', error => finish(error));
65
+ child.stderr.resume();
66
+ lines.on('line', line => {
67
+ let response;
68
+ try { response = JSON.parse(line); } catch { return; }
69
+ if (![1, 2].includes(response.id)) return;
70
+ if (response.error) return finish(new Error(`Codex 权限配置检查失败:${response.error.message}`));
71
+ if (response.id === 1) {
72
+ const version = response.result?.userAgent?.match(/\/(\d+)\.(\d+)\.\d+\b/);
73
+ if (!version || (Number(version[1]) === 0 && Number(version[2]) < CODEX_PERMISSIONS_MIN_MINOR)) {
74
+ return finish(new Error('本次权限配置需要 Codex CLI 0.163.0 或更新版本,未交付讨论任务'));
75
+ }
76
+ send(2, 'config/read', { cwd: launch_directory(config, task), includeLayers: false });
77
+ }
78
+ else finish(null, response.result?.config);
79
+ });
80
+ send(1, 'initialize', { clientInfo: { name: 'codiscuss', version: '0.1.0' } });
81
+ });
82
+ }
83
+
84
+ /** Reject legacy overrides that would silently replace the requested filesystem policy. */
85
+ async function check_codex_permissions(config, task, permissions, spawn_process) {
86
+ const effective = await read_codex_config(config, task, permissions, spawn_process).catch(error => { throw not_sent(error); });
87
+ if (effective?.sandbox_mode != null || effective?.sandbox_workspace_write != null) {
88
+ throw not_sent(new Error('Codex 配置含旧 sandbox_mode / sandbox_workspace_write,会覆盖本次权限;请移除旧配置后重试,未交付讨论任务'));
89
+ }
90
+ if (effective?.default_permissions !== permissions.name || !effective?.permissions?.[permissions.name]) {
91
+ throw not_sent(new Error('Codex 未采用本次 permission profile,未交付讨论任务;请更新 CLI'));
92
+ }
93
+ }
94
+
95
+ /** Native `codex exec` arguments; every continuation names the exact bound thread. */
96
+ function invocation(config, binding, task) {
97
+ const permissions = codex_permissions(config, task);
98
+ const args = ['exec', '--cd', launch_directory(config, task)];
99
+ if (binding.session_id) args.push('resume', binding.session_id);
100
+ args.push(...permissions.args, '--json', '--skip-git-repo-check', ...(config.model ? ['-m', config.model] : []),
101
+ ...(config.effort ? ['-c', `model_reasoning_effort="${config.effort}"`] : []), '-');
102
+ return { args, input: task.prompt,
103
+ ...(permissions.name ? { check: spawn_process => check_codex_permissions(config, task, permissions, spawn_process) } : {}) };
104
+ }
105
+
106
+ /** Keep the actual resume failure even when later model-refresh logs replace the diagnostic tail. */
107
+ function remember_writer_conflict(state, diagnostic) {
108
+ if (/thread-store conflict:[\s\S]*already has an active writer/.test(diagnostic)) state.writer_conflict = true;
109
+ }
110
+
111
+ /** Consume only documented lifecycle events, not text produced by agent tools. */
112
+ function lifecycle(state, event, task, bind) {
113
+ if (event.type === 'thread.started') bind(event.thread_id);
114
+ 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); }
115
+ if (event.type === 'turn.failed') state.error = event.error?.message || 'Codex CLI 执行失败';
116
+ if (event.type === 'error') state.diagnostic = event.message || 'Codex CLI 返回错误';
117
+ remember_writer_conflict(state, state.error || state.diagnostic);
118
+ }
119
+
120
+ /** A thread another process writes to cannot be resumed here; retrying does not release it. */
121
+ function exit_error(state) {
122
+ return state.writer_conflict ? `Codex 会话 ${state.session_id} 已被另一进程持有(active writer),CLI 无法接管。若为 App 中的聊天,请使用 Codex App 接入;独立 CLI 须使用未被占用的会话。重复点击重试不会解除占用` : '';
123
+ }
124
+
125
+ /** What `codex exec --json` events say the agent is doing, which tool it starts, and text it wrote to the user. */
126
+ const events = {
127
+ phase(event) {
128
+ if (event.type === 'turn.started') return 'thinking';
129
+ const kind = event.item?.type;
130
+ if (event.type === 'item.started') {
131
+ return { command_execution: 'command', file_change: 'editing', mcp_tool_call: 'tool', web_search: 'tool', reasoning: 'thinking' }[kind] || '';
132
+ }
133
+ if (event.type === 'item.completed') return kind === 'agent_message' ? 'writing' : 'thinking';
134
+ return '';
135
+ },
136
+ tool(event) {
137
+ if (event.type !== 'item.started') return '';
138
+ return event.item?.type === 'mcp_tool_call' ? String(event.item.tool || '') : event.item?.type === 'web_search' ? 'web_search' : '';
139
+ },
140
+ note(event) { return event.type === 'item.completed' && event.item?.type === 'agent_message' ? String(event.item.text || '').trim() : ''; },
141
+ };
142
+
143
+ /** Where Codex keeps one thread's rollout, or '' when this machine has none by that ID; shared by the App adapter. */
144
+ export function session_path(session_id, env = process.env, home = homedir()) {
145
+ const sessions = join(env.CODEX_HOME || join(home, '.codex'), 'sessions');
146
+ if (!existsSync(sessions)) return '';
147
+ const found = readdirSync(sessions, { recursive: true }).map(String).find(path => path.endsWith(`-${session_id}.jsonl`));
148
+ return found ? join(sessions, found) : '';
149
+ }
150
+
151
+ /** Codex turns in order, and the index of the first turn whose user prompt carries a marker (-1 when none does). */
152
+ function codex_turns(file, markers) {
153
+ const turns = [];
154
+ let cut = -1;
155
+ for (const entry of log_entries(file)) {
156
+ const payload = entry.payload || {};
157
+ if (entry.type === 'event_msg' && payload.type === 'task_started' && payload.turn_id) turns.push(payload.turn_id);
158
+ else if (cut < 0 && turns.length && entry.type === 'response_item' && payload.type === 'message' && payload.role === 'user'
159
+ && marked(prompt_text(payload.content), markers)) cut = turns.length - 1;
160
+ }
161
+ return { turns, cut };
162
+ }
163
+
164
+ /** Ask Codex's app server to fork a thread, through `last_turn` when given; returns the new thread's ID. */
165
+ function codex_fork(profile, thread_id, last_turn, spawn_process) {
166
+ return new Promise((resolve_fork, reject) => {
167
+ const child = spawn_process(profile.executable, ['app-server'], { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
168
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
169
+ let settled = false;
170
+ const finish = (error, value) => {
171
+ if (settled) return;
172
+ settled = true;
173
+ clearTimeout(timer); lines.close(); child.stdin.destroy(); child.kill();
174
+ if (error) reject(error); else resolve_fork(value);
175
+ };
176
+ const timer = setTimeout(() => finish(new Error('Codex 分叉会话超时')), FORK_TIMEOUT_MS);
177
+ const send = value => child.stdin.write(`${JSON.stringify(value)}\n`);
178
+ child.once('error', error => finish(error));
179
+ child.once('close', () => finish(new Error('Codex app-server 未完成分叉就退出了')));
180
+ child.stdin.on('error', error => finish(error));
181
+ child.stderr.resume();
182
+ lines.on('line', line => {
183
+ let reply;
184
+ try { reply = JSON.parse(line); } catch { return; }
185
+ if (reply.error && [1, 2].includes(reply.id)) return finish(new Error(`Codex 分叉会话失败:${reply.error.message}`));
186
+ if (reply.id === 1) {
187
+ send({ method: 'initialized', params: {} });
188
+ send({ id: 2, method: 'thread/fork', params: { threadId: thread_id, excludeTurns: true, ...(last_turn ? { lastTurnId: last_turn } : {}) } });
189
+ } else if (reply.id === 2) {
190
+ const id = reply.result?.thread?.id;
191
+ finish(id ? null : new Error('Codex 分叉会话没有返回新会话 ID'), id);
192
+ }
193
+ });
194
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'codiscuss', version: '0.1.0' } } });
195
+ });
196
+ }
197
+
198
+ /** Fork through the turn before the first marked one; a cut at the first turn starts anew. Shared by the App adapter. */
199
+ export async function branch(profile, session_id, markers, { always, env, home, spawn_process = spawn }) {
200
+ const file = session_path(session_id, env, home);
201
+ if (!file) throw new Error(`本机找不到 Codex 会话 ${session_id}`);
202
+ const { turns, cut } = codex_turns(file, markers);
203
+ if (cut < 0 && !always) return null;
204
+ if (cut === 0) return { session_id: '' };
205
+ return { session_id: await codex_fork(profile, session_id, cut > 0 ? turns[cut - 1] : '', spawn_process) };
206
+ }
207
+
208
+ /** Whether a stored thread was started in the Codex desktop, which resumes it through the App rather than the CLI. */
209
+ export function desktop_thread(session_id, options = {}) {
210
+ const file = session_path(session_id, options.env, options.home);
211
+ return Boolean(file) && /"originator":"Codex Desktop"/.test(readFileSync(file, 'utf8').split('\n', 1)[0]);
212
+ }
213
+
214
+ /** Whether Codex stored a thread by that ID on this machine. */
215
+ export function session_exists(session_id, { env = process.env, home = homedir() } = {}) {
216
+ return Boolean(session_id && session_path(session_id, env, home));
217
+ }
218
+
219
+ /** The model and effort a thread last ran with, from its rollout; no turn is started. Shared by the App adapter. */
220
+ export function session_settings(session_id, { env = process.env, home = homedir() } = {}) {
221
+ const file = session_path(session_id, env, home);
222
+ if (!file) return null;
223
+ return newest_record(file, '"turn_context"', record => record.type === 'turn_context' && typeof record.payload?.model === 'string'
224
+ ? { model: record.payload.model, effort: String(record.payload.effort || record.payload.collaboration_mode?.settings?.reasoning_effort || '') } : null);
225
+ }
226
+
227
+ /** Read the string keys of a TOML file by table; Codex settings need nothing richer. */
228
+ function toml_strings(text) {
229
+ const tables = { '': {} };
230
+ let table = '';
231
+ for (const line of text.split(/\r?\n/)) {
232
+ const header = line.match(/^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/);
233
+ if (header) { table = header[1].replaceAll('"', ''); tables[table] ||= {}; continue; }
234
+ const pair = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"/);
235
+ if (pair) tables[table][pair[1]] = pair[2];
236
+ }
237
+ return tables;
238
+ }
239
+
240
+ /** Codex's user config: what it runs with when the card leaves model or effort empty. */
241
+ function defaults(profile, { env = process.env, home = homedir() } = {}) {
242
+ try {
243
+ const tables = toml_strings(readFileSync(join(env.CODEX_HOME || join(home, '.codex'), 'config.toml'), 'utf8'));
244
+ const top = tables[''], chosen = top.profile ? tables[`profiles.${top.profile}`] || {} : {};
245
+ return { model: chosen.model || top.model || '', effort: chosen.model_reasoning_effort || top.model_reasoning_effort || '' };
246
+ } catch { return { model: '', effort: '' }; }
247
+ }
248
+
249
+ /** Codex's catalog is paginated; initialize before querying and never create a thread or turn. */
250
+ function models(profile, options) {
251
+ return query_catalog(profile, ['app-server', '--listen', 'stdio://'], (send, finish) => {
252
+ const list = [];
253
+ let request_id = 1;
254
+ return {
255
+ start: () => send({ id: 0, method: 'initialize', params: { clientInfo: { name: 'codiscuss', title: 'AI CoDiscuss', version: '0.2.1' } } }),
256
+ line: line => {
257
+ const reply = json_line(line);
258
+ if (!reply || (reply.id !== 0 && reply.id !== request_id)) return;
259
+ if (reply.error) return finish(new Error('Codex 模型目录查询失败,请检查 CLI 版本与登录'));
260
+ if (reply.id === 0) {
261
+ send({ method: 'initialized', params: {} });
262
+ send({ id: request_id, method: 'model/list', params: { limit: 100, includeHidden: false } });
263
+ return;
264
+ }
265
+ for (const model of reply.result?.data || []) {
266
+ if (!model.hidden) list.push(model_entry(model.model || model.id, model.displayName,
267
+ model.supportedReasoningEfforts?.map(item => item.reasoningEffort)));
268
+ }
269
+ const cursor = reply.result?.nextCursor;
270
+ if (cursor) send({ id: ++request_id, method: 'model/list', params: { limit: 100, includeHidden: false, cursor } });
271
+ else finish(null, list);
272
+ },
273
+ };
274
+ }, options);
275
+ }
276
+
277
+ /** Codex CLI's behaviour; its static facts are in profiles.mjs. */
278
+ export const codex_cli = {
279
+ id: 'codex_cli', catalog_source: 'codex-app-server',
280
+ deliver: (config, binding, task, spawn_process, user_home) => cli_delivery(codex_cli, config, binding, task, spawn_process, user_home),
281
+ invocation, lifecycle, exit_error, events,
282
+ on_stderr: remember_writer_conflict,
283
+ session: { path: session_path, exists: session_exists, owns: (id, options) => session_exists(id, options) && !desktop_thread(id, options),
284
+ branch, settings: session_settings },
285
+ models: { list: models, defaults },
286
+ };
@@ -0,0 +1,196 @@
1
+ /** Parts every software adapter shares: running programs, ending process trees, reading session logs and model catalogs. */
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { createInterface } from 'node:readline';
7
+ import { TASK_SUBCOMMANDS } from '../expert_prompts.mjs';
8
+ import { atomic_json } from '../files.mjs';
9
+
10
+ // The room defines which website subcommands a task may run; adapters turn them into each software's permission rules.
11
+ export { TASK_SUBCOMMANDS };
12
+ export const CALL_TIMEOUT_MS = 30_000;
13
+ /** Tool names that edit files, across the softwares' event streams. */
14
+ export const EDIT_TOOLS = /^(Edit|MultiEdit|Write|NotebookEdit|write_file|write_to_file|replace_file_content|multi_replace_file_content)$/;
15
+ /** A refused operation's detail and rule are cut to this length; a run reports at most this many. */
16
+ export const DENIAL_FIELD = 300;
17
+ export const MAX_DENIALS = 20;
18
+
19
+ /** Only failures before a software invocation are safe for automatic redelivery. */
20
+ export function not_sent(error) { return Object.assign(error, { delivery_status: 'not_sent' }); }
21
+
22
+ /** Run an executable directly; user text is always an argv value, never shell code. */
23
+ export function run_program(executable, args, options = {}) {
24
+ return new Promise((resolve_result, reject) => {
25
+ let child;
26
+ try { child = spawn(executable, args, { windowsHide: true, shell: false, ...options }); }
27
+ catch (error) { reject(not_sent(error)); return; }
28
+ let spawned = false;
29
+ child.once('spawn', () => { spawned = true; });
30
+ let output = '';
31
+ let error_text = '';
32
+ child.stdout?.on('data', chunk => { output += chunk; });
33
+ child.stderr?.on('data', chunk => { error_text += chunk; });
34
+ child.once('error', error => reject(spawned ? error : not_sent(error)));
35
+ child.once('close', code => code === 0 ? resolve_result(output) : reject(new Error(`软件退出码 ${code}: ${error_text.slice(-500)}`)));
36
+ if (options.input !== undefined) child.stdin.end(options.input);
37
+ else child.stdin?.end();
38
+ });
39
+ }
40
+
41
+ /** Bind returned software identities back to the private credential file. */
42
+ export function bind_access(path, session_id) {
43
+ const access = JSON.parse(readFileSync(path, 'utf8'));
44
+ atomic_json(path, { ...access, session_id });
45
+ }
46
+
47
+ /** Compare local directory identities without assuming case sensitivity on Windows. */
48
+ export function same_path(left, right) {
49
+ if (!left || !right) return false;
50
+ const normalize = value => process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value);
51
+ return normalize(left) === normalize(right);
52
+ }
53
+
54
+ /** The participant's discussion project directory; workdir is the legacy field name. */
55
+ export function project_directory(config) { return config.cwd || config.workdir || ''; }
56
+
57
+ /** Keep the requested project as cwd; without a project, use the separate answer directory. */
58
+ export function launch_directory(config, task) {
59
+ return project_directory(config) || task.draft_directory;
60
+ }
61
+
62
+ /** Read optional native settings; malformed settings must not silently disable a permission check. */
63
+ export function read_json(path) { return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {}; }
64
+
65
+ /**
66
+ * Windows leaves a killed process's descendants running, so a stopped Claude turn kept its tool commands working.
67
+ * End the whole tree there; elsewhere, or when taskkill cannot run, use the plain kill.
68
+ */
69
+ export function kill_process_tree(child, plain_kill, platform = process.platform, run = spawnSync) {
70
+ if (platform === 'win32' && child.pid && child.exitCode === null && child.signalCode === null) {
71
+ try {
72
+ if (run('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: CALL_TIMEOUT_MS }).status === 0) return true;
73
+ } catch { /* Fall through to the plain kill. */ }
74
+ }
75
+ return plain_kill();
76
+ }
77
+
78
+ /** Make a delivered process's kill end its whole tree on Windows, so a stopped turn leaves no tool commands running. */
79
+ export function tree_killable(child) {
80
+ if (typeof child.kill === 'function') {
81
+ const plain_kill = child.kill.bind(child);
82
+ child.kill = () => kill_process_tree(child, plain_kill);
83
+ }
84
+ return child;
85
+ }
86
+
87
+ /**
88
+ * Report the phase and any progress note of each complete JSON line, as the adapter's `events` read them; malformed
89
+ * lines are left to the caller's own parsing.
90
+ */
91
+ export function watch_events(adapter, stream, task) {
92
+ if (!task.on_activity && !task.on_note) return;
93
+ createInterface({ input: stream, crlfDelay: Infinity }).on('line', line => {
94
+ let event;
95
+ try { event = JSON.parse(line); } catch { return; }
96
+ const phase = adapter.events.phase(event);
97
+ if (phase) task.on_activity?.(phase, adapter.events.tool(event));
98
+ const note = adapter.events.note(event);
99
+ if (note) task.on_note?.(note);
100
+ });
101
+ }
102
+
103
+ /** The task file name an execution's prompt carries; it marks where that execution entered a session. */
104
+ export const execution_marker = id => `free-${id}.task.json`;
105
+
106
+ /** JSON lines of a session log; lines the software wrote in another shape are skipped. */
107
+ export function log_entries(file) {
108
+ return readFileSync(file, 'utf8').split('\n').flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } });
109
+ }
110
+
111
+ /** The text of a prompt as the software stored it: a string, or text parts (tool results carry no prompt). */
112
+ export function prompt_text(content) {
113
+ if (typeof content === 'string') return content;
114
+ return Array.isArray(content) ? content.filter(part => ['text', 'input_text'].includes(part?.type)).map(part => part.text || '').join('\n') : '';
115
+ }
116
+
117
+ /** Whether a prompt names any of the execution markers. */
118
+ export const marked = (text, markers) => markers.some(marker => text.includes(marker));
119
+
120
+ // Only the end of a session log is read; the latest request is there and long sessions stay cheap.
121
+ const SESSION_TAIL_BYTES = 4 * 1024 * 1024;
122
+
123
+ /** The lines at the end of a file, newest first; a line cut at the start of the tail fails to parse and is skipped. */
124
+ function tail_lines(file) {
125
+ const fd = openSync(file, 'r');
126
+ try {
127
+ const size = fstatSync(fd).size, length = Math.min(size, SESSION_TAIL_BYTES), buffer = Buffer.alloc(length);
128
+ readSync(fd, buffer, 0, length, size - length);
129
+ return buffer.toString('utf8').split('\n').reverse();
130
+ } finally { closeSync(fd); }
131
+ }
132
+
133
+ /** Parse the newest log record that `pick` turns into settings. */
134
+ export function newest_record(file, marker, pick) {
135
+ for (const line of tail_lines(file)) {
136
+ if (!line.includes(marker)) continue;
137
+ try { const found = pick(JSON.parse(line)); if (found) return found; } catch { /* A cut or foreign line says nothing. */ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ const QUERY_TIMEOUT_MS = 30000;
143
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
144
+ export const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/;
145
+
146
+ /** Return only model identifiers, display names and supported effort values. */
147
+ export function model_entry(id, label, efforts) {
148
+ if (typeof id !== 'string' || !MODEL_PATTERN.test(id)) return null;
149
+ return { id, label: typeof label === 'string' && label.trim() ? label : id,
150
+ ...(Array.isArray(efforts) ? { efforts: [...new Set(efforts.filter(item => typeof item === 'string'))] } : {}) };
151
+ }
152
+
153
+ /** Ignore non-protocol diagnostics while leaving malformed or missing catalogs visibly unavailable. */
154
+ export function json_line(line) {
155
+ try { return JSON.parse(line); } catch { return null; }
156
+ }
157
+
158
+ /**
159
+ * Read one catalog through the software's own CLI without an agent turn: `args` start it noninteractively and
160
+ * `reader(send, finish)` speaks its protocol. Its temporary process is killed on every outcome; size and time are bounded.
161
+ */
162
+ export function query_catalog(profile, args, reader, options = {}) {
163
+ return new Promise((resolve_result, reject) => {
164
+ const env = { ...process.env };
165
+ delete env.CLAUDECODE;
166
+ const child = (options.spawn || spawn)(profile.executable, args, {
167
+ cwd: tmpdir(), env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
168
+ });
169
+ let finished = false, bytes = 0;
170
+ const finish = (error, models) => {
171
+ if (finished) return;
172
+ finished = true; clearTimeout(timer); lines.close(); child.stdin.destroy(); child.kill();
173
+ if (error) reject(error); else resolve_result(models);
174
+ };
175
+ const timer = setTimeout(() => finish(new Error('读取模型列表超时,请检查软件登录和网络后重试')), options.timeout_ms ?? QUERY_TIMEOUT_MS);
176
+ const lines = createInterface({ input: child.stdout });
177
+ const send = value => child.stdin.write(`${JSON.stringify(value)}\n`);
178
+ const receive = reader(send, finish);
179
+ child.once('error', () => finish(new Error('无法启动模型查询,请检查 CLI 路径')));
180
+ child.stdin.on('error', () => finish(new Error('模型查询连接已关闭')));
181
+ child.stderr.on('data', () => {}); // CLI diagnostics can contain private account or configuration data.
182
+ child.stdout.on('data', chunk => {
183
+ bytes += chunk.length;
184
+ if (bytes > MAX_OUTPUT_BYTES) finish(new Error('模型目录响应超出大小限制'));
185
+ });
186
+ lines.on('line', line => {
187
+ if (finished) return;
188
+ try { receive.line(line); } catch { finish(new Error('软件模型目录格式无法识别,请检查 CLI 版本')); }
189
+ });
190
+ child.once('close', code => {
191
+ if (!finished && code === 0 && receive.complete) finish(null, receive.complete());
192
+ else if (!finished) finish(new Error(`软件未返回模型目录(退出码 ${code ?? '未知'}),请检查版本、登录与网络`));
193
+ });
194
+ receive.start();
195
+ });
196
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The adapter layer's only entry for the rest of the runner. Each supported software has two parts:
3
+ *
4
+ * - its static facts in `profiles.mjs` (label, platform, config fields, efforts, access levels, capabilities), plain data
5
+ * the web page reads as well;
6
+ * - its behaviour in its own module, a descriptor with the same shape for every software:
7
+ * - `deliver(config, binding, task, spawn_process, user_home)` → `{session_id, status, capabilities}`, where
8
+ * `capabilities` `{stoppable, progress}` says what this one delivery can do;
9
+ * - `session`: `{path, exists, branch, settings}` for the software's own session store, or null;
10
+ * - `models`: `{list, effort?, defaults}` read from the installed software, or null when it is chosen inside the App;
11
+ * - optional `receipt(config, id)` for deliveries confirmed later (`status: 'sidecar_pending'`),
12
+ * `into_window(profile, binding)` (this delivery goes into the member's own open window) and `cleanup(directory)`
13
+ * (remove what a deleted room left in the software).
14
+ *
15
+ * `adapter_of` merges the two. The room runner, launch settings and the website use these, never a software name
16
+ * (AGENTS.md red line 9).
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import { existsSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { isAbsolute } from 'node:path';
22
+ import { antigravity } from './antigravity_app.mjs';
23
+ import { antigravity_cli } from './antigravity_cli.mjs';
24
+ import { api } from './api.mjs';
25
+ import { claude } from './claude.mjs';
26
+ import { codex } from './codex_app.mjs';
27
+ import { codex_cli } from './codex_cli.mjs';
28
+ import { execution_marker, MODEL_PATTERN } from './common.mjs';
29
+ import { adapter_capabilities, adapter_profile, ADAPTER_IDS } from './profiles.mjs';
30
+
31
+ export { execution_marker };
32
+ export { adapter_capabilities as capabilities_of };
33
+
34
+ export const ADAPTERS = Object.fromEntries([codex_cli, codex, claude, antigravity_cli, antigravity, api]
35
+ .map(behaviour => [behaviour.id, { ...adapter_profile(behaviour.id), ...behaviour }]));
36
+ const SESSION_PATTERN = /^[0-9A-Za-z_-]{8,128}$/;
37
+
38
+ /** The descriptor of a configured adapter, or null for a value this version does not support. */
39
+ export function adapter_of(id) { return ADAPTERS[id] || null; }
40
+
41
+ /**
42
+ * Which adapter holds an existing conversation of the software a manual member registered with (`client`): the one
43
+ * whose store owns it, else the CLI that would resume it. '' when no adapter takes over that software.
44
+ */
45
+ export function adoption_adapter(client, session_id, options = {}) {
46
+ const candidates = Object.values(ADAPTERS).filter(adapter => adapter.client === client && adapter.adopt);
47
+ return (candidates.find(adapter => adapter.session?.owns?.(session_id, options))
48
+ || candidates.find(adapter => adapter.kind === 'cli') || candidates[0])?.id || '';
49
+ }
50
+
51
+ /** Check one configured software member: a supported adapter, its installed software located, and what it alone requires. */
52
+ export function validate_participant(participant) {
53
+ const adapter = adapter_of(participant.adapter);
54
+ if (!adapter) throw new Error(`适配器只能为 ${ADAPTER_IDS.join('/')}`);
55
+ if (adapter.location === 'executable' && !existsSync(participant.executable ?? '')) throw new Error('请指定实际原生可执行文件');
56
+ adapter.validate?.(participant);
57
+ }
58
+
59
+ /** Remove what a deleted room's members left inside their software, e.g. agy's private projects. */
60
+ export function cleanup_room(directory) {
61
+ for (const adapter of Object.values(ADAPTERS)) adapter.cleanup?.(directory);
62
+ }
63
+
64
+ /** Deliver one task through its member's adapter; only allowlisted adapters run. */
65
+ export async function deliver_task(config, binding, task, spawn_process = spawn, user_home = homedir()) {
66
+ const adapter = adapter_of(config.adapter);
67
+ if (!adapter) throw new Error('不支持的软件适配器');
68
+ return adapter.deliver(config, binding, task, spawn_process, user_home);
69
+ }
70
+
71
+ /** A delivery's later receipt (`status: 'sidecar_pending'`), or null while none has arrived. */
72
+ export function delivery_receipt(config, id) {
73
+ return adapter_of(config.adapter)?.receipt?.(config, id) ?? null;
74
+ }
75
+
76
+ /** Read a persisted delivery, including the delivery names written by the pre-capability runner. */
77
+ export function stored_delivery(value) {
78
+ const legacy = { process: { stoppable: true, progress: true }, api: { stoppable: true, progress: true },
79
+ window: { stoppable: false, progress: false }, app: { stoppable: false, progress: false }, sidecar: { stoppable: false, progress: false } };
80
+ return Object.hasOwn(legacy, value) ? legacy[value] : JSON.parse(value);
81
+ }
82
+
83
+ /** Adapters whose sessions this machine can cut or branch; the website lists taken-back executions only for these. */
84
+ export const BRANCHABLE_ADAPTERS = Object.values(ADAPTERS).filter(adapter => adapter.session?.branch).map(adapter => adapter.id);
85
+
86
+ /** Whether a session exists on this machine, e.g. to tell if a pending Claude fork already happened. */
87
+ export function session_exists(adapter_id, session_id, options = {}) {
88
+ return Boolean(adapter_of(adapter_id)?.session?.exists(session_id, options));
89
+ }
90
+
91
+ /**
92
+ * Branch a session before the first of some taken-back executions it contains, leaving the original untouched.
93
+ *
94
+ * @param {object} profile The member's local software profile (`adapter`, `executable`).
95
+ * @param {string} session_id The session the member is bound to.
96
+ * @param {number[]} executions Execution IDs whose turns the branch must not contain.
97
+ * @param {object} options `always` branches even when none of them is in the session (a forked room needs its own copy).
98
+ * @returns {Promise<null|{session_id: string, fork?: {from: string, at: string}}>} null when nothing needs cutting;
99
+ * an empty session_id starts a new conversation; `fork` asks Claude's next turn to fork into session_id.
100
+ */
101
+ export async function branch_session(profile, session_id, executions, { always = false, env = process.env, home = homedir(), spawn_process = spawn } = {}) {
102
+ const branch = adapter_of(profile.adapter)?.session?.branch;
103
+ if (!branch) throw new Error(`${profile.adapter} 的会话不能由本机截断`);
104
+ return branch(profile, session_id, executions.map(execution_marker), { always, env, home, spawn_process });
105
+ }
106
+
107
+ /**
108
+ * The model and effort a session last ran with, read from the software's own session store; no AI turn is started.
109
+ * These are each software's internal formats, so anything unreadable yields empty strings rather than an error.
110
+ */
111
+ export function session_settings(adapter_id, session_id, options = {}) {
112
+ const none = { model: '', effort: '' };
113
+ if (typeof session_id !== 'string' || !SESSION_PATTERN.test(session_id)) return none;
114
+ try { return adapter_of(adapter_id)?.session?.settings(session_id, options) || none; }
115
+ catch { return none; }
116
+ }
117
+
118
+ /** Public model metadata only; failures leave the original-software default available to the UI. */
119
+ export async function software_models(profile, options = {}) {
120
+ const adapter = adapter_of(profile.adapter);
121
+ const result = { adapter: profile.adapter, models: [], source: adapter?.catalog_source || 'original-software' };
122
+ if (!adapter?.models) return { ...result, error: '此入口的模型在原软件内设置' };
123
+ if (typeof profile.executable !== 'string' || !isAbsolute(profile.executable)) return { ...result, error: '请先配置 CLI 的完整路径,再读取模型列表' };
124
+ try {
125
+ const models = (await adapter.models.list(profile, options)).filter(Boolean);
126
+ result.models = [...new Map(models.map(model => [model.id, model])).values()];
127
+ if (!result.models.length) result.error = '软件未返回可选模型,请检查版本、登录与网络;仍可使用软件默认模型';
128
+ } catch (error) { result.error = error.message; }
129
+ return result;
130
+ }
131
+
132
+ /** The effort levels the software offers for the selected model, where it can tell without an agent turn. */
133
+ export async function software_effort(profile, options = {}) {
134
+ const effort = adapter_of(profile.adapter)?.models?.effort;
135
+ if (!effort || !isAbsolute(profile.executable || '') || (profile.model && !MODEL_PATTERN.test(profile.model))) return { error: '无法读取所选模型的思考档位' };
136
+ try { return { levels: await effort(profile, options) }; }
137
+ catch { return { error: '无法读取原生思考档位;保留 CLI 默认值,或检查软件版本后重试' }; }
138
+ }
139
+
140
+ /** What a CLI runs with when the card leaves model or effort empty. */
141
+ export async function software_defaults(profile, options = {}) {
142
+ return (await adapter_of(profile.adapter)?.models?.defaults(profile, options)) || { model: '', effort: '' };
143
+ }