annotatable-html 1.38.3 → 1.45.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.
package/lib/claude.js ADDED
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ var fs = require('fs'), path = require('path'), os = require('os'), util = require('util');
4
+ var processes = require('./duty-process');
5
+ var canonical = value => path.resolve(value).replace(/\\/g, '/').toLowerCase();
6
+ function registrations(home, sessionId) {
7
+ var dir = path.join(home, 'sessions');
8
+ if (!fs.existsSync(dir)) return [];
9
+ return fs.readdirSync(dir).filter(n => /^\d+\.json$/.test(n)).flatMap(n => {
10
+ try { var r = JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8')); return (!sessionId || r.sessionId === sessionId) ? [r] : []; } catch (_) { return []; }
11
+ });
12
+ }
13
+ async function liveRegistrations(home, id) {
14
+ var live = [];
15
+ for (var r of registrations(home, id)) {
16
+ var p = await processes.processInfo(r.pid);
17
+ if (p && /^claude(?:\.exe)?$/i.test(p.Name) && Number(p.CreationDate.replace(/\/Date\((\d+)\)\//, '$1')) <= r.startedAt + 2000) live.push({ registration: r, process: p });
18
+ }
19
+ return live;
20
+ }
21
+ function transcript(home, id) {
22
+ var dir = path.join(home, 'projects');
23
+ var found = fs.existsSync(dir) ? fs.readdirSync(dir).map(n => path.join(dir, n, id + '.jsonl')).filter(n => fs.existsSync(n)) : [];
24
+ if (found.length !== 1) throw new Error('无法唯一定位指定 Claude 会话记录');
25
+ return found[0];
26
+ }
27
+ function savedTurn(file, id) {
28
+ var rows = processes.tailRows(file).filter(r => r.sessionId === id && !r.isSidechain);
29
+ var user = rows.findLast(r => r.type === 'user' && r.permissionMode);
30
+ var assistant = rows.findLast(r => r.type === 'assistant' && r.message && r.message.model);
31
+ if (!user || !assistant || !user.cwd) throw new Error('Claude 会话缺少可核对的模型、目录或权限');
32
+ return { model: assistant.message.model, cwd: user.cwd, permissionMode: user.permissionMode };
33
+ }
34
+ function resumeFlags(argv, cwd) {
35
+ var values = ['--allowedTools', '--allowed-tools', '--disallowedTools', '--disallowed-tools', '--add-dir', '--tools', '--settings', '--setting-sources', '--effort', '--agent', '--mcp-config', '--append-system-prompt', '--system-prompt', '--plugin-dir', '--betas'];
36
+ var boolean = ['--strict-mcp-config']; var result = [], files = [];
37
+ for (var i = 1; i < argv.length; i++) {
38
+ if (boolean.includes(argv[i])) { result.push(argv[i]); continue; }
39
+ var flag = argv[i].split('=')[0]; if (!values.includes(flag)) continue;
40
+ var inline = argv[i].includes('='), items = inline ? [argv[i].slice(flag.length + 1)] : [];
41
+ if (!inline && argv[i + 1] && !argv[i + 1].startsWith('-')) items.push(argv[++i]);
42
+ if (['--allowedTools', '--allowed-tools', '--disallowedTools', '--disallowed-tools', '--add-dir', '--tools', '--mcp-config', '--betas'].includes(flag)) {
43
+ while (!inline && argv[i + 1] && !argv[i + 1].startsWith('-')) items.push(argv[++i]);
44
+ }
45
+ if (!items.length) throw new Error('原 Claude 参数缺少值:' + flag);
46
+ if (flag === '--settings') {
47
+ items = items.map(value => {
48
+ if (value.trim().startsWith('{')) {
49
+ var parsed = JSON.parse(value);
50
+ if (parsed.env) {
51
+ if (Object.keys(parsed.env).some(k => process.env[k] !== String(parsed.env[k]))) throw new Error('请在原 Claude 会话内注册,以保留临时环境设置');
52
+ delete parsed.env; // Credentials stay in inherited memory, never in the binding file.
53
+ }
54
+ return JSON.stringify(parsed);
55
+ }
56
+ var file = path.resolve(cwd, value); files.push(file); return file;
57
+ });
58
+ }
59
+ if (flag === '--mcp-config') items = items.map(value => {
60
+ if (value.trim().startsWith('{')) throw new Error('临时内联 MCP 配置可能含凭据;请使用配置文件后在原会话重新注册');
61
+ var file = path.resolve(cwd, value); files.push(file); return file;
62
+ });
63
+ result.push(flag, ...items);
64
+ }
65
+ return { args: result, files: files };
66
+ }
67
+ function profileFiles(home, cwd, extra) {
68
+ return processes.configStamps(cwd, ['.claude/settings.json', '.claude/settings.local.json'], [path.join(home, 'settings.json')].concat(extra || []));
69
+ }
70
+ function providerContext(env) {
71
+ var base = new URL(env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com');
72
+ if (base.username || base.password || base.search || base.hash) throw new Error('Claude 提供方地址不能含内嵌凭据或查询参数');
73
+ return { baseURL: base.href, bedrock: env.CLAUDE_CODE_USE_BEDROCK || '', vertex: env.CLAUDE_CODE_USE_VERTEX || '',
74
+ foundry: env.CLAUDE_CODE_USE_FOUNDRY || '', region: env.AWS_REGION || env.AWS_DEFAULT_REGION || '',
75
+ vertexProject: env.ANTHROPIC_VERTEX_PROJECT_ID || '', vertexRegion: env.CLOUD_ML_REGION || '' };
76
+ }
77
+ var hookStatus = 'annotatable-html duty listener';
78
+ function hookCommand() {
79
+ return [process.execPath, path.join(__dirname, '../bin/cli.js'), 'duty', '--claude', '--hook'].map(value =>
80
+ "'" + value.replace(/\\/g, '/').replace(/'/g, "'\"'\"'") + "'").join(' ');
81
+ }
82
+ function updateHooks(binding, install) {
83
+ var core = require('./core'), file = path.join(binding.settings.cwd, '.claude', 'settings.local.json');
84
+ return core.withDocLock(file, function () {
85
+ var before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null, config = before ? JSON.parse(before) : {};
86
+ if (!config || Array.isArray(config) || typeof config !== 'object') throw new Error('Claude 项目设置必须为 JSON 对象');
87
+ if (install && (config.disableAllHooks || config.allowManagedHooksOnly)) throw new Error('原项目策略禁用了本地钩子,无法自动接回通知');
88
+ var events = ['SessionStart', 'Stop'], command = hookCommand(), changed = false;
89
+ config.hooks = config.hooks || {};
90
+ for (var event of events) {
91
+ var entries = config.hooks[event] || [], next = [];
92
+ for (var entry of entries) {
93
+ var hooks = entry.hooks.filter(h => !(h.type === 'command' && h.statusMessage === hookStatus));
94
+ if (hooks.length !== entry.hooks.length) changed = true;
95
+ if (hooks.length) next.push(Object.assign({}, entry, { hooks: hooks }));
96
+ }
97
+ if (install) {
98
+ var entry = { hooks: [{ type: 'command', command: command, asyncRewake: true, timeout: 2147483, statusMessage: hookStatus }] };
99
+ if (event === 'SessionStart') entry.matcher = 'startup|resume';
100
+ next.push(entry); changed = true;
101
+ }
102
+ if (next.length) config.hooks[event] = next; else delete config.hooks[event];
103
+ }
104
+ if (!Object.keys(config.hooks).length) delete config.hooks;
105
+ if (changed) {
106
+ var text = JSON.stringify(config, null, 2) + '\n';
107
+ if (before !== text) core.writeDocAtomic(file, text);
108
+ }
109
+ return { file: file, command: command };
110
+ });
111
+ }
112
+ function installResumeHooks(binding) {
113
+ binding.claude.resumeHook = updateHooks(binding, true);
114
+ binding.claude.files = binding.claude.files.map(x => processes.fileStamp(x.file));
115
+ }
116
+ function removeResumeHooks(binding) {
117
+ if (!binding.claude || !binding.claude.resumeHook) return;
118
+ var dir = path.dirname(binding.lockFile), duty = require('./duty');
119
+ for (var name of fs.existsSync(dir) ? fs.readdirSync(dir) : []) {
120
+ if (!/^duty-claude-[a-f0-9-]+\.lock$/i.test(name) || path.join(dir, name) === binding.lockFile) continue;
121
+ var lock = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
122
+ if (duty.alive(lock.pid)) return; // A project uses one pair of hooks for all its registered sessions.
123
+ }
124
+ updateHooks(binding, false);
125
+ }
126
+ async function resumeHook(input) {
127
+ if (!input || !['SessionStart', 'Stop'].includes(input.hook_event_name) || !/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(input.session_id || '') || !input.cwd) return;
128
+ if (process.env.ANNOTATABLE_DUTY_HEADLESS_SESSION === input.session_id) return;
129
+ var lockFile = path.join(input.cwd, '.annotatable', 'duty-claude-' + input.session_id + '.lock');
130
+ if (!fs.existsSync(lockFile)) return;
131
+ var lock = JSON.parse(fs.readFileSync(lockFile, 'utf8')), duty = require('./duty');
132
+ if (!duty.alive(lock.pid) || !lock.bindingFile || !fs.existsSync(lock.bindingFile)) return;
133
+ var binding = JSON.parse(fs.readFileSync(lock.bindingFile, 'utf8'));
134
+ if (binding.host !== 'claude' || binding.threadId !== input.session_id || binding.stopRequested ||
135
+ !binding.claude.resumeHook || canonical(binding.settings.cwd) !== canonical(input.cwd) ||
136
+ canonical(binding.lockFile) !== canonical(lockFile) || canonical(duty.pathsFor(binding.file).binding) !== canonical(lock.bindingFile)) return;
137
+ var deadline = Date.now() + 10000, connected;
138
+ for (;;) {
139
+ try { connected = await duty.listen(binding.file, { claudeSession: input.session_id, events: true, nativeHook: true }); break; }
140
+ catch (error) { if (Date.now() >= deadline) throw error; await new Promise(resolve => setTimeout(resolve, 200)); }
141
+ }
142
+ if (!connected.stopped && !connected.alreadyListening) await duty.events(binding.file, { exitCode: 2, hook: true });
143
+ }
144
+ async function dutyBinding(file, options) {
145
+ var home = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
146
+ var id = options.claudeSession || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID;
147
+ if (!id) {
148
+ var chain = await processes.parents(process.pid);
149
+ var candidates = registrations(home).filter(r => chain.some(p => p.ProcessId === r.pid));
150
+ if (candidates.length === 1) id = candidates[0].sessionId;
151
+ }
152
+ if (!/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(id || '')) throw new Error('Claude 续接需要明确的原会话编号;使用 --claude-session ID');
153
+ var matches = await liveRegistrations(home, id);
154
+ if (matches.length !== 1) throw new Error('请在原 Claude 会话仍运行时注册,无法确认唯一处理进程');
155
+ var owner = matches[0], record = transcript(home, id), saved = savedTurn(record, id);
156
+ var ancestry = chain || await processes.parents(process.pid), originalIndex = ancestry.findIndex(p => p.ProcessId === owner.process.ProcessId);
157
+ if (options.events && originalIndex < 0) throw new Error('请从原 Claude 会话的后台命令工具接上通知');
158
+ var bash = ancestry.slice(1, originalIndex < 0 ? 1 : originalIndex).find(p => /^bash(?:\.exe)?$/i.test(p.Name));
159
+ if (canonical(saved.cwd) !== canonical(owner.registration.cwd)) throw new Error('Claude 会话目录不一致');
160
+ var flags = resumeFlags(await processes.argumentsFor(owner.process.ProcessId), saved.cwd);
161
+ return { threadId: id, binary: options.claudeBin || owner.process.ExecutablePath, settings: saved,
162
+ claude: { home: home, transcript: record, args: flags.args, gitBashPath: bash && bash.ExecutablePath || process.env.CLAUDE_CODE_GIT_BASH_PATH || null,
163
+ provider: providerContext(process.env), files: profileFiles(home, saved.cwd, flags.files), owners: [processes.identity(owner.process)] } };
164
+ }
165
+ class ClaudeRpc extends processes.JsonProcess {
166
+ constructor(binding, options) {
167
+ var env = processes.agentEnv({ CLAUDE_CONFIG_DIR: binding.claude.home });
168
+ if (options && options.headless) env.ANNOTATABLE_DUTY_HEADLESS_SESSION = binding.threadId;
169
+ if (binding.claude.gitBashPath) env.CLAUDE_CODE_GIT_BASH_PATH = binding.claude.gitBashPath;
170
+ super(binding.binary, ['-p', options && options.fresh ? '--session-id' : '--resume', binding.threadId, '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
171
+ '--permission-prompt-tool', 'stdio', ...binding.claude.args, '--model', binding.settings.model, '--permission-mode', binding.settings.permissionMode], {
172
+ cwd: binding.settings.cwd, env: env,
173
+ protocol: { request: (id, subtype, params) => ({ type: 'control_request', request_id: id, request: Object.assign({ subtype: subtype }, params) }),
174
+ response: m => m.type === 'control_response' ? { id: m.response.request_id, result: m.response.response, error: m.response.subtype === 'error' ? m.response.error : null } : null,
175
+ isRequest: m => m.type === 'control_request' }
176
+ });
177
+ }
178
+ }
179
+ async function runHeadless(binding, hit, options) {
180
+ options = options || {};
181
+ if (!util.isDeepStrictEqual(savedTurn(binding.claude.transcript, binding.threadId), binding.settings) ||
182
+ !util.isDeepStrictEqual(binding.claude.files.map(x => processes.fileStamp(x.file)), binding.claude.files)) throw new Error('Claude 模型、权限或配置已改变,请重新注册');
183
+ if (!(await options.released()).released) throw new Error('原 Claude 进程尚未退出');
184
+ var rpc = options.rpc || new ClaudeRpc(binding, { headless: true }), record = { host: 'claude', threadId: binding.threadId, pid: rpc.child.pid, startedAt: Date.now() }, timer;
185
+ if (options.onStart) options.onStart(record);
186
+ var refusal, complete;
187
+ var done = new Promise((resolve, reject) => { complete = { resolve: resolve, reject: reject }; }); done.catch(function () {});
188
+ rpc.on('serverRequest', message => {
189
+ record.approval = { subtype: message.request.subtype, tool: message.request.tool_name || null };
190
+ rpc.send({ type: 'control_response', response: { subtype: 'success', request_id: message.request_id,
191
+ response: { behavior: 'deny', message: '无人值守保留待办,不扩大权限' } } });
192
+ refusal = 'Claude 需要人工审批或原宿主工具,保留待办'; rpc.request('interrupt', {}).catch(function () {});
193
+ });
194
+ rpc.on('notification', message => {
195
+ if (message.session_id && message.session_id !== binding.threadId) { complete.reject(new Error('Claude 返回其他会话')); return; }
196
+ if (message.type === 'system' && message.subtype === 'init') {
197
+ record.restored = { model: message.model, cwd: message.cwd, permissionMode: message.permissionMode };
198
+ record.shellTools = (message.tools || []).filter(name => ['Bash', 'PowerShell'].includes(name));
199
+ if (message.model !== binding.settings.model || message.permissionMode !== binding.settings.permissionMode || canonical(message.cwd) !== canonical(binding.settings.cwd)) { refusal = 'Claude 恢复设置不一致'; rpc.request('interrupt', {}).catch(function () {}); }
200
+ }
201
+ if (message.type === 'result') complete.resolve(message);
202
+ });
203
+ rpc.on('exit', () => complete.reject(new Error('Claude 在回复结束前退出')));
204
+ try {
205
+ await rpc.request('initialize', {});
206
+ var settings = await rpc.request('get_settings', {});
207
+ if (!settings.applied || settings.applied.model !== binding.settings.model || settings.errors && settings.errors.length) throw new Error('Claude 配置或模型预检失败');
208
+ if (binding.claude.provider && !util.isDeepStrictEqual(providerContext(Object.assign({}, process.env, settings.effective && settings.effective.env)), binding.claude.provider)) throw new Error('Claude 提供方不一致,保留待办');
209
+ if (!(await options.released(rpc.child.pid)).released) throw new Error('原 Claude 会话已重开,暂停无头执行');
210
+ record.effectivePermissions = settings.effective && settings.effective.permissions;
211
+ var turnPrompt = require('./duty').prompt(binding, hit, { headless: true });
212
+ if (binding.claude.gitBashPath) turnPrompt += '原会话使用 Bash;继续用 Bash 运行上述 node 命令,不切换命令工具。';
213
+ rpc.send({ type: 'user', session_id: binding.threadId, parent_tool_use_id: null,
214
+ message: { role: 'user', content: turnPrompt } });
215
+ timer = setTimeout(() => { refusal = 'Claude 回复超时'; rpc.request('interrupt', {}).catch(function () {}); complete.reject(new Error(refusal)); }, options.timeoutMs || 300000);
216
+ var result = await done;
217
+ if (refusal || !record.restored || result.is_error || result.subtype !== 'success') throw new Error(refusal || 'Claude 未成功完成回复');
218
+ record.outcome = 'completed'; return record;
219
+ } catch (error) { record.outcome = 'failed'; record.error = error.message; throw Object.assign(error, { dutyRun: record }); }
220
+ finally { clearTimeout(timer); await rpc.close(); record.finishedAt = Date.now(); }
221
+ }
222
+ function createDutyAdapter(getBinding, updateBinding) {
223
+ async function live() { var b = getBinding(); return liveRegistrations(b.claude.home, b.threadId); }
224
+ async function released(excludePid) {
225
+ var matches = (await live()).filter(x => x.process.ProcessId !== excludePid);
226
+ if (matches.length) return { released: false, reason: 'claude-session-process-alive' };
227
+ return processes.ownersExited(getBinding().claude.owners);
228
+ }
229
+ return {
230
+ online: async () => {
231
+ var b = getBinding(), matches = await live(); if (matches.length !== 1) return null;
232
+ if (!processes.sameProcess(b.claude.owners[0], matches[0].process)) updateBinding(Object.assign({}, b, { claude: Object.assign({}, b.claude, { owners: [processes.identity(matches[0].process)] }) }));
233
+ var connected = b.bridge && processes.sameProcess(b.bridge, await processes.processInfo(b.bridge.ProcessId));
234
+ return { available: true, active: !connected, notify: async () => {} }; // The original host consumes its own background task output.
235
+ },
236
+ released: released, run: (hit, onStart) => runHeadless(getBinding(), hit, { onStart: onStart, released: released }),
237
+ close: () => { var b = getBinding(); if (b.stopRequested) removeResumeHooks(b); }
238
+ };
239
+ }
240
+ module.exports = { dutyBinding: dutyBinding, createDutyAdapter: createDutyAdapter, ClaudeRpc: ClaudeRpc,
241
+ runHeadless: runHeadless, savedTurn: savedTurn, resumeFlags: resumeFlags, liveRegistrations: liveRegistrations,
242
+ installResumeHooks: installResumeHooks, removeResumeHooks: removeResumeHooks, resumeHook: resumeHook };
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ // The Windows desktop app owns a separate stdio app-server. Use its inherited
4
+ // app-tools pipe instead of resuming the same thread in an unrelated daemon.
5
+ var net = require('net');
6
+ var os = require('os');
7
+ var crypto = require('crypto');
8
+ var MAX_FRAME = 8 * 1024 * 1024;
9
+
10
+ class DesktopRpc {
11
+ constructor(pipePath) {
12
+ this.pipePath = pipePath;
13
+ this.pending = new Map();
14
+ this.nextId = 0;
15
+ this.buffer = Buffer.alloc(0);
16
+ }
17
+ async open() {
18
+ await new Promise((resolve, reject) => {
19
+ var timer = setTimeout(() => { this.close(); reject(new Error('Codex 桌面连接超时')); }, 10000);
20
+ this.socket = net.createConnection(this.pipePath);
21
+ this.socket.once('connect', () => { clearTimeout(timer); resolve(); });
22
+ this.socket.on('data', chunk => this.receive(chunk));
23
+ this.socket.on('error', error => { clearTimeout(timer); this.abort(error); reject(error); });
24
+ this.socket.on('close', () => {
25
+ clearTimeout(timer);
26
+ var error = new Error('Codex 桌面连接已关闭;请在当前桌面会话重挂监听');
27
+ this.abort(error); reject(error);
28
+ });
29
+ });
30
+ }
31
+ abort(error) {
32
+ if (!this.failure) this.failure = error;
33
+ for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
34
+ this.pending.clear();
35
+ if (this.socket) this.socket.destroy();
36
+ }
37
+ receive(chunk) {
38
+ this.buffer = Buffer.concat([this.buffer, chunk]);
39
+ while (this.buffer.length >= 4) {
40
+ var size = os.endianness() === 'LE' ? this.buffer.readUInt32LE(0) : this.buffer.readUInt32BE(0);
41
+ if (size > MAX_FRAME) { this.abort(new Error('Codex 桌面响应超过大小限制')); return; }
42
+ if (this.buffer.length < size + 4) return;
43
+ var message;
44
+ try { message = JSON.parse(this.buffer.subarray(4, size + 4).toString('utf8')); }
45
+ catch (_) { this.abort(new Error('Codex 桌面返回无效 JSON')); return; }
46
+ this.buffer = this.buffer.subarray(size + 4);
47
+ var pending = this.pending.get(message.id);
48
+ if (!pending) continue;
49
+ this.pending.delete(message.id); clearTimeout(pending.timer);
50
+ if (message.error) pending.reject(new Error('Codex 桌面接口失败:' + message.error.message));
51
+ else pending.resolve(message.result);
52
+ }
53
+ }
54
+ request(method, params) {
55
+ if (this.failure) return Promise.reject(this.failure);
56
+ return new Promise((resolve, reject) => {
57
+ var id = ++this.nextId;
58
+ var body = Buffer.from(JSON.stringify({ id: id, jsonrpc: '2.0', method: method, params: params }));
59
+ if (body.length > MAX_FRAME) { reject(new Error('Codex 桌面请求超过大小限制')); return; }
60
+ var header = Buffer.alloc(4);
61
+ if (os.endianness() === 'LE') header.writeUInt32LE(body.length); else header.writeUInt32BE(body.length);
62
+ var timer = setTimeout(() => {
63
+ this.pending.delete(id);
64
+ reject(new Error('Codex 桌面 ' + method + ' 超时;结果不确定,请先核对会话,不要重复发送'));
65
+ }, 15000);
66
+ this.pending.set(id, { resolve: resolve, reject: reject, timer: timer });
67
+ try { this.socket.write(Buffer.concat([header, body])); }
68
+ catch (error) { this.pending.delete(id); clearTimeout(timer); reject(error); }
69
+ });
70
+ }
71
+ async call(tool, args, context) {
72
+ var result = await this.request('tools/call', {
73
+ callerSource: 'codex', namespace: 'codex_app', hostId: 'local',
74
+ callId: 'annotatable-' + crypto.randomUUID(), threadId: context.threadId,
75
+ turnId: context.turnId, tool: tool, arguments: args
76
+ });
77
+ if (!result || result.success !== true) throw new Error('Codex 桌面工具 ' + tool + ' 未成功;批注仍保留在文档中');
78
+ var item = (result.contentItems || []).find(item => item.type === 'inputText');
79
+ if (!item) throw new Error('Codex 桌面工具 ' + tool + ' 缺少结果');
80
+ try { return JSON.parse(item.text); }
81
+ catch (_) { throw new Error('Codex 桌面工具 ' + tool + ' 返回无效结果'); }
82
+ }
83
+ close() { this.abort(new Error('Codex 桌面连接已结束')); }
84
+ }
85
+
86
+ class DesktopNotifier {
87
+ constructor(pipePath, threadId, turnId) {
88
+ if (!pipePath || !threadId || !turnId) throw new Error('Codex 桌面通知缺少连接、会话或来源轮次');
89
+ this.rpc = new DesktopRpc(pipePath);
90
+ this.context = { threadId: threadId, turnId: turnId };
91
+ this.kind = 'desktop';
92
+ }
93
+ async open() {
94
+ await this.rpc.open();
95
+ var result = await this.rpc.request('tools/list', { threadStartKind: 'all' });
96
+ for (const name of ['read_thread', 'send_message_to_thread']) {
97
+ if (!(result.tools || []).some(tool => tool.namespace === 'codex_app' && tool.name === name)) {
98
+ throw new Error('Codex 桌面不提供 ' + name + ';无法恢复自动通知');
99
+ }
100
+ }
101
+ return this.read();
102
+ }
103
+ async read() {
104
+ var result = await this.rpc.call('read_thread', {
105
+ threadId: this.context.threadId, hostId: 'local', turnLimit: 1,
106
+ includeOutputs: false, maxOutputCharsPerItem: 0
107
+ }, this.context);
108
+ if (!result.thread || result.thread.id !== this.context.threadId || result.thread.hostId !== 'local') {
109
+ throw new Error('Codex 桌面返回了其他会话;停止监听');
110
+ }
111
+ return result;
112
+ }
113
+ async notify(hit, options) {
114
+ var prompt = '【批注工具自动通知】用户指定的页面收到待回应批注。请按 annotatable-html 工作流读取完整批注并回复,完成后重新挂同一页面的监听。\n' +
115
+ '这条消息由本机监听程序发出,不是用户新输入。\nPOLL_HIT ' + JSON.stringify(hit);
116
+ if (options && options.managed) {
117
+ prompt = '【指定页面批注自动续接】请读取并回复用户已指定页面的待回应批注。\n' +
118
+ '后台续接程序负责后续监听,不要重挂 poll 或启动另一个控制器。长回复通过 UTF-8 JSON 文件交给批注 CLI,完成后核对落盘。\n' +
119
+ 'POLL_HIT ' + JSON.stringify(hit);
120
+ }
121
+ var result = await this.rpc.call('send_message_to_thread', {
122
+ threadId: this.context.threadId, hostId: 'local', prompt: prompt
123
+ }, this.context);
124
+ if (result.threadId !== this.context.threadId) throw new Error('Codex 桌面未确认目标会话收到通知');
125
+ return { threadId: result.threadId, transport: this.kind };
126
+ }
127
+ close() { this.rpc.close(); }
128
+ }
129
+
130
+ module.exports = { DesktopRpc: DesktopRpc, DesktopNotifier: DesktopNotifier };
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ var fs = require('fs');
4
+ var path = require('path');
5
+ var JsonProcess = require('./duty-process').JsonProcess;
6
+ var util = require('util');
7
+
8
+ function canonicalPath(value) { return path.resolve(value).replace(/\\/g, '/').toLowerCase(); }
9
+ function readSettings(thread) {
10
+ if (!thread || !thread.id || !thread.path || !thread.modelProvider || !thread.model) throw new Error('会话缺少可核对的续接设置');
11
+ var fd = fs.openSync(thread.path, 'r'), size, buffer;
12
+ try {
13
+ size = fs.fstatSync(fd).size; buffer = Buffer.alloc(Math.min(size, 4 * 1024 * 1024));
14
+ fs.readSync(fd, buffer, 0, buffer.length, size - buffer.length);
15
+ } finally { fs.closeSync(fd); }
16
+ var tail = buffer.toString('utf8').split(/\r?\n/);
17
+ var context;
18
+ for (var line of tail) {
19
+ try { var row = JSON.parse(line); if (row.type === 'turn_context') context = row.payload; } catch (_) {}
20
+ }
21
+ if (!context || !context.permission_profile || !context.sandbox_policy || !context.approval_policy) {
22
+ throw new Error('无法核对会话原有权限;保留批注,不启动无头模型');
23
+ }
24
+ if (canonicalPath(context.cwd) !== canonicalPath(thread.cwd) || context.model !== thread.model) {
25
+ throw new Error('会话元数据与最近轮次设置不一致');
26
+ }
27
+ return { model: thread.model, modelProvider: thread.modelProvider, cwd: thread.cwd,
28
+ reasoningEffort: context.effort || thread.reasoningEffort || null,
29
+ approvalPolicy: context.approval_policy, approvalsReviewer: context.approvals_reviewer || 'user',
30
+ sandbox: context.sandbox_policy, permissionProfile: context.permission_profile, rolloutPath: thread.path };
31
+ }
32
+ function normalizeSandbox(policy, cwd) {
33
+ var type = { 'workspace-write': 'workspaceWrite', 'read-only': 'readOnly', 'danger-full-access': 'dangerFullAccess', 'external-sandbox': 'externalSandbox' }[policy.type] || policy.type;
34
+ var result = { type: type, network: policy.networkAccess === undefined ? policy.network_access || false : policy.networkAccess };
35
+ if (type === 'workspaceWrite') {
36
+ result.roots = Array.from(new Set([cwd].concat(policy.writableRoots || policy.writable_roots || []).map(canonicalPath))).sort();
37
+ result.excludeTmp = !!(policy.excludeTmpdirEnvVar || policy.exclude_tmpdir_env_var);
38
+ result.excludeSlash = !!(policy.excludeSlashTmp || policy.exclude_slash_tmp);
39
+ }
40
+ return result;
41
+ }
42
+ function assertRestored(saved, restored) {
43
+ var valid = restored.thread && restored.thread.id && restored.model === saved.model && restored.modelProvider === saved.modelProvider &&
44
+ canonicalPath(restored.cwd) === canonicalPath(saved.cwd) &&
45
+ util.isDeepStrictEqual(saved.approvalPolicy, restored.approvalPolicy) && saved.approvalsReviewer === restored.approvalsReviewer &&
46
+ util.isDeepStrictEqual(normalizeSandbox(saved.sandbox, saved.cwd), normalizeSandbox(restored.sandbox, restored.cwd));
47
+ if (!valid) throw new Error('无头恢复后的模型、目录或权限不一致;未启动回复轮次');
48
+ }
49
+
50
+ class StdioRpc extends JsonProcess {
51
+ constructor(binary, home, cwd) {
52
+ var env = require('./duty-process').agentEnv({ CODEX_HOME: home });
53
+ super(binary, ['app-server', '--stdio'], { cwd: cwd, env: env });
54
+ }
55
+ async open() {
56
+ await this.request('initialize', { clientInfo: { name: 'annotatable_duty', version: require('../package.json').version }, capabilities: { experimentalApi: true } });
57
+ this.send({ method: 'initialized', params: {} });
58
+ }
59
+ }
60
+
61
+ async function runHeadless(binding, hit, options) {
62
+ options = options || {};
63
+ if (binding.settings.rolloutPath) {
64
+ var latest = readSettings({ id: binding.threadId, path: binding.settings.rolloutPath, model: binding.settings.model,
65
+ modelProvider: binding.settings.modelProvider, cwd: binding.settings.cwd });
66
+ if (!util.isDeepStrictEqual(latest, binding.settings)) throw new Error('注册后会话设置已改变;请重新注册,不自动覆盖原设置');
67
+ }
68
+ var released = await (options.checkReleased || require('./duty-process').released)(binding.desktop.owners);
69
+ if (!released.released) throw new Error('桌面运行时尚未确认退出;不启动第二份会话');
70
+ var rpc = options.rpc || new StdioRpc(binding.binary, binding.codexHome, binding.settings.cwd);
71
+ var record = { startedAt: Date.now(), threadId: binding.threadId, pid: rpc.child && rpc.child.pid, outcome: 'starting', events: [] };
72
+ if (options.onStart) options.onStart(record);
73
+ var turnId;
74
+ var requestError;
75
+ var terminal;
76
+ var timeout;
77
+ var finished = new Promise((resolve, reject) => { terminal = { resolve: resolve, reject: reject }; });
78
+ // Avoid an unhandled rejection while initialization is still in progress.
79
+ finished.catch(function () {});
80
+ function interrupt(reason) {
81
+ requestError = reason;
82
+ if (turnId) rpc.request('turn/interrupt', { threadId: binding.threadId, turnId: turnId }).catch(function () {});
83
+ }
84
+ rpc.on('serverRequest', message => {
85
+ if (message.method === 'item/tool/call') {
86
+ rpc.send({ id: message.id, result: { success: false, contentItems: [{ type: 'inputText', text: '当前为无头宿主,桌面动态工具不可用。使用内置命令执行工具运行本地批注 CLI;不要申请扩大权限。' }] } });
87
+ record.events.push({ kind: 'desktop-tool-unavailable', tool: message.params.tool });
88
+ } else if (/requestApproval$/.test(message.method)) {
89
+ rpc.send({ id: message.id, result: { decision: 'decline' } });
90
+ interrupt('需要人工审批;批注保留待处理');
91
+ } else {
92
+ rpc.send({ id: message.id, error: { code: -32601, message: 'Unattended request requires a user or an unavailable host' } });
93
+ interrupt('无头宿主无法处理 ' + message.method);
94
+ }
95
+ });
96
+ rpc.on('notification', message => {
97
+ if (message.params && message.params.threadId && message.params.threadId !== binding.threadId) return;
98
+ if (message.method === 'turn/completed') {
99
+ record.turnStatus = message.params.turn.status;
100
+ terminal.resolve(message.params.turn);
101
+ }
102
+ if (message.method === 'item/completed' && message.params.item && message.params.item.type === 'agentMessage') {
103
+ record.finalMessage = message.params.item.text;
104
+ }
105
+ });
106
+ rpc.on('exit', () => terminal.reject(new Error('无头进程在轮次完成前退出')));
107
+ try {
108
+ await rpc.open();
109
+ var restored = await rpc.request('thread/resume', { threadId: binding.threadId, excludeTurns: true, model: binding.settings.model });
110
+ if (!restored.thread || restored.thread.id !== binding.threadId) throw new Error('恢复到了其他会话');
111
+ assertRestored(binding.settings, restored);
112
+ record.restored = { model: restored.model, modelProvider: restored.modelProvider, cwd: restored.cwd,
113
+ approvalPolicy: restored.approvalPolicy, approvalsReviewer: restored.approvalsReviewer, sandbox: restored.sandbox };
114
+ if (!(await (options.checkReleased || require('./duty-process').released)(binding.desktop.owners)).released) throw new Error('桌面已重开;不启动无头回复轮次');
115
+ var prompt = require('./duty').prompt(binding, hit, { headless: true });
116
+ var started = await rpc.request('turn/start', { threadId: binding.threadId, input: [{ type: 'text', text: prompt }], effort: binding.settings.reasoningEffort || undefined });
117
+ turnId = started.turn.id; record.turnId = turnId;
118
+ if (requestError) interrupt(requestError);
119
+ timeout = setTimeout(() => { interrupt('无头处理超时,保留待办'); setTimeout(() => terminal.reject(new Error(requestError)), 10000).unref(); }, options.timeoutMs || 300000);
120
+ var turn = await finished;
121
+ if (requestError || turn.status !== 'completed') throw new Error(requestError || '无头轮次未完成:' + turn.status);
122
+ record.outcome = 'completed';
123
+ return record;
124
+ } catch (error) { record.outcome = 'failed'; record.error = error.message; throw Object.assign(error, { dutyRun: record }); }
125
+ finally { clearTimeout(timeout); await rpc.close(); record.finishedAt = Date.now(); if (options.onFinish) options.onFinish(record); }
126
+ }
127
+
128
+ module.exports = { readSettings: readSettings, normalizeSandbox: normalizeSandbox, assertRestored: assertRestored,
129
+ StdioRpc: StdioRpc, runHeadless: runHeadless, canonicalPath: canonicalPath };