ai_co_discuss 0.2.1 → 0.2.3

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.
@@ -0,0 +1,127 @@
1
+ /** Discover model selectors from the installed software without starting an AI turn. */
2
+ import { spawn } from 'node:child_process';
3
+ import { isAbsolute } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { createInterface } from 'node:readline';
6
+
7
+ const QUERY_TIMEOUT_MS = 30000;
8
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
9
+ const SOURCES = { codex_cli: 'codex-app-server', claude: 'claude-cli', antigravity_cli: 'antigravity-cli' };
10
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/;
11
+
12
+ /** Return only model identifiers, display names and supported effort values. */
13
+ function model_entry(id, label, efforts) {
14
+ if (typeof id !== 'string' || !MODEL_PATTERN.test(id)) return null;
15
+ return { id, label: typeof label === 'string' && label.trim() ? label : id,
16
+ ...(Array.isArray(efforts) ? { efforts: [...new Set(efforts.filter(item => typeof item === 'string'))] } : {}) };
17
+ }
18
+
19
+ /** Keep all query processes noninteractive and avoid user/project startup hooks. */
20
+ function query_arguments(adapter) {
21
+ if (adapter === 'codex_cli') return ['app-server', '--listen', 'stdio://'];
22
+ if (adapter === 'antigravity_cli') return ['models'];
23
+ return ['--safe-mode', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json',
24
+ '--verbose', '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'];
25
+ }
26
+
27
+ /** Read one catalog, kill its temporary process on every outcome, and bound response size/time. */
28
+ function query_catalog(profile, options) {
29
+ return new Promise((resolve_result, reject) => {
30
+ const env = { ...process.env };
31
+ delete env.CLAUDECODE;
32
+ const child = (options.spawn || spawn)(profile.executable, query_arguments(profile.adapter), {
33
+ cwd: tmpdir(), env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
34
+ });
35
+ let finished = false, bytes = 0;
36
+ const finish = (error, models) => {
37
+ if (finished) return;
38
+ finished = true; clearTimeout(timer); lines.close(); child.stdin.destroy(); child.kill();
39
+ if (error) reject(error); else resolve_result(models);
40
+ };
41
+ const timer = setTimeout(() => finish(new Error('读取模型列表超时,请检查软件登录和网络后重试')), options.timeout_ms ?? QUERY_TIMEOUT_MS);
42
+ const lines = createInterface({ input: child.stdout });
43
+ const send = value => child.stdin.write(`${JSON.stringify(value)}\n`);
44
+ const receive = catalog_reader(profile.adapter, send, finish);
45
+ child.once('error', () => finish(new Error('无法启动模型查询,请检查 CLI 路径')));
46
+ child.stdin.on('error', () => finish(new Error('模型查询连接已关闭')));
47
+ child.stderr.on('data', () => {}); // CLI diagnostics can contain private account or configuration data.
48
+ child.stdout.on('data', chunk => {
49
+ bytes += chunk.length;
50
+ if (bytes > MAX_OUTPUT_BYTES) finish(new Error('模型目录响应超出大小限制'));
51
+ });
52
+ lines.on('line', line => {
53
+ if (finished) return;
54
+ try { receive.line(line); } catch { finish(new Error('软件模型目录格式无法识别,请检查 CLI 版本')); }
55
+ });
56
+ child.once('close', code => {
57
+ if (!finished && code === 0 && receive.complete) finish(null, receive.complete());
58
+ else if (!finished) finish(new Error(`软件未返回模型目录(退出码 ${code ?? '未知'}),请检查版本、登录与网络`));
59
+ });
60
+ receive.start();
61
+ });
62
+ }
63
+
64
+ /** Codex's catalog is paginated; initialize before querying and never create a thread or turn. */
65
+ function codex_reader(send, finish) {
66
+ const models = [];
67
+ let request_id = 1;
68
+ return {
69
+ start: () => send({ id: 0, method: 'initialize', params: { clientInfo: { name: 'codiscuss', title: 'AI CoDiscuss', version: '0.2.1' } } }),
70
+ line: line => {
71
+ const reply = json_line(line);
72
+ if (!reply || (reply.id !== 0 && reply.id !== request_id)) return;
73
+ if (reply.error) return finish(new Error('Codex 模型目录查询失败,请检查 CLI 版本与登录'));
74
+ if (reply.id === 0) {
75
+ send({ method: 'initialized', params: {} });
76
+ send({ id: request_id, method: 'model/list', params: { limit: 100, includeHidden: false } });
77
+ return;
78
+ }
79
+ for (const model of reply.result?.data || []) {
80
+ if (!model.hidden) models.push(model_entry(model.model || model.id, model.displayName,
81
+ model.supportedReasoningEfforts?.map(item => item.reasoningEffort)));
82
+ }
83
+ const cursor = reply.result?.nextCursor;
84
+ if (cursor) send({ id: ++request_id, method: 'model/list', params: { limit: 100, includeHidden: false, cursor } });
85
+ else finish(null, models);
86
+ },
87
+ };
88
+ }
89
+
90
+ /** Ignore non-protocol diagnostics while leaving malformed or missing catalogs visibly unavailable. */
91
+ function json_line(line) {
92
+ try { return JSON.parse(line); } catch { return null; }
93
+ }
94
+
95
+ /** Claude initialization exposes its picker; agy emits tab-separated ids and labels. */
96
+ function catalog_reader(adapter, send, finish) {
97
+ if (adapter === 'codex_cli') return codex_reader(send, finish);
98
+ if (adapter === 'claude') return {
99
+ start: () => send({ type: 'control_request', request_id: 'catalog', request: { subtype: 'initialize' } }),
100
+ line: line => {
101
+ const reply = json_line(line);
102
+ if (reply?.type !== 'control_response' || reply.response?.request_id !== 'catalog') return;
103
+ if (reply.response.subtype !== 'success') return finish(new Error('Claude 模型目录查询失败,请检查 CLI 版本与登录'));
104
+ finish(null, (reply.response.response?.models || []).map(model => model_entry(model.value, model.displayName,
105
+ model.supportedEffortLevels || (model.supportsEffort ? undefined : []))));
106
+ },
107
+ };
108
+ const models = [];
109
+ return { start: () => {}, complete: () => models, line: line => {
110
+ const fields = line.trim().split(/\t+| {2,}/);
111
+ if (fields.length >= 2) models.push(model_entry(fields[0], fields.slice(1).join(' ')));
112
+ } };
113
+ }
114
+
115
+ /** Public model metadata only; failures leave the original-software default available to the UI. */
116
+ export async function software_models(profile, options = {}) {
117
+ const { adapter, executable } = profile;
118
+ const result = { adapter, models: [], source: SOURCES[adapter] || 'original-software' };
119
+ if (!SOURCES[adapter]) return { ...result, error: '此入口的模型在原软件内设置' };
120
+ if (typeof executable !== 'string' || !isAbsolute(executable)) return { ...result, error: '请先配置 CLI 的完整路径,再读取模型列表' };
121
+ try {
122
+ const models = (await query_catalog(profile, options)).filter(Boolean);
123
+ result.models = [...new Map(models.map(model => [model.id, model])).values()];
124
+ if (!result.models.length) result.error = '软件未返回可选模型,请检查版本、登录与网络;仍可使用软件默认模型';
125
+ } catch (error) { result.error = error.message; }
126
+ return result;
127
+ }
@@ -18,19 +18,23 @@ async function process_request(name) {
18
18
  writeFileSync(claim, String(process.pid), { flag: 'wx', mode: 0o600 });
19
19
  try {
20
20
  const access = JSON.parse(readFileSync(request.access_path, 'utf8'));
21
- const response = await fetch(`${access.site}/api/rooms/${access.room}/events/${request.event_id}`, {
22
- method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
21
+ const free = request.mode === 'free';
22
+ const response = await fetch(`${access.site}/api/rooms/${access.room}/${free ? 'executions' : 'events'}/${request.event_id}`, {
23
+ method: free ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
23
24
  headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': access.token },
24
- body: JSON.stringify({ action: 'view' }),
25
+ body: free ? undefined : JSON.stringify({ action: 'view' }),
25
26
  });
26
27
  const event = await response.json();
27
28
  if (!response.ok || event.status !== 'dispatched') throw new Error('请求已撤销或状态不明,未调用软件');
29
+ if (free && event.lease_until <= Math.floor(Date.now() / 1000)) throw new Error('执行租约已到期,未调用软件');
30
+ if (!free) {
28
31
  const runtime_response = await fetch(`${access.site}/api/rooms/${access.room}/runtime`, {
29
32
  redirect: 'error', signal: AbortSignal.timeout(10000),
30
33
  });
31
34
  const runtime = await runtime_response.json();
32
35
  if (!runtime_response.ok || runtime.status !== 'running' || (runtime.deadline > 0 && runtime.deadline <= Math.floor(Date.now() / 1000))
33
36
  || event.run_id !== runtime.run_id || event.generation !== runtime.generation) throw new Error('运行已停止或到期,未调用软件');
37
+ }
34
38
  const args = request.session_id ? ['send-message', request.session_id, request.prompt] : ['new-conversation', request.prompt];
35
39
  const raw = execFileSync(agentapi, ['agentapi', ...args], { encoding: 'utf8', windowsHide: true, timeout: 30000 });
36
40
  const result = JSON.parse(raw);
@@ -3,12 +3,12 @@ import { spawnSync } from 'node:child_process';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
5
5
  import { join, resolve } from 'node:path';
6
- import { atomic_json, deliver_task, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
6
+ import { atomic_json, deliver_task, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
7
7
  import { api } from './discussion_runner.mjs';
8
8
 
9
9
  const RENEW_MS = 30_000;
10
10
  const MAX_DELIVERY_ATTEMPTS = 3;
11
- const LIVE = ['reserving', 'delivering', 'running'];
11
+ const LIVE = ['reserving', 'delivering', 'sidecar_pending', 'running'];
12
12
  const MAX_HASHED_BYTES = 5 * 1024 * 1024;
13
13
  const GIT_TIMEOUT_MS = 15_000;
14
14
 
@@ -182,7 +182,7 @@ export class FreeRoomRunner {
182
182
  on_exit: () => this.exited.add(execution.id),
183
183
  });
184
184
  if (result.session_id) this.store.bind(member.name, result.session_id);
185
- this.save(member.name, { status: 'running', attempts: 0 });
185
+ this.save(member.name, { status: result.status === 'sidecar_pending' ? 'sidecar_pending' : 'running', attempts: 0 });
186
186
  } catch (error) {
187
187
  const attempts = (this.row(member.name)?.attempts || 0) + 1;
188
188
  if ((invoked && error.delivery_status !== 'not_sent') || attempts >= MAX_DELIVERY_ATTEMPTS) {
@@ -195,6 +195,13 @@ export class FreeRoomRunner {
195
195
  /** Follow the website's verdict; stop only this member's processes when its execution ends. */
196
196
  async reconcile(member, row, active, minutes) {
197
197
  if (row.status === 'reserving') return this.reserve(member, row);
198
+ if (row.status === 'sidecar_pending') {
199
+ const receipt = sidecar_receipt(this.config.participants[member.name], `free-${row.execution_id}`);
200
+ if (receipt?.status === 'accepted' && receipt.session_id) {
201
+ this.store.bind(member.name, receipt.session_id);
202
+ this.save(member.name, { status: 'running' });
203
+ } else if (receipt && active.has(row.execution_id)) return this.fail(member.name, row.execution_id, receipt.error || 'Sidecar 投递结果不明,请核对原会话');
204
+ }
198
205
  const execution = active.get(row.execution_id);
199
206
  if (!execution) {
200
207
  this.stop_member(member.name);