ai_co_discuss 0.3.39 → 0.3.40

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.
@@ -1,313 +1,384 @@
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
- };
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 { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } 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
+ // How often an open window's session log is read for the end of the turn that took a task.
18
+ const WINDOW_POLL_MS = 3_000;
19
+ // Error kinds Claude Code records that a retry a minute later meets again: a used-up plan limit, a lost login.
20
+ const LASTING_ERRORS = ['rate_limit', 'authentication_failed'];
21
+ const EDIT_TOOL_NAMES = /^(Edit|MultiEdit|Write|NotebookEdit)$/;
22
+ // Claude Code logs side queries (titles, compaction, subagents) too; only these sources are the member's own turns.
23
+ const CLAUDE_MAIN_SOURCES = ['sdk', 'repl_main_thread'];
24
+ // A background process can be stopped and streams its progress; a turn in the member's own window does neither.
25
+ const PROCESS = { stoppable: true, progress: true };
26
+ const WINDOW = { stoppable: false, progress: false };
27
+
28
+ /** Explain why a finished Claude print run failed, including errors reported with exit code 0. */
29
+ export function claude_exit_error(code, output) {
30
+ let result = null;
31
+ try { result = JSON.parse(output.trim().split(/\r?\n/).at(-1)); } catch { /* Non-JSON output keeps the exit code check. */ }
32
+ if (result?.is_error) return `Claude 执行出错:${result.result || result.api_error || result.subtype || '未知错误'}`;
33
+ return code === 0 ? '' : `Claude 退出码 ${code}`;
34
+ }
35
+
36
+ /**
37
+ * The API error Claude Code wrote in place of an answer, from a session log entry or a stream-json event, or null.
38
+ * Its `kind` is Claude's own category (`rate_limit`, `authentication_failed`, `server_error`, ...).
39
+ */
40
+ export function claude_api_error(entry) {
41
+ if (entry?.type !== 'assistant' || !(entry.isApiErrorMessage || entry.is_api_error_message)) return null;
42
+ const text = prompt_text(entry.message?.content).trim();
43
+ const kind = String(entry.error || '');
44
+ return { kind, text: text || String(entry.api_error || kind || '未知错误'), lasting: LASTING_ERRORS.includes(kind) };
45
+ }
46
+
47
+ /** How a Claude API error reads on the failed execution; a lasting one says it is not retried. */
48
+ export function claude_error_reason(error) {
49
+ const label = { rate_limit: 'Claude 用量已达上限', authentication_failed: 'Claude 未登录或登录已失效' }[error.kind] || 'Claude 接口出错';
50
+ return `${label}:${error.text}${error.lasting ? '(不自动重试;恢复后请重新发送)' : ''}`;
51
+ }
52
+
53
+ /** The first program a shell command runs, skipping `cd` and `Set-Location` steps, for a prefix rule that also allows a retry. */
54
+ function command_program(command) {
55
+ const steps = String(command).split(/&&|\|\||;|\|/).map(step => step.trim().split(/\s+/)[0]).filter(Boolean);
56
+ return steps.find(step => !/^(cd|Set-Location|sl|pushd)$/i.test(step)) || steps[0] || '';
57
+ }
58
+
59
+ /**
60
+ * The operations a headless Claude run refused, from the `permission_denials` of its final result line, each with the
61
+ * `--allowedTools` rule that would let the next run do it: a command prefix, an edit of that folder, or the tool itself.
62
+ */
63
+ export function claude_denials(output) {
64
+ let result = null;
65
+ try { result = JSON.parse(output.trim().split(/\r?\n/).at(-1)); } catch { return []; }
66
+ const seen = new Set();
67
+ return (Array.isArray(result?.permission_denials) ? result.permission_denials : []).map(denial => {
68
+ const tool = String(denial.tool_name || '').slice(0, 40), input = denial.tool_input || {};
69
+ if (['Bash', 'PowerShell'].includes(tool) && input.command) {
70
+ const program = command_program(input.command);
71
+ return { tool, detail: String(input.command), rule: program ? `${tool}(${program} *)` : '' };
72
+ }
73
+ const path = input.file_path || input.notebook_path;
74
+ if (EDIT_TOOL_NAMES.test(tool) && path) return { tool, detail: String(path), rule: `Edit(${claude_rule_path(dirname(path))}/**)` };
75
+ return { tool, detail: JSON.stringify(input), rule: tool };
76
+ }).filter(item => item.tool && !seen.has(item.detail) && seen.add(item.detail))
77
+ .slice(0, MAX_DENIALS).map(item => ({ ...item, detail: item.detail.slice(0, DENIAL_FIELD), rule: item.rule.length <= DENIAL_FIELD ? item.rule : '' }));
78
+ }
79
+
80
+ /**
81
+ * Drop variables a hosting Claude session injects, so the spawned CLI uses its own login.
82
+ * The host also hides the user's persisted login variables, which are read back on Windows.
83
+ */
84
+ export function standalone_claude_env(env = process.env, persisted = persisted_claude_env) {
85
+ if (env.CLAUDECODE !== '1') return env;
86
+ const own = Object.entries(env).filter(([key]) =>
87
+ !/^(CLAUDECODE|CLAUDE_PID|CLAUDE_EFFORT)$|^(CLAUDE_CODE_|CLAUDE_AGENT_SDK_|CLAUDE_PREVIEW_)/.test(key));
88
+ return { ...Object.fromEntries(own), ...persisted() };
89
+ }
90
+
91
+ let persisted_cache;
92
+ /** Read Machine then User persisted ANTHROPIC_/CLAUDE variables once; values stay in memory only. */
93
+ function persisted_claude_env() {
94
+ if (process.platform !== 'win32') return {};
95
+ if (!persisted_cache) {
96
+ const script = "$r=@{}; foreach($s in 'Machine','User'){ $v=[Environment]::GetEnvironmentVariables($s);"
97
+ + " foreach($k in $v.Keys){ if($k -match '^(ANTHROPIC_|CLAUDE)'){ $r[$k]=$v[$k] } } }; $r | ConvertTo-Json -Compress";
98
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true });
99
+ try { persisted_cache = result.status === 0 && result.stdout.trim() ? JSON.parse(result.stdout) : {}; }
100
+ catch { persisted_cache = {}; }
101
+ }
102
+ return persisted_cache;
103
+ }
104
+
105
+ /** Claude permission rules match Windows paths in POSIX form, e.g. C:\a becomes //c/a. */
106
+ export function claude_rule_path(directory) {
107
+ const path = resolve(directory).replaceAll('\\', '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`);
108
+ return `/${path}`;
109
+ }
110
+
111
+ /**
112
+ * Allow the listed codiscuss website subcommands and draft writes for this run; a writable member also edits its project
113
+ * and runs any command. User rules are added after. Claude Code on Windows may run commands through its PowerShell tool
114
+ * instead of Bash, so both are listed.
115
+ */
116
+ export function claude_permissions(config, task) {
117
+ // A headless run cannot ask for approval: 自动 and a taken-over manual member use auto mode, as the original window
118
+ // does, and 全放开 skips every check; the website commands and drafts stay pre-approved either way.
119
+ if (['own', 'auto', 'full'].includes(config.project_access)) {
120
+ const commands = TASK_SUBCOMMANDS.flatMap(command => [`Bash(codiscuss ${command} *)`, `PowerShell(codiscuss ${command} *)`]);
121
+ return ['--permission-mode', config.project_access === 'full' ? 'bypassPermissions' : 'auto', '--add-dir', task.directory, task.draft_directory, ...task.skill_directories, '--allowedTools',
122
+ ...commands, `Edit(${claude_rule_path(task.draft_directory)}/**)`, ...(config.allowed_tools || []), ...skill_edit_denial(task)];
123
+ }
124
+ const commands = runs_commands(config) ? ['Bash', 'PowerShell']
125
+ : TASK_SUBCOMMANDS.flatMap(command => [`Bash(codiscuss ${command} *)`, `PowerShell(codiscuss ${command} *)`]);
126
+ const writable = [task.draft_directory, ...(runs_commands(config) ? [project_directory(config)] : [])];
127
+ return ['--add-dir', task.directory, task.draft_directory, ...task.skill_directories, '--allowedTools',
128
+ ...commands, ...writable.map(directory => `Edit(${claude_rule_path(directory)}/**)`), ...(config.allowed_tools || []), ...skill_edit_denial(task)];
129
+ }
130
+
131
+ /**
132
+ * Skill folders (the shared skills and an enabled workflow) are read, never edited. This stops Claude's own edit tools only;
133
+ * a command the member may run can still write there, and Windows has no native sandbox to prevent it.
134
+ */
135
+ function skill_edit_denial(task) {
136
+ return ['--disallowedTools', ...task.skill_directories.map(directory => `Edit(${claude_rule_path(directory)}/**)`)];
137
+ }
138
+
139
+ /** What Claude's stream-json events say the agent is doing, which tool it starts, and text it wrote to the user. */
140
+ const events = {
141
+ phase(event) {
142
+ if (event.type === 'system' || event.type === 'user') return 'thinking';
143
+ const block = event.type === 'assistant' ? event.message?.content?.at(-1) : null;
144
+ if (block?.type === 'tool_use') return /^(Bash|PowerShell)$/.test(block.name) ? 'command' : EDIT_TOOLS.test(block.name) ? 'editing' : 'tool';
145
+ return block?.type === 'text' ? 'writing' : block?.type === 'thinking' ? 'thinking' : '';
146
+ },
147
+ tool(event) {
148
+ const block = event.type === 'assistant' ? event.message?.content?.at(-1) : null;
149
+ return block?.type === 'tool_use' ? String(block.name || '') : '';
150
+ },
151
+ note(event) {
152
+ if (event.type !== 'assistant') return '';
153
+ return (event.message?.content || []).filter(block => block.type === 'text').map(block => block.text).join('\n').trim();
154
+ },
155
+ };
156
+
157
+ /**
158
+ * The open Claude Code window holding one session, with its cross-session inbox, or null when no live session holds it.
159
+ * Read from the registry each running session writes; `inbox` is null on a version without one. Not a public interface:
160
+ * the peer token and message line are what Claude's own sessions use (tested on 2.1.312).
161
+ */
162
+ export function claude_window(session_id, env = process.env) {
163
+ const directory = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'sessions');
164
+ if (!session_id || !existsSync(directory)) return null;
165
+ for (const name of readdirSync(directory).filter(item => /^\d+\.json$/.test(item))) {
166
+ let record;
167
+ try { record = JSON.parse(readFileSync(join(directory, name), 'utf8')); } catch { continue; }
168
+ if (record.sessionId !== session_id) continue;
169
+ try { process.kill(Number(record.pid), 0); } catch { continue; }
170
+ const key = readdirSync(directory).find(item => item.startsWith(`${record.pid}.`) && item.endsWith('.key'));
171
+ let token = '';
172
+ try { token = key ? JSON.parse(readFileSync(join(directory, key), 'utf8')).peerToken || '' : ''; } catch { /* no inbox credential */ }
173
+ return { pid: Number(record.pid), inbox: record.messagingSocketPath && token ? { socket: record.messagingSocketPath, token } : null };
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /** Whether this execution goes into a manual member's own open window (taken over with `own` permissions) rather than a background process. */
179
+ export function claude_inbox_ready(profile, binding) {
180
+ return profile.adapter === 'claude' && profile.project_access === 'own' && Boolean(profile.session_id)
181
+ && binding.session_id === profile.session_id && Boolean(claude_window(profile.session_id)?.inbox);
182
+ }
183
+
184
+ /**
185
+ * A taken-over manual Claude member whose session a window without an inbox still holds: resuming that session from
186
+ * here would split its file into two branches neither side sees in full, so its first hosted turn forks it and later
187
+ * turns resume the fork; a fork whose session was never written is retried under the same ID. Once no window holds
188
+ * the session, it is resumed as it is.
189
+ */
190
+ export function adopted_fork(config, binding, held, exists = id => session_exists(id)) {
191
+ if (config.project_access !== 'own' || !config.session_id || binding.fork || !binding.session_id) return binding;
192
+ if (binding.session_id !== config.session_id) return exists(binding.session_id) ? binding : { ...binding, fork: { from: config.session_id, at: '' } };
193
+ return held ? { ...binding, session_id: randomUUID(), fork: { from: config.session_id, at: '' } } : binding;
194
+ }
195
+
196
+ /** Post one task into an open Claude window; it starts a turn there, or between tool calls of the turn it is in. */
197
+ async function inbox_delivery(config, window, task) {
198
+ const { inbox } = window;
199
+ try { bind_access(task.access_path, config.session_id); }
200
+ catch (error) { throw not_sent(error); }
201
+ // Only what the window writes after this point can be the turn that takes this task.
202
+ const file = session_path(config.session_id);
203
+ const offset = file ? statSync(file).size : 0;
204
+ const text = `CoDiscuss 本机服务代房间转达(你已注册为该房间成员,这是房间分派给你的任务):\n${task.prompt}`;
205
+ const lines = [{ type: 'auth', token: inbox.token }, { type: 'user', message: { role: 'user', content: text } }];
206
+ await new Promise((resolve_sent, reject) => {
207
+ let written = false;
208
+ const socket = connect(inbox.socket, () => {
209
+ socket.end(lines.map(line => `${JSON.stringify(line)}\n`).join(''), () => { written = true; resolve_sent(); });
210
+ });
211
+ socket.on('error', error => written ? undefined : reject(not_sent(error)));
212
+ });
213
+ // The discussion runner enters the execution once the session is known, as it does when a process reports it.
214
+ task.on_binding?.(config.session_id);
215
+ watch_window_turn({ file, offset, pid: window.pid, marker: `${task.id}.task.json` }, task);
216
+ return { session_id: config.session_id, status: 'accepted', capabilities: WINDOW };
217
+ }
218
+
219
+ /**
220
+ * Follow a task posted into an open window through the session log, from `offset` on: note once when the window has not
221
+ * taken it by INBOX_CHECK_MS, and when the turn that took it ends (or the window closes first), report the API error that
222
+ * turn ended on as a failure, otherwise its end, so an answer it never submitted fails the execution instead of hanging.
223
+ */
224
+ export function watch_window_turn({ file, offset, pid, marker }, task, { interval = WINDOW_POLL_MS, unstarted_after = INBOX_CHECK_MS } = {}) {
225
+ const since = Date.now();
226
+ let pending = Buffer.alloc(0), taken = false, noted = false, error = null;
227
+ const finish = () => {
228
+ clearInterval(timer);
229
+ if (error) task.on_failure?.(claude_error_reason(error), { retryable: !error.lasting });
230
+ else task.on_exit?.();
231
+ };
232
+ const timer = setInterval(() => {
233
+ for (const entry of read_appended()) {
234
+ if (!taken) { taken = entry.type === 'user' && prompt_text(entry.message?.content).includes(marker); continue; }
235
+ error = claude_api_error(entry) || error;
236
+ if (entry.type === 'system' && entry.subtype === 'turn_duration') return finish();
237
+ }
238
+ if (!process_alive(pid)) return finish();
239
+ if (!taken && !noted && Date.now() - since >= unstarted_after) {
240
+ noted = true;
241
+ task.on_note?.('已投递到原窗口,但它还没开始处理:原窗口可能处于跳过审批模式(需在窗口中批准这条消息)或仍在忙。');
242
+ }
243
+ }, interval);
244
+ timer.unref();
245
+ /** Whole lines the log gained since the last read; a line still being written waits for the next. */
246
+ function read_appended() {
247
+ let size;
248
+ try { size = file ? statSync(file).size : 0; } catch { return []; }
249
+ if (size <= offset) return [];
250
+ const chunk = Buffer.alloc(size - offset), handle = openSync(file, 'r');
251
+ try { readSync(handle, chunk, 0, chunk.length, offset); } finally { closeSync(handle); }
252
+ offset = size;
253
+ pending = Buffer.concat([pending, chunk]);
254
+ const end = pending.lastIndexOf(0x0a);
255
+ if (end < 0) return [];
256
+ const lines = pending.subarray(0, end).toString('utf8').split('\n');
257
+ pending = pending.subarray(end + 1);
258
+ return lines.map(json_line).filter(Boolean);
259
+ }
260
+ }
261
+
262
+ function process_alive(pid) {
263
+ try { process.kill(pid, 0); return true; } catch { return false; }
264
+ }
265
+
266
+ /** Use one official Claude process per turn and resume the persisted session, or post into the member's open window. */
267
+ async function deliver(config, binding, task, spawn_process = spawn) {
268
+ const window = config.project_access === 'own' && binding.session_id && binding.session_id === config.session_id ? claude_window(config.session_id) : null;
269
+ // Only a task written for the window names its credential file; a background process gets it through its environment.
270
+ if (window?.inbox && task.inbox) return inbox_delivery(config, window, task);
271
+ binding = adopted_fork(config, binding, Boolean(window));
272
+ const session_id = binding.session_id || randomUUID();
273
+ try { bind_access(task.access_path, session_id); }
274
+ catch (error) { throw not_sent(error); }
275
+ // Streamed events show the turn's progress; the final line is still the result that decides success.
276
+ // A rewound or forked member first forks its earlier session into the ID it is now bound to, cut at `at` when given.
277
+ const fork = binding.fork ? ['--resume', binding.fork.from, '--fork-session', ...(binding.fork.at ? ['--resume-session-at', binding.fork.at] : []), '--session-id', session_id]
278
+ : [binding.session_id ? '--resume' : '--session-id', session_id];
279
+ const args = ['-p', '--output-format', 'stream-json', '--verbose', ...fork,
280
+ ...(config.model ? ['--model', config.model] : []), ...(config.effort ? ['--effort', config.effort] : []), ...claude_permissions(config, task)];
281
+ let child;
282
+ try {
283
+ child = spawn_process(config.executable, [...args, '--', task.prompt], {
284
+ cwd: launch_directory(config, task), windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'],
285
+ env: { ...standalone_claude_env(), DISCUSS_ACCESS_FILE: task.access_path },
286
+ });
287
+ } catch (error) { throw not_sent(error); }
288
+ tree_killable(child);
289
+ let spawned = false;
290
+ const log_path = resolve(task.directory, `claude-${task.id}.log`);
291
+ let output = '';
292
+ child.stdout.on('data', chunk => { output += chunk; task.on_output(log_path, chunk); });
293
+ child.stderr.on('data', chunk => task.on_output(log_path, chunk));
294
+ watch_events(claude, child.stdout, task);
295
+ child.once('close', code => {
296
+ if (!spawned) return;
297
+ const denials = claude_denials(output);
298
+ if (denials.length) task.on_denials?.(denials);
299
+ const error = claude_exit_error(code, output);
300
+ const api_error = error ? output.split(/\r?\n/).map(json_line).map(claude_api_error).filter(Boolean).at(-1) : null;
301
+ if (api_error) task.on_failure(claude_error_reason(api_error), { retryable: !api_error.lasting });
302
+ else if (error) task.on_failure(error);
303
+ else task.on_exit?.();
304
+ });
305
+ task.on_process(child);
306
+ await new Promise((resolve_spawn, reject) => {
307
+ child.once('spawn', () => { spawned = true; resolve_spawn(); });
308
+ child.once('error', error => spawned ? task.on_failure(error.message) : reject(not_sent(error)));
309
+ });
310
+ // A failed spawn creates no session; only a successfully started process can be resumed.
311
+ task.on_binding(session_id);
312
+ return { session_id, status: 'accepted', capabilities: PROCESS };
313
+ }
314
+
315
+ /** Where Claude keeps one session's log, or '' when this machine has none by that ID. */
316
+ function session_path(session_id, env = process.env, home = homedir()) {
317
+ const projects = join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects');
318
+ if (!existsSync(projects)) return '';
319
+ return readdirSync(projects).map(dir => join(projects, dir, `${session_id}.jsonl`)).find(existsSync) || '';
320
+ }
321
+
322
+ /** Whether Claude stored a session by that ID on this machine, e.g. whether a pending fork already happened. */
323
+ function session_exists(session_id, { env = process.env, home = homedir() } = {}) {
324
+ return Boolean(session_id && session_path(session_id, env, home));
325
+ }
326
+
327
+ /** The directory a session was made in, from its log; Claude resumes a session only there. '' when it recorded none. */
328
+ function session_cwd(session_id, { env = process.env, home = homedir() } = {}) {
329
+ const file = session_path(session_id, env, home);
330
+ if (!file) return '';
331
+ return readFileSync(file, 'utf8').split('\n').map(line => { try { return JSON.parse(line).cwd; } catch { return undefined; } }).find(Boolean) || '';
332
+ }
333
+
334
+ /** Fork on the next turn at the last answer before the first marked prompt; a cut before any answer starts anew. */
335
+ function branch(profile, session_id, markers, { always, env, home }) {
336
+ const file = session_path(session_id, env, home);
337
+ if (!file) throw new Error(`本机找不到 Claude 会话 ${session_id}`);
338
+ const entries = log_entries(file);
339
+ const index = entries.findIndex(entry => entry.type === 'user' && marked(prompt_text(entry.message?.content), markers));
340
+ if (index < 0) return always ? { session_id: randomUUID(), fork: { from: session_id, at: '' } } : null;
341
+ const at = entries.slice(0, index).findLast(entry => entry.type === 'assistant' && entry.uuid)?.uuid;
342
+ return at ? { session_id: randomUUID(), fork: { from: session_id, at } } : { session_id: '' };
343
+ }
344
+
345
+ /** Claude's user settings: what it runs with when the card leaves model or effort empty. */
346
+ function defaults(profile, { env = process.env, home = homedir() } = {}) {
347
+ try {
348
+ const settings = JSON.parse(readFileSync(join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'settings.json'), 'utf8'));
349
+ return { model: typeof settings.model === 'string' ? settings.model : '', effort: typeof settings.effortLevel === 'string' ? settings.effortLevel : '' };
350
+ } catch { return { model: '', effort: '' }; }
351
+ }
352
+
353
+ /** The model and effort a session last ran with, from its own log; no turn is started. */
354
+ function session_settings(session_id, { env = process.env, home = homedir() } = {}) {
355
+ const file = session_path(session_id, env, home);
356
+ if (!file) return null;
357
+ return newest_record(file, '"type":"api-request"', record => record.type === 'api-request' && CLAUDE_MAIN_SOURCES.includes(record.querySource)
358
+ && typeof record.params?.model === 'string' ? { model: record.params.model, effort: String(record.params.output_config?.effort || '') } : null);
359
+ }
360
+
361
+ /** Claude's initialization exposes its model picker; the query process never persists a session or loads MCP servers. */
362
+ function models(profile, options) {
363
+ const args = ['--safe-mode', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json',
364
+ '--verbose', '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'];
365
+ return query_catalog(profile, args, (send, finish) => ({
366
+ start: () => send({ type: 'control_request', request_id: 'catalog', request: { subtype: 'initialize' } }),
367
+ line: line => {
368
+ const reply = json_line(line);
369
+ if (reply?.type !== 'control_response' || reply.response?.request_id !== 'catalog') return;
370
+ if (reply.response.subtype !== 'success') return finish(new Error('Claude 模型目录查询失败,请检查 CLI 版本与登录'));
371
+ finish(null, (reply.response.response?.models || []).map(model => model_entry(model.value, model.displayName,
372
+ model.supportedEffortLevels || (model.supportsEffort ? undefined : []))));
373
+ },
374
+ }), options);
375
+ }
376
+
377
+ /** Claude Code's behaviour; its static facts are in profiles.mjs. */
378
+ export const claude = {
379
+ id: 'claude', catalog_source: 'claude-cli',
380
+ into_window: claude_inbox_ready,
381
+ deliver, events,
382
+ session: { path: session_path, exists: session_exists, owns: session_exists, cwd: session_cwd, branch, settings: session_settings },
383
+ models: { list: models, defaults },
384
+ };