annotatable-html 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +352 -2
- package/agent/annot_answer.js +7 -0
- package/agent/annot_edit.js +6 -0
- package/agent/annot_poll.js +19 -0
- package/agent/annot_read.js +5 -0
- package/agent/annot_reply.js +50 -0
- package/bin/cli.js +323 -0
- package/lib/claude.js +242 -0
- package/lib/codex-desktop.js +130 -0
- package/lib/codex-headless.js +129 -0
- package/lib/codex.js +216 -0
- package/lib/core.js +2206 -0
- package/lib/document.js +56 -0
- package/lib/duty-process.js +190 -0
- package/lib/duty-worker.js +5 -0
- package/lib/duty.js +334 -0
- package/lib/lang-en.js +462 -0
- package/lib/opencode.js +195 -0
- package/lib/poll-process.js +42 -0
- package/lib/serve.js +318 -0
- package/lib/vendor/marked.cjs +2811 -0
- package/lib/vendor/temml.cjs +14710 -0
- package/package.json +51 -6
- package/templates/SKILL.md +53 -0
- package/templates/annotatable-doc.html +5702 -0
- package/templates/annotatable.theme.css +28 -0
- package/templates/commands/html4agent.md +6 -0
- package/templates/opencode/annotatable-html.js +12 -0
- package/templates/references/compatibility.md +14 -0
- package/templates/references/hosts.md +47 -0
- package/templates/references/operations.md +37 -0
- package/templates/theme-studio.html +330 -0
package/lib/document.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Resolve a page served on this machine to its document. No registry or background scan.
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
var core = require('./core.js');
|
|
7
|
+
|
|
8
|
+
function resolveDocument(source, options) {
|
|
9
|
+
var page;
|
|
10
|
+
try { page = new URL(source); } catch (_) { return Promise.reject(new Error('页面地址无效')); }
|
|
11
|
+
if (!/^https?:$/.test(page.protocol) || ['localhost', '127.0.0.1', '[::1]'].indexOf(page.hostname) === -1) {
|
|
12
|
+
return Promise.reject(new Error('只支持本机 localhost 或回环地址的批注页面'));
|
|
13
|
+
}
|
|
14
|
+
if (page.username || page.password) return Promise.reject(new Error('页面地址不要包含用户名或密码'));
|
|
15
|
+
var rel;
|
|
16
|
+
try { rel = decodeURIComponent(page.pathname); } catch (_) { return Promise.reject(new Error('页面路径编码无效')); }
|
|
17
|
+
if (!/\.html?$/i.test(rel)) return Promise.reject(new Error('请提供具体的 HTML 页面地址'));
|
|
18
|
+
var endpoint = new URL('/__annot/context', page.origin);
|
|
19
|
+
endpoint.searchParams.set('path', rel);
|
|
20
|
+
if (endpoint.hostname === 'localhost') endpoint.hostname = '127.0.0.1';
|
|
21
|
+
return new Promise(function (resolve, reject) {
|
|
22
|
+
var client = require(endpoint.protocol === 'https:' ? 'https' : 'http');
|
|
23
|
+
var req = client.get(endpoint, function (res) {
|
|
24
|
+
var chunks = [], size = 0;
|
|
25
|
+
res.on('data', function (chunk) {
|
|
26
|
+
size += chunk.length;
|
|
27
|
+
if (size > 65536) { req.destroy(new Error('页面定位信息过大')); return; }
|
|
28
|
+
chunks.push(chunk);
|
|
29
|
+
});
|
|
30
|
+
res.on('error', reject);
|
|
31
|
+
res.on('end', function () {
|
|
32
|
+
if (res.statusCode !== 200) {
|
|
33
|
+
reject(new Error('无法定位文档(HTTP ' + res.statusCode + ');旧服务请更新后重启 annotatable serve'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
var info = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
38
|
+
if (info.service !== 'annotatable-html' || !path.isAbsolute(info.file || '') || !path.isAbsolute(info.root || '')) {
|
|
39
|
+
throw new Error('该地址未返回批注服务的文档信息');
|
|
40
|
+
}
|
|
41
|
+
var relative = path.relative(info.root, info.file);
|
|
42
|
+
if (relative === '..' || relative.indexOf('..' + path.sep) === 0 || path.isAbsolute(relative)) {
|
|
43
|
+
throw new Error('文档定位结果超出服务目录');
|
|
44
|
+
}
|
|
45
|
+
var html = fs.readFileSync(info.file, 'utf8');
|
|
46
|
+
if (!core.readStore(html) || core.docIdOf(html) !== info.docId) throw new Error('本机文档与页面标识不一致');
|
|
47
|
+
resolve(options && options.includeContext ? { file: info.file, root: info.root, pagePath: rel, port: Number(page.port || (page.protocol === 'https:' ? 443 : 80)), protocol: page.protocol } : info.file);
|
|
48
|
+
} catch (err) { reject(err); }
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
req.setTimeout(5000, function () { req.destroy(new Error('页面服务连接超时')); });
|
|
52
|
+
req.on('error', reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { resolveDocument: resolveDocument };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Process identity is evidence of release; a socket timeout is never evidence.
|
|
4
|
+
var cp = require('child_process');
|
|
5
|
+
var fs = require('fs');
|
|
6
|
+
var path = require('path');
|
|
7
|
+
var readline = require('readline');
|
|
8
|
+
var EventEmitter = require('events');
|
|
9
|
+
|
|
10
|
+
function literal(value) { return "'" + String(value).replace(/'/g, "''") + "'"; }
|
|
11
|
+
function powershell(script) {
|
|
12
|
+
return new Promise(function (resolve, reject) {
|
|
13
|
+
cp.execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
14
|
+
'[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); ' + script],
|
|
15
|
+
{ windowsHide: true, encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 }, function (error, stdout, stderr) {
|
|
16
|
+
if (error) { reject(new Error('无法确认宿主进程状态:' + (stderr || error.message).trim())); return; }
|
|
17
|
+
try { resolve(stdout.trim() ? JSON.parse(stdout.trim()) : null); }
|
|
18
|
+
catch (_) { reject(new Error('宿主进程检查未返回有效 JSON')); }
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function fields() { return 'ProcessId,ParentProcessId,Name,CreationDate,ExecutablePath,CommandLine'; }
|
|
23
|
+
async function processInfo(pid) {
|
|
24
|
+
if (!Number.isInteger(pid) || pid < 1) throw new Error('无效的进程编号');
|
|
25
|
+
if (process.platform !== 'win32') throw new Error('此版本仅在 Windows 验证了桌面所有权检查');
|
|
26
|
+
return powershell('Get-CimInstance Win32_Process -Filter ' + literal('ProcessId=' + pid) + ' | Select-Object ' + fields() + ' | ConvertTo-Json -Compress');
|
|
27
|
+
}
|
|
28
|
+
function sameProcess(expected, actual) {
|
|
29
|
+
return !!(expected && actual && expected.CreationDate && expected.ExecutablePath && expected.ProcessId === actual.ProcessId &&
|
|
30
|
+
expected.CreationDate === actual.CreationDate &&
|
|
31
|
+
String(expected.ExecutablePath).toLowerCase() === String(actual.ExecutablePath).toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
function identity(p) {
|
|
34
|
+
if (!p) return null;
|
|
35
|
+
return { ProcessId: p.ProcessId, ParentProcessId: p.ParentProcessId, Name: p.Name, CreationDate: p.CreationDate, ExecutablePath: p.ExecutablePath };
|
|
36
|
+
}
|
|
37
|
+
function agentEnv(extra) {
|
|
38
|
+
var env = Object.assign({}, process.env, extra || {});
|
|
39
|
+
for (var key of ['CODEX_APP_TOOLS_PIPE_PATH', 'CODEX_THREAD_ID', 'CODEX_TURN_ID', 'CODEX_SESSION_ID', 'CODEX_TOOL_CALL_ID',
|
|
40
|
+
'CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_ENTRYPOINT',
|
|
41
|
+
'ANNOTATABLE_OPENCODE_SESSION_ID', 'ANNOTATABLE_OPENCODE_SERVER_URL', 'ANNOTATABLE_OPENCODE_DIRECTORY']) delete env[key];
|
|
42
|
+
return env;
|
|
43
|
+
}
|
|
44
|
+
function tailRows(file) {
|
|
45
|
+
var fd = fs.openSync(file, 'r'), buffer;
|
|
46
|
+
try { var size = fs.fstatSync(fd).size; buffer = Buffer.alloc(Math.min(size, 4 * 1024 * 1024)); fs.readSync(fd, buffer, 0, buffer.length, size - buffer.length); }
|
|
47
|
+
finally { fs.closeSync(fd); }
|
|
48
|
+
return buffer.toString('utf8').split(/\r?\n/).flatMap(line => { try { return [JSON.parse(line)]; } catch (_) { return []; } });
|
|
49
|
+
}
|
|
50
|
+
function fileStamp(file) { try { var s = fs.statSync(file); return { file: file, size: s.size, mtimeMs: s.mtimeMs }; } catch (e) { if (e.code === 'ENOENT') return { file: file, missing: true }; throw e; } }
|
|
51
|
+
function configStamps(cwd, relativeNames, extra) {
|
|
52
|
+
var files = (extra || []).slice();
|
|
53
|
+
for (var at = cwd;; at = path.dirname(at)) {
|
|
54
|
+
for (var name of relativeNames) files.push(path.join(at, name));
|
|
55
|
+
if (path.dirname(at) === at) break;
|
|
56
|
+
}
|
|
57
|
+
return Array.from(new Set(files)).map(fileStamp);
|
|
58
|
+
}
|
|
59
|
+
async function parents(pid) {
|
|
60
|
+
if (!Number.isInteger(pid) || pid < 1) throw new Error('无效的进程编号');
|
|
61
|
+
return powershell('$taskPs=@(Get-CimInstance Win32_Process); $taskNext=' + pid + '; $taskResult=@(); for($taskI=0;$taskI -lt 20;$taskI++) { $taskP=$taskPs | Where-Object ProcessId -eq $taskNext; if(-not $taskP){break}; $taskResult+=($taskP | Select-Object ' + fields() + '); $taskNext=$taskP.ParentProcessId }; ConvertTo-Json -InputObject @($taskResult) -Compress');
|
|
62
|
+
}
|
|
63
|
+
async function ownersExited(owners) {
|
|
64
|
+
if (!Array.isArray(owners) || !owners.length) return { released: false, reason: 'missing-owner-evidence' };
|
|
65
|
+
for (var owner of owners) if (sameProcess(owner, await processInfo(owner.ProcessId))) return { released: false, reason: 'host-process-alive', pid: owner.ProcessId };
|
|
66
|
+
return { released: true, reason: 'recorded-host-processes-exited' };
|
|
67
|
+
}
|
|
68
|
+
async function argumentsFor(pid) {
|
|
69
|
+
if (!Number.isInteger(pid) || pid < 1) throw new Error('无效的进程编号');
|
|
70
|
+
var source = 'using System; using System.Runtime.InteropServices; public static class AnnotatableArgs { [DllImport("shell32.dll",SetLastError=true)] static extern IntPtr CommandLineToArgvW([MarshalAs(UnmanagedType.LPWStr)] string s,out int n); [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr p); public static string[] Read(string s){int n;IntPtr p=CommandLineToArgvW(s,out n);if(p==IntPtr.Zero)throw new Exception("argv unavailable");try{string[] a=new string[n];for(int i=0;i<n;i++)a[i]=Marshal.PtrToStringUni(Marshal.ReadIntPtr(p,i*IntPtr.Size));return a;}finally{LocalFree(p);}} }';
|
|
71
|
+
return powershell('Add-Type -TypeDefinition ' + literal(source) + '; $taskP=Get-CimInstance Win32_Process -Filter ' + literal('ProcessId=' + pid) + '; if(-not $taskP){throw "Host exited"}; ConvertTo-Json -InputObject @([AnnotatableArgs]::Read($taskP.CommandLine)) -Compress');
|
|
72
|
+
}
|
|
73
|
+
async function listeners(binary) {
|
|
74
|
+
var filter = binary ? ' | Where-Object { $_.ExecutablePath -eq ' + literal(binary) + ' }' : '';
|
|
75
|
+
return powershell('$taskPs=@(Get-CimInstance Win32_Process' + filter + '); $taskPorts=@(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue); ' +
|
|
76
|
+
'@($taskPs | ForEach-Object { $taskP=$_; $taskOwn=@($taskPorts | Where-Object OwningProcess -eq $taskP.ProcessId); if($taskOwn.Count){ @{ process=($taskP | Select-Object ' + fields() + '); ports=@($taskOwn | Select-Object -ExpandProperty LocalPort -Unique) } } }) | ConvertTo-Json -Depth 5 -Compress');
|
|
77
|
+
}
|
|
78
|
+
async function portOwner(port) {
|
|
79
|
+
var all = await listeners();
|
|
80
|
+
var found = (Array.isArray(all) ? all : all ? [all] : []).filter(x => x.ports.includes(Number(port)));
|
|
81
|
+
if (found.length !== 1) throw new Error('无法唯一确认本机服务进程');
|
|
82
|
+
return found[0].process;
|
|
83
|
+
}
|
|
84
|
+
async function released(owners) {
|
|
85
|
+
if (!Array.isArray(owners) || !owners.length) return { released: false, reason: 'missing-owner-evidence' };
|
|
86
|
+
for (var owner of owners) {
|
|
87
|
+
var current = await processInfo(owner.ProcessId);
|
|
88
|
+
if (sameProcess(owner, current)) return { released: false, reason: 'desktop-process-alive', pid: owner.ProcessId };
|
|
89
|
+
}
|
|
90
|
+
var main = owners[0];
|
|
91
|
+
var present = await powershell('Get-CimInstance Win32_Process -Filter ' + literal("Name='" + String(main.Name).replace(/'/g, '') + "'") +
|
|
92
|
+
' | Where-Object { $_.ExecutablePath -eq ' + literal(main.ExecutablePath) +
|
|
93
|
+
' -or $_.ExecutablePath -match "[\\\\/]OpenAI\\.Codex_" } | Select-Object ' + fields() + ' | ConvertTo-Json -Compress');
|
|
94
|
+
if (present && (!Array.isArray(present) || present.length)) return { released: false, reason: 'desktop-starting-or-reopened' };
|
|
95
|
+
return { released: true, reason: 'recorded-desktop-processes-exited' };
|
|
96
|
+
}
|
|
97
|
+
async function pipeOwners(pipePath) {
|
|
98
|
+
if (process.platform !== 'win32' || !/^\\\\\.\\pipe\\/.test(pipePath || '')) throw new Error('需要可识别的 Windows 桌面管道');
|
|
99
|
+
var script = [
|
|
100
|
+
'Add-Type -TypeDefinition ' + literal('using System; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; public static class AnnotatablePipeOwner { [DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetNamedPipeServerProcessId(SafePipeHandle handle, out uint pid); }') + ';',
|
|
101
|
+
'$taskName=[IO.Path]::GetFileName(' + literal(pipePath) + ');',
|
|
102
|
+
'$taskPipe=New-Object System.IO.Pipes.NamedPipeClientStream(".",$taskName,[System.IO.Pipes.PipeDirection]::InOut,[System.IO.Pipes.PipeOptions]::Asynchronous);',
|
|
103
|
+
'try { $taskPipe.Connect(2000); [uint32]$taskServerPid=0; if(-not [AnnotatablePipeOwner]::GetNamedPipeServerProcessId($taskPipe.SafePipeHandle,[ref]$taskServerPid)){ throw "Pipe owner unavailable" } } finally { $taskPipe.Dispose() };',
|
|
104
|
+
'$taskAll=@(Get-CimInstance Win32_Process); $taskServer=$taskAll | Where-Object ProcessId -eq $taskServerPid;',
|
|
105
|
+
'$taskMain=$taskServer; for($taskI=0;$taskI -lt 12;$taskI++){ if($taskMain.Name -match "^(ChatGPT|Codex)\\.exe$" -and $taskMain.CommandLine -notmatch "app-server|exec-server"){ break }; $taskParent=$taskAll | Where-Object ProcessId -eq $taskMain.ParentProcessId; if(-not $taskParent){break}; $taskMain=$taskParent };',
|
|
106
|
+
'if($taskMain.Name -notmatch "^(ChatGPT|Codex)\\.exe$" -or $taskMain.CommandLine -match "app-server|exec-server"){throw "Desktop main process not identified"};',
|
|
107
|
+
'$taskRuntimes=@($taskAll | Where-Object { $_.ParentProcessId -eq $taskMain.ProcessId -and $_.CommandLine -match "\\bapp-server\\b" -and $_.CommandLine -notmatch "managed-daemon" });',
|
|
108
|
+
'if($taskRuntimes.Count -eq 0){throw "Desktop runtime process not identified"};',
|
|
109
|
+
'@{ main=($taskMain | Select-Object ' + fields() + '); runtime=@($taskRuntimes | Select-Object ' + fields() + '); pipeServer=($taskServer | Select-Object ' + fields() + ') } | ConvertTo-Json -Depth 6 -Compress'
|
|
110
|
+
].join(' ');
|
|
111
|
+
var result = await powershell(script);
|
|
112
|
+
result.owners = [result.main].concat(result.runtime);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
function pipeCandidates(initialPipe) {
|
|
116
|
+
if (process.platform !== 'win32') return [initialPipe];
|
|
117
|
+
var directory = '\\\\.\\pipe\\';
|
|
118
|
+
var name = String(initialPipe || '').slice(directory.length);
|
|
119
|
+
// The application prefix is stable; process numbers and restart suffixes are not.
|
|
120
|
+
var prefix = name.replace(/[-_][a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i, '');
|
|
121
|
+
if (prefix === name) prefix = name.replace(/(?:[-_](?:\d+|[a-f0-9]{8,}))+$/i, '');
|
|
122
|
+
if (!prefix || prefix === name) prefix = name.split(/[-_]/).slice(0, 3).join('-');
|
|
123
|
+
var entries;
|
|
124
|
+
try { entries = fs.readdirSync(directory); } catch (_) { return [initialPipe]; }
|
|
125
|
+
return Array.from(new Set([initialPipe].concat(entries.filter(function (entry) {
|
|
126
|
+
return entry.indexOf(prefix) === 0;
|
|
127
|
+
}).map(function (entry) { return directory + entry; }))));
|
|
128
|
+
}
|
|
129
|
+
function windowsArgument(value) {
|
|
130
|
+
value = String(value);
|
|
131
|
+
return '"' + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1') + '"';
|
|
132
|
+
}
|
|
133
|
+
async function spawnIndependent(executable, args, cwd) {
|
|
134
|
+
if (process.platform !== 'win32') throw new Error('独立后台启动尚未在此系统验证');
|
|
135
|
+
var command = [executable].concat(args).map(windowsArgument).join(' ');
|
|
136
|
+
var result = await powershell([
|
|
137
|
+
'$taskEnv=[string[]]@([Environment]::GetEnvironmentVariables("Process").GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" });',
|
|
138
|
+
'$taskStartup=New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ShowWindow=[uint16]0;CreateFlags=[uint32]1032;EnvironmentVariables=$taskEnv};',
|
|
139
|
+
'$taskCreated=Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine=' + literal(command) + ';CurrentDirectory=' + literal(cwd) + ';ProcessStartupInformation=$taskStartup};',
|
|
140
|
+
'$taskCreated | Select-Object ReturnValue,ProcessId | ConvertTo-Json -Compress'
|
|
141
|
+
].join(' '));
|
|
142
|
+
if (!result || result.ReturnValue !== 0 || !result.ProcessId) throw new Error('独立后台程序未启动:' + (result && result.ReturnValue));
|
|
143
|
+
return result.ProcessId;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Shared framing, timeouts and lifecycle; each harness supplies only its wire format.
|
|
147
|
+
class JsonProcess extends EventEmitter {
|
|
148
|
+
constructor(binary, args, options) {
|
|
149
|
+
super(); options = options || {}; this.protocol = options.protocol || {};
|
|
150
|
+
this.pending = new Map(); this.nextId = 0; this.stderr = '';
|
|
151
|
+
this.child = cp.spawn(binary, args, { cwd: options.cwd, env: options.env || process.env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
152
|
+
this.child.stderr.on('data', b => { this.stderr = (this.stderr + b.toString('utf8')).slice(-6000); });
|
|
153
|
+
readline.createInterface({ input: this.child.stdout }).on('line', line => this.receive(line));
|
|
154
|
+
this.child.on('error', error => this.fail(error)); this.child.stdin.on('error', error => this.fail(error));
|
|
155
|
+
this.child.on('close', code => { this.ended = true; this.fail(new Error('宿主进程已退出:' + code)); this.emit('exit', code); });
|
|
156
|
+
}
|
|
157
|
+
fail(error) { this.failure = error; for (var p of this.pending.values()) { clearTimeout(p.timer); p.reject(error); } this.pending.clear(); }
|
|
158
|
+
send(message) { if (this.failure) throw this.failure; this.child.stdin.write(JSON.stringify(message) + '\n'); }
|
|
159
|
+
receive(line) {
|
|
160
|
+
var m; try { m = JSON.parse(line); } catch (_) { return; }
|
|
161
|
+
var response = this.protocol.response ? this.protocol.response(m) : (m.id !== undefined && !m.method ? { id: m.id, result: m.result, error: m.error } : null);
|
|
162
|
+
if (response) {
|
|
163
|
+
var p = this.pending.get(String(response.id)); if (!p) return;
|
|
164
|
+
this.pending.delete(String(response.id)); clearTimeout(p.timer);
|
|
165
|
+
if (response.error) p.reject(new Error('宿主请求失败:' + JSON.stringify(response.error))); else p.resolve(response.result);
|
|
166
|
+
} else if (this.protocol.isRequest ? this.protocol.isRequest(m) : m.id !== undefined) this.emit('serverRequest', m);
|
|
167
|
+
else this.emit('notification', m);
|
|
168
|
+
}
|
|
169
|
+
request(method, params) {
|
|
170
|
+
if (this.failure) return Promise.reject(this.failure);
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
var id = String(++this.nextId), timer = setTimeout(() => { this.pending.delete(id); reject(new Error(method + ' 超时,结果不确定')); }, 30000);
|
|
173
|
+
this.pending.set(id, { resolve: resolve, reject: reject, timer: timer });
|
|
174
|
+
try { this.send(this.protocol.request ? this.protocol.request(id, method, params) : { id: Number(id), method: method, params: params }); }
|
|
175
|
+
catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async close() {
|
|
179
|
+
if (this.ended) return;
|
|
180
|
+
var done = new Promise(resolve => this.child.once('close', resolve));
|
|
181
|
+
this.child.stdin.end();
|
|
182
|
+
var timeout = setTimeout(() => { this.child.kill(); }, 3000); timeout.unref();
|
|
183
|
+
await done; clearTimeout(timeout);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { processInfo: processInfo, sameProcess: sameProcess, released: released,
|
|
188
|
+
pipeOwners: pipeOwners, pipeCandidates: pipeCandidates, spawnIndependent: spawnIndependent,
|
|
189
|
+
identity: identity, ownersExited: ownersExited, argumentsFor: argumentsFor, listeners: listeners, portOwner: portOwner,
|
|
190
|
+
powershell: powershell, literal: literal, JsonProcess: JsonProcess, tailRows: tailRows, fileStamp: fileStamp, configStamps: configStamps, parents: parents, agentEnv: agentEnv };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
require('./duty').runWorker(process.argv[2]).catch(function (error) {
|
|
3
|
+
try { require('fs').appendFileSync(process.argv[2] + '.worker.log', new Date().toISOString() + ' pid=' + process.pid + ' ' + error.stack + '\n'); } catch (_) {}
|
|
4
|
+
console.error(error.message); process.exitCode = 1;
|
|
5
|
+
});
|
package/lib/duty.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Explicit, page-scoped continuation. No global registry or system startup item.
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
var core = require('./core');
|
|
7
|
+
var processes = require('./duty-process');
|
|
8
|
+
function canonicalPath(value) { return path.resolve(value).replace(/\\/g, '/').toLowerCase(); }
|
|
9
|
+
function hostModule(host) {
|
|
10
|
+
if (!['codex', 'claude', 'opencode'].includes(host)) throw new Error('未知续接宿主');
|
|
11
|
+
return require('./' + host);
|
|
12
|
+
}
|
|
13
|
+
function publicHit(hit) { var result = Object.assign({}, hit); delete result.signature; return result; }
|
|
14
|
+
function prompt(binding, hit, options) {
|
|
15
|
+
var cli = JSON.stringify(path.join(__dirname, '../bin/cli.js').replace(/\\/g, '/'));
|
|
16
|
+
var file = JSON.stringify(binding.file.replace(/\\/g, '/'));
|
|
17
|
+
var message = '【指定页面批注自动续接】本轮只处理用户指定页面,沿用原会话。\nPOLL_HIT ' + JSON.stringify(publicHit(hit)) + '\n' +
|
|
18
|
+
'用内置本地命令工具运行 node ' + cli + ' read ' + file + ',读取完整待回应批注。' +
|
|
19
|
+
'用文件写入工具将完整回复保存为当前目录 UTF-8 JSON 数组文件,格式 [{"id":"批注id","reply":"完整回复"}];' +
|
|
20
|
+
'再运行 node ' + cli + ' reply ' + file + ' 回复文件路径。核对实际落盘后结束本轮。' +
|
|
21
|
+
'长文不要嵌进 shell 参数,不要手改 HTML 数据。明确要求改正文时使用同一 CLI 的 edit 命令。' +
|
|
22
|
+
'不改设置、不换模型、不扩大权限、不处理其他页面或聊天、不运行 Git。后台程序负责持续监听,不要重挂 poll 或启动第二个控制器。';
|
|
23
|
+
if (binding.host === 'claude' && binding.bridge && binding.bridge.nativeHook && !(options && options.headless)) {
|
|
24
|
+
message += '原生钩子会自动接回后续通知。答完结束本轮,不运行 duty、--listen 或安装设置。';
|
|
25
|
+
} else if (binding.host === 'claude' && !(options && options.headless)) {
|
|
26
|
+
message += '本通知进程以退出码 1 结束是正常命中。答完用 Bash 的 run_in_background=true 重新执行 node ' + cli + ' duty ' + file +
|
|
27
|
+
' --claude --listen --claude-session ' + binding.threadId + ',接回当前原会话的通知。若返回 DUTY_STOPPED,表示用户已停止,结束监听,不重新注册。';
|
|
28
|
+
}
|
|
29
|
+
return message;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function alive(pid) { if (!Number.isInteger(pid) || pid < 1) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code !== 'ESRCH'; } }
|
|
33
|
+
function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
|
|
34
|
+
function atomicJson(file, value) {
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
var temp = file + '.tmp-' + process.pid;
|
|
37
|
+
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n');
|
|
38
|
+
fs.renameSync(temp, file);
|
|
39
|
+
}
|
|
40
|
+
function pathsFor(file) {
|
|
41
|
+
var base = path.join(path.dirname(file), '.annotatable', path.basename(file));
|
|
42
|
+
var legacy = base + '.codex-duty'; base += '.duty';
|
|
43
|
+
if (!fs.existsSync(base + '.json') && fs.existsSync(legacy + '.json')) base = legacy;
|
|
44
|
+
return { binding: base + '.json', state: base + '.state.json' };
|
|
45
|
+
}
|
|
46
|
+
function docId(file) { var id = core.docIdOf(fs.readFileSync(file, 'utf8')); if (!id) throw new Error('页面缺少文档标识'); return id; }
|
|
47
|
+
function mutateBinding(file, change) {
|
|
48
|
+
return core.withDocLock(file, function () {
|
|
49
|
+
var target = pathsFor(file).binding, current = readJson(target), next = change(current);
|
|
50
|
+
if (next) atomicJson(target, next);
|
|
51
|
+
return next || current;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function pending(file) {
|
|
55
|
+
var store = core.readStore(fs.readFileSync(file, 'utf8'));
|
|
56
|
+
if (!store) throw new Error('NO_STORE');
|
|
57
|
+
var annotations = store.annotations.filter(a => a.status !== 'answered' && core.hasUserText(a));
|
|
58
|
+
var replay = store.control && store.control.replay;
|
|
59
|
+
var signature = JSON.stringify({ questions: annotations.map(a => ({ id: a.id, user: (a.thread || []).filter(t => t.role === 'user').map(t => t.text), comment: a.comment })), replay: replay || null });
|
|
60
|
+
return { file: file, rev: store.rev, pending: annotations.length, ids: annotations.map(a => a.id), created: annotations.map(a => a.created || 0), replay: replay || null, signature: signature };
|
|
61
|
+
}
|
|
62
|
+
function completed(file, ids) {
|
|
63
|
+
var store = core.readStore(fs.readFileSync(file, 'utf8'));
|
|
64
|
+
return !!store && ids.every(id => {
|
|
65
|
+
var a = store.annotations.find(a => a.id === id);
|
|
66
|
+
if (!a) return true; // A human may delete a question while it is being handled.
|
|
67
|
+
var last = (a.thread || []).slice(-1)[0];
|
|
68
|
+
return a.status === 'answered' && last && last.role === 'ai' && !last.streaming && !!String(last.text || '').trim();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function acquire(file, metadata) {
|
|
72
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
+
for (var i = 0; i < 2; i++) {
|
|
74
|
+
try {
|
|
75
|
+
var fd = fs.openSync(file, 'wx');
|
|
76
|
+
fs.writeFileSync(fd, JSON.stringify(Object.assign({}, metadata, { pid: process.pid }))); fs.closeSync(fd);
|
|
77
|
+
return function () { var owner = readJson(file); if (owner && owner.pid === process.pid) fs.unlinkSync(file); };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code !== 'EEXIST') throw error;
|
|
80
|
+
var owner = readJson(file);
|
|
81
|
+
if (!owner || !Number.isInteger(owner.pid) || alive(owner.pid)) throw new Error('此会话已有续接进程或锁状态不明');
|
|
82
|
+
fs.unlinkSync(file);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new Error('无法获得会话处理权');
|
|
86
|
+
}
|
|
87
|
+
async function register(file, options) {
|
|
88
|
+
options = options || {};
|
|
89
|
+
if (process.platform !== 'win32') throw new Error('自动续接目前只在 Windows 验证;其他系统请继续使用 poll');
|
|
90
|
+
if (typeof WebSocket !== 'function' || typeof fetch !== 'function') throw new Error('自动续接需要 Node.js 22+');
|
|
91
|
+
if (options.pageContext && options.pageContext.protocol !== 'http:') throw new Error('自动续接目前只支持本机 HTTP 页面;HTTPS 请用本地文件路径注册');
|
|
92
|
+
var port = options.port === undefined ? 0 : Number(options.port);
|
|
93
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('--port 必须是 0 到 65535 的整数');
|
|
94
|
+
file = fs.realpathSync(path.resolve(file));
|
|
95
|
+
var files = pathsFor(file), old = readJson(files.binding), oldState = readJson(files.state);
|
|
96
|
+
if (oldState && oldState.headless && alive(oldState.headless.pid)) throw new Error('无头任务仍在运行;完成后才能交还原宿主');
|
|
97
|
+
var host = options.host || 'codex', details = await hostModule(host).dutyBinding(file, options);
|
|
98
|
+
var threadId = details.threadId;
|
|
99
|
+
if (old && (old.threadId !== threadId || (old.host || 'codex') !== host) && oldState && alive(oldState.pid)) throw new Error('该页面已绑定另一条运行中的聊天');
|
|
100
|
+
var serverRoot = fs.realpathSync(options.pageContext ? options.pageContext.root : (old && old.serverRoot || path.dirname(file)));
|
|
101
|
+
var pagePath = options.pageContext ? options.pageContext.pagePath : (old && old.pagePath || '/' + path.basename(file));
|
|
102
|
+
var relative = path.relative(serverRoot, file);
|
|
103
|
+
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) ||
|
|
104
|
+
!pagePath.startsWith('/') || canonicalPath(path.resolve(serverRoot, '.' + pagePath)) !== canonicalPath(file)) throw new Error('页面地址与服务目录不一致');
|
|
105
|
+
if (oldState && alive(oldState.pid) && ((port && port !== oldState.serverPort) ||
|
|
106
|
+
serverRoot !== (old.serverRoot || path.dirname(file)) || pagePath !== (old.pagePath || '/' + path.basename(file)))) throw new Error('请先停止该页续接,再更改服务地址或端口');
|
|
107
|
+
var binding = Object.assign({}, details, { host: host, file: file, docId: docId(file), port: port, serverRoot: serverRoot, pagePath: pagePath,
|
|
108
|
+
generation: (old && old.generation || 0) + 1, stopRequested: false,
|
|
109
|
+
lockFile: old && (old.host || 'codex') === host && old.threadId === threadId ? old.lockFile :
|
|
110
|
+
path.join(details.settings.cwd, '.annotatable', 'duty-' + host + '-' + threadId + '.lock'), registeredAt: Date.now() });
|
|
111
|
+
var locked = readJson(binding.lockFile);
|
|
112
|
+
if (locked && alive(locked.pid) && (!oldState || oldState.pid !== locked.pid)) throw new Error('此聊天已有其他页面的续接控制器;不会并行启动');
|
|
113
|
+
var bridgeOwner = details.claude && details.claude.owners[0];
|
|
114
|
+
var alreadyListening = !!(old && old.bridge && alive(old.bridge.ProcessId) && (!bridgeOwner || processes.sameProcess(old.bridge.owner, bridgeOwner)));
|
|
115
|
+
if (options.events) binding.bridge = alreadyListening ? old.bridge : Object.assign(processes.identity(await processes.processInfo(process.pid)),
|
|
116
|
+
{ owner: bridgeOwner, deliveredSequence: old && old.bridge && processes.sameProcess(old.bridge.owner, bridgeOwner) ? old.bridge.deliveredSequence || 0 : 0 });
|
|
117
|
+
mutateBinding(file, function (latest) {
|
|
118
|
+
if ((latest && latest.generation) !== (old && old.generation)) throw new Error('续接绑定已被其他操作更改,请重新检查状态');
|
|
119
|
+
if (host === 'claude') hostModule(host).installResumeHooks(binding);
|
|
120
|
+
if (locked && oldState && locked.pid === oldState.pid && alive(locked.pid)) atomicJson(binding.lockFile, Object.assign({}, locked, { bindingFile: files.binding }));
|
|
121
|
+
return binding;
|
|
122
|
+
});
|
|
123
|
+
if (oldState && oldState.phase !== 'stopped' && alive(oldState.pid)) return { attached: true, pid: oldState.pid, binding: files.binding, pageUrl: oldState.pageUrl, alreadyListening: alreadyListening };
|
|
124
|
+
var pid = await processes.spawnIndependent(process.execPath, [path.join(__dirname, 'duty-worker.js'), files.binding], details.settings.cwd);
|
|
125
|
+
var deadline = Date.now() + 10000;
|
|
126
|
+
for (;;) {
|
|
127
|
+
var started = readJson(files.state);
|
|
128
|
+
if (started && started.pid === pid && started.pageUrl) break;
|
|
129
|
+
if (!alive(pid) || Date.now() > deadline) throw new Error('后台续接程序未确认启动;请查看 --status');
|
|
130
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
131
|
+
}
|
|
132
|
+
return { attached: false, pid: pid, binding: files.binding, pageUrl: started.pageUrl, alreadyListening: alreadyListening };
|
|
133
|
+
}
|
|
134
|
+
function status(file) {
|
|
135
|
+
var files = pathsFor(path.resolve(file));
|
|
136
|
+
var binding = readJson(files.binding), state = readJson(files.state);
|
|
137
|
+
return { registered: !!binding, running: !!(state && alive(state.pid)), threadId: binding && binding.threadId, state: state };
|
|
138
|
+
}
|
|
139
|
+
function stop(file) {
|
|
140
|
+
var binding = mutateBinding(path.resolve(file), function (current) {
|
|
141
|
+
if (!current) return;
|
|
142
|
+
current.stopRequested = true; current.generation++; return current;
|
|
143
|
+
});
|
|
144
|
+
return binding ? { stopRequested: true, waitsForActiveRun: true } : { registered: false };
|
|
145
|
+
}
|
|
146
|
+
async function listen(file, options) {
|
|
147
|
+
file = fs.realpathSync(path.resolve(file)); options = options || {};
|
|
148
|
+
var files = pathsFor(file), initial = readJson(files.binding);
|
|
149
|
+
if (!initial || initial.stopRequested) return { stopped: true };
|
|
150
|
+
if (initial.host !== 'claude') throw new Error('--listen 只用于 Claude 原会话的通知任务');
|
|
151
|
+
var details = await hostModule('claude').dutyBinding(file, options);
|
|
152
|
+
if (details.threadId !== initial.threadId) throw new Error('通知任务与已注册的原会话不一致');
|
|
153
|
+
var bridge = Object.assign(processes.identity(await processes.processInfo(process.pid)), { owner: details.claude.owners[0], nativeHook: !!options.nativeHook });
|
|
154
|
+
var oldBridge = initial.bridge && await processes.processInfo(initial.bridge.ProcessId);
|
|
155
|
+
var stopped = false, alreadyListening = false;
|
|
156
|
+
var binding = mutateBinding(file, function (current) {
|
|
157
|
+
if (!current || current.stopRequested) { stopped = true; return; }
|
|
158
|
+
if (current.threadId !== details.threadId || current.host !== 'claude') throw new Error('续接绑定已改变');
|
|
159
|
+
var state = readJson(files.state);
|
|
160
|
+
if (!state || !alive(state.pid)) throw new Error('续接控制器已停止;请检查状态后显式注册');
|
|
161
|
+
if (current.generation !== initial.generation && current.bridge && alive(current.bridge.ProcessId) && processes.sameProcess(current.bridge.owner, bridge.owner)) { alreadyListening = true; return; }
|
|
162
|
+
alreadyListening = !!(current.bridge && processes.sameProcess(current.bridge, oldBridge) && processes.sameProcess(current.bridge.owner, bridge.owner));
|
|
163
|
+
if (alreadyListening) return;
|
|
164
|
+
bridge.deliveredSequence = current.bridge && processes.sameProcess(current.bridge.owner, bridge.owner) ? current.bridge.deliveredSequence || 0 : 0;
|
|
165
|
+
current.bridge = bridge; current.generation++; return current;
|
|
166
|
+
});
|
|
167
|
+
return { stopped: stopped, alreadyListening: alreadyListening, threadId: binding && binding.threadId };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class DutyController {
|
|
171
|
+
constructor(binding, adapters) {
|
|
172
|
+
this.binding = binding; this.adapters = adapters; this.state = adapters.previous || {};
|
|
173
|
+
this.state.host = binding.host || 'codex'; this.state.pid = process.pid; this.state.threadId = binding.threadId; this.state.file = binding.file;
|
|
174
|
+
this.state.phase = 'starting'; this.state.runs = this.state.runs || [];
|
|
175
|
+
this.registration(binding);
|
|
176
|
+
this.generation = binding.generation; this.offlineObservations = 0;
|
|
177
|
+
}
|
|
178
|
+
persist() { this.state.updatedAt = Date.now(); this.adapters.save(this.state); }
|
|
179
|
+
registration(binding) {
|
|
180
|
+
if (binding.registeredAt !== this.state.registeredAt) {
|
|
181
|
+
this.state.blockedSignature = null; this.state.notifiedSignature = null; delete this.state.error;
|
|
182
|
+
this.state.registeredAt = binding.registeredAt;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async tick() {
|
|
186
|
+
if (this.busy) return;
|
|
187
|
+
this.busy = true;
|
|
188
|
+
try {
|
|
189
|
+
var binding = this.adapters.load();
|
|
190
|
+
if (!binding || binding.threadId !== this.binding.threadId || binding.file !== this.binding.file || binding.docId !== this.binding.docId) throw new Error('续接绑定发生变化,停止自动处理');
|
|
191
|
+
this.binding = binding;
|
|
192
|
+
this.registration(binding); this.generation = binding.generation;
|
|
193
|
+
if (this.state.headless && alive(this.state.headless.pid)) { this.state.phase = 'waiting-for-existing-headless'; this.persist(); return; }
|
|
194
|
+
if (binding.stopRequested) { this.state.phase = 'stopped'; this.state.owner = 'none'; this.persist(); return; }
|
|
195
|
+
if (docId(binding.file) !== binding.docId) throw new Error('文档标识已改变,保留批注并停止处理');
|
|
196
|
+
var hit = this.adapters.pending();
|
|
197
|
+
var desktop = await this.adapters.online();
|
|
198
|
+
if (this.adapters.load().stopRequested) { this.state.phase = 'stopped'; this.state.owner = 'none'; this.persist(); return; }
|
|
199
|
+
if (desktop && desktop.available) {
|
|
200
|
+
this.offlineObservations = 0; this.state.phase = 'desktop'; this.state.owner = 'desktop';
|
|
201
|
+
if (hit.pending || hit.replay) {
|
|
202
|
+
if (desktop.active) this.state.phase = 'waiting-for-desktop-turn';
|
|
203
|
+
else if (hit.signature !== this.state.notifiedSignature && hit.signature !== this.state.blockedSignature) {
|
|
204
|
+
// Mark before sending: uncertain delivery must not cause duplicates.
|
|
205
|
+
this.state.notifiedSignature = hit.signature;
|
|
206
|
+
this.state.notification = { sequence: (this.state.notification && this.state.notification.sequence || 0) + 1, signature: hit.signature, hit: publicHit(hit) }; this.persist();
|
|
207
|
+
await desktop.notify(publicHit(hit));
|
|
208
|
+
this.state.lastNotificationAt = Date.now();
|
|
209
|
+
}
|
|
210
|
+
} else this.state.notifiedSignature = null;
|
|
211
|
+
this.persist(); return;
|
|
212
|
+
}
|
|
213
|
+
var release = await this.adapters.released();
|
|
214
|
+
if (!release.released) { this.state.phase = 'waiting-for-desktop-release'; this.state.reason = release.reason; this.persist(); return; }
|
|
215
|
+
this.offlineObservations++;
|
|
216
|
+
this.state.owner = 'none'; this.state.phase = 'desktop-offline';
|
|
217
|
+
if (hit.signature === this.state.blockedSignature) { this.state.phase = 'needs-attention'; this.persist(); return; }
|
|
218
|
+
if (!(hit.pending || hit.replay) || this.offlineObservations < 2) { this.persist(); return; }
|
|
219
|
+
// A second probe immediately precedes taking ownership.
|
|
220
|
+
var returned = await this.adapters.online();
|
|
221
|
+
if (returned && returned.available) { this.offlineObservations = 0; this.persist(); return; }
|
|
222
|
+
if (!(await this.adapters.released()).released) { this.persist(); return; }
|
|
223
|
+
if (this.adapters.load().stopRequested) { this.state.phase = 'stopped'; this.state.owner = 'none'; this.persist(); return; }
|
|
224
|
+
hit = this.adapters.pending();
|
|
225
|
+
if (!(hit.pending || hit.replay)) { this.persist(); return; }
|
|
226
|
+
if (hit.signature === this.state.blockedSignature) { this.state.phase = 'needs-attention'; this.persist(); return; }
|
|
227
|
+
this.state.owner = 'headless'; this.state.phase = 'headless'; this.state.notifiedSignature = null; this.persist();
|
|
228
|
+
try {
|
|
229
|
+
var run = await this.adapters.headless(hit, info => { this.state.headless = info; this.persist(); });
|
|
230
|
+
this.state.runs.push(run);
|
|
231
|
+
if (!this.adapters.completed(hit.ids) || (hit.replay && this.adapters.pending().replay)) throw new Error('无头轮次结束,但页面仍无完整落盘回复或正文重做仍未处理');
|
|
232
|
+
this.state.phase = 'desktop-offline'; this.state.lastReplyAt = Date.now(); this.state.blockedSignature = null; delete this.state.error;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error.dutyRun) this.state.runs.push(error.dutyRun);
|
|
235
|
+
this.state.error = error.message; this.state.phase = 'needs-attention'; this.state.blockedSignature = hit.signature;
|
|
236
|
+
} finally { this.state.headless = null; this.state.owner = 'none'; this.persist(); }
|
|
237
|
+
} finally { this.busy = false; }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
class PageService {
|
|
242
|
+
constructor(binding, previous) {
|
|
243
|
+
this.binding = binding; this.root = binding.serverRoot || path.dirname(binding.file);
|
|
244
|
+
this.pagePath = binding.pagePath || '/' + path.basename(binding.file);
|
|
245
|
+
this.port = binding.port || (previous && previous.serverPort) || 0;
|
|
246
|
+
}
|
|
247
|
+
get url() { return 'http://localhost:' + this.port + this.pagePath.split('/').map(encodeURIComponent).join('/'); }
|
|
248
|
+
async ensure() {
|
|
249
|
+
if (this.owned) return;
|
|
250
|
+
if (this.port) {
|
|
251
|
+
try {
|
|
252
|
+
var response = await fetch('http://127.0.0.1:' + this.port + '/__annot/context?path=' + encodeURIComponent(this.pagePath), { signal: AbortSignal.timeout(1500) });
|
|
253
|
+
if (response.ok) {
|
|
254
|
+
var context = await response.json();
|
|
255
|
+
if (canonicalPath(context.file) !== canonicalPath(this.binding.file) || context.docId !== this.binding.docId ||
|
|
256
|
+
canonicalPath(context.root) !== canonicalPath(this.root)) throw new Error('指定端口属于其他文档或服务目录,停止续接');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
throw new Error('指定端口不是此页面的批注服务');
|
|
260
|
+
} catch (error) { if (/指定端口/.test(error.message)) throw error; }
|
|
261
|
+
}
|
|
262
|
+
this.owned = await require('./serve').start(this.root, this.port, { managed: true });
|
|
263
|
+
this.port = this.owned.port;
|
|
264
|
+
}
|
|
265
|
+
async close() { if (this.owned) { await this.owned.close(); this.owned = null; } }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function events(file, options) {
|
|
269
|
+
options = options || {};
|
|
270
|
+
var files = pathsFor(path.resolve(file)), last = 0;
|
|
271
|
+
var initial = readJson(files.binding), owner = initial && initial.bridge && initial.bridge.owner;
|
|
272
|
+
for (;;) {
|
|
273
|
+
var binding = readJson(files.binding), state = readJson(files.state);
|
|
274
|
+
if (binding && binding.stopRequested) { if (!options.hook) console.error('DUTY_STOPPED 用户已停止,不自动重新启动'); return; }
|
|
275
|
+
if (!binding || !binding.bridge || binding.bridge.ProcessId !== process.pid) return;
|
|
276
|
+
if (owner && !alive(owner.ProcessId)) return;
|
|
277
|
+
if (!state || !alive(state.pid)) throw new Error('续接控制器已停止');
|
|
278
|
+
var notification = state.notification;
|
|
279
|
+
if (notification && notification.sequence !== last) {
|
|
280
|
+
last = notification.sequence;
|
|
281
|
+
var hit = pending(binding.file);
|
|
282
|
+
if (notification.sequence > (binding.bridge.deliveredSequence || 0) && (hit.pending || hit.replay) && hit.signature === notification.signature) {
|
|
283
|
+
var claimed;
|
|
284
|
+
mutateBinding(binding.file, function (current) {
|
|
285
|
+
if (!current || current.stopRequested || !current.bridge || current.bridge.ProcessId !== process.pid ||
|
|
286
|
+
(current.bridge.deliveredSequence || 0) >= notification.sequence || pending(current.file).signature !== notification.signature) return;
|
|
287
|
+
current.bridge.deliveredSequence = notification.sequence; current.generation++; claimed = current; return current;
|
|
288
|
+
});
|
|
289
|
+
if (!claimed) continue;
|
|
290
|
+
console.log(prompt(claimed, hit).replace(/\n/g, ' '));
|
|
291
|
+
process.exitCode = options.exitCode || 1; return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function runWorker(configFile) {
|
|
298
|
+
var binding = readJson(configFile);
|
|
299
|
+
if (!binding) throw new Error('缺少续接绑定');
|
|
300
|
+
var files = pathsFor(binding.file), releaseLock = acquire(binding.lockFile, { bindingFile: configFile });
|
|
301
|
+
var previous = readJson(files.state), pageServer = new PageService(binding, previous);
|
|
302
|
+
function updateBinding(next) {
|
|
303
|
+
var updated = false;
|
|
304
|
+
binding = mutateBinding(binding.file, function (latest) {
|
|
305
|
+
if (!latest || latest.generation !== next.generation) return;
|
|
306
|
+
updated = true; return next;
|
|
307
|
+
});
|
|
308
|
+
return updated;
|
|
309
|
+
}
|
|
310
|
+
var host = hostModule(binding.host || 'codex').createDutyAdapter(() => binding, updateBinding);
|
|
311
|
+
var onSignal = () => stop(binding.file);
|
|
312
|
+
process.on('SIGTERM', onSignal); process.on('SIGINT', onSignal);
|
|
313
|
+
var controller = new DutyController(binding, { previous: previous, load: () => (binding = readJson(configFile)),
|
|
314
|
+
pending: () => pending(binding.file), completed: ids => completed(binding.file, ids),
|
|
315
|
+
online: () => host.online(), released: () => host.released(),
|
|
316
|
+
headless: (hit, onStart) => host.run(hit, onStart), save: state => { atomicJson(files.state, state); } });
|
|
317
|
+
try {
|
|
318
|
+
await pageServer.ensure();
|
|
319
|
+
controller.state.serverPort = pageServer.port; controller.state.pageUrl = pageServer.url; controller.persist();
|
|
320
|
+
while (controller.state.phase !== 'stopped') {
|
|
321
|
+
try { await pageServer.ensure(); await controller.tick(); }
|
|
322
|
+
catch (error) { controller.state.phase = 'needs-attention'; controller.state.error = error.message; controller.persist(); }
|
|
323
|
+
core.writeWatchBeat(binding.file, ['needs-attention', 'stopped'].includes(controller.state.phase) ? 'handoff' : 'watching', controller.state.startedAt || (controller.state.startedAt = Date.now()));
|
|
324
|
+
if (controller.state.phase !== 'stopped') await new Promise(resolve => setTimeout(resolve, 2000));
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
try { host.close(); } finally { releaseLock(); await pageServer.close(); core.writeWatchBeat(binding.file, 'handoff'); }
|
|
328
|
+
process.removeListener('SIGTERM', onSignal); process.removeListener('SIGINT', onSignal);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = { register: register, status: status, stop: stop, listen: listen, runWorker: runWorker,
|
|
333
|
+
DutyController: DutyController, pending: pending, completed: completed, pathsFor: pathsFor,
|
|
334
|
+
acquire: acquire, atomicJson: atomicJson, alive: alive, PageService: PageService, events: events, prompt: prompt, publicHit: publicHit, canonicalPath: canonicalPath };
|