@rockysoft/opencode-hybrid-workflow 0.9.0 → 0.9.1
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/cli.mjs +91 -91
- package/core/controller.mjs +471 -471
- package/core/plugin.mjs +93 -93
- package/core/telemetry.mjs +111 -111
- package/core/workers.mjs +202 -202
- package/docs/configuration.md +6 -0
- package/lib/settings-service.mjs +137 -137
- package/package.json +76 -76
package/core/workers.mjs
CHANGED
|
@@ -1,202 +1,202 @@
|
|
|
1
|
-
import {documentTool} from '../lib/public-document.mjs';
|
|
2
|
-
import {reviewer} from '../lib/reviewers.mjs';
|
|
3
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { Telemetry, jsonLineReader } from './telemetry.mjs';
|
|
7
|
-
import { findingSchema, isMajor } from './decision.mjs';
|
|
8
|
-
import {processIdentity} from './process-identity.mjs';
|
|
9
|
-
import {commandSpec} from '../lib/commands.mjs';
|
|
10
|
-
import {commandEnvironment} from '../lib/platform.mjs';
|
|
11
|
-
import {prepareBrowser,browserTools,browserInstructions} from '../lib/reviewer-browser.mjs';
|
|
12
|
-
import {evidenceServer,queryInstructions,queryTool} from '../lib/evidence-mcp.mjs';
|
|
13
|
-
|
|
14
|
-
export const resultSchema = {
|
|
15
|
-
type: 'object', additionalProperties: false,
|
|
16
|
-
properties: {
|
|
17
|
-
verdict: { type: 'string', enum: ['pass', 'revise', 'blocked'] },
|
|
18
|
-
report: { type: 'string' },
|
|
19
|
-
findings: { type: 'array', items: findingSchema },
|
|
20
|
-
verifiedResolutions: {type:'array',items:{type:'object',additionalProperties:false,properties:{id:{type:'string'},evidence:{type:'string'}},required:['id','evidence']}},
|
|
21
|
-
}, required: ['verdict', 'report', 'findings', 'verifiedResolutions'],
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export function workerArgs(who, config, folder, images = [], browser = null, evidence = null) {
|
|
25
|
-
const lane=reviewer(config,who);
|
|
26
|
-
// Attachment count is a transport choice, never an evidence quota. Larger
|
|
27
|
-
// sets stay in the frozen images/ manifest and are opened with view_image.
|
|
28
|
-
const inlineImages = images.length <= 4 && images.join('').length < 2048 ? images : [];
|
|
29
|
-
if (lane.adapter === 'codex-cli') return ['exec', '--json', '--ignore-user-config', '--ignore-rules', '--ephemeral', '--skip-git-repo-check',
|
|
30
|
-
'--sandbox', 'read-only', '-C', folder, '-m', lane.model,
|
|
31
|
-
// User config is intentionally isolated, so Windows' required sandbox
|
|
32
|
-
// implementation must be explicit too. Without it shell reads are denied.
|
|
33
|
-
...(process.platform === 'win32' ? ['-c', 'windows.sandbox="elevated"'] : []),
|
|
34
|
-
'-c', 'approval_policy="never"',
|
|
35
|
-
...(lane.effort==='default'?[]:['-c', `model_reasoning_effort="${lane.effort??'high'}"`]), '-c', 'web_search="disabled"',
|
|
36
|
-
...(browser ? ['-c',`mcp_servers.review_browser.command=${JSON.stringify(browser.command)}`,
|
|
37
|
-
'-c',`mcp_servers.review_browser.args=${JSON.stringify(browser.args)}`,
|
|
38
|
-
'-c','mcp_servers.review_browser.startup_timeout_sec=60',
|
|
39
|
-
'-c','mcp_servers.review_browser.tool_timeout_sec=120',
|
|
40
|
-
'-c','mcp_servers.review_browser.default_tools_approval_mode="approve"',
|
|
41
|
-
'-c',`mcp_servers.review_browser.enabled_tools=${JSON.stringify(browserTools)}`] : []),
|
|
42
|
-
...(evidence?['-c',`mcp_servers.review_evidence.command=${JSON.stringify(evidence.command)}`,'-c',`mcp_servers.review_evidence.args=${JSON.stringify(evidence.args)}`,
|
|
43
|
-
'-c','mcp_servers.review_evidence.default_tools_approval_mode="approve"','-c',`mcp_servers.review_evidence.enabled_tools=${JSON.stringify([queryTool,documentTool])}`]:[]),
|
|
44
|
-
...inlineImages.flatMap(image=>['--image',image]),
|
|
45
|
-
'--output-schema', path.join(folder, 'schema.json'), '-o', path.join(folder, 'result.json'), '-'];
|
|
46
|
-
return ['-p', ...(browser||evidence?['--setting-sources','','--disable-slash-commands','--settings','{"disableAllHooks":true,"enabledPlugins":{}}']:['--safe-mode']), '--restricted', '--strict-mcp-config', '--mcp-config', JSON.stringify({mcpServers:{...(browser?{review_browser:browser}:{}),...(evidence?{review_evidence:evidence}:{})}}),
|
|
47
|
-
'--no-chrome', '--no-session-persistence', '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
|
|
48
|
-
'--tools', 'Read,Glob,Grep', '--allowedTools', ['Read','Glob','Grep',...(browser?browserTools.map(name=>'mcp__review_browser__'+name):[]),...(evidence?[queryTool,documentTool].map(name=>'mcp__review_evidence__'+name):[])].join(','),
|
|
49
|
-
'--model', lane.model, ...(lane.effort==='default'?[]:['--effort',lane.effort??'high']), '--output-format', 'stream-json', '--verbose',
|
|
50
|
-
'--json-schema', JSON.stringify(resultSchema)];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function validateResult(value) {
|
|
54
|
-
if (!value || !['pass', 'revise', 'blocked'].includes(value.verdict) || typeof value.report !== 'string' || !value.report.trim() ||
|
|
55
|
-
!Array.isArray(value.findings)) throw new Error('Invalid reviewer result; checkpoint stays closed.');
|
|
56
|
-
if (Object.keys(value).some(key => !['verdict', 'report', 'findings','verifiedResolutions'].includes(key))) throw new Error('Invalid reviewer result fields.');
|
|
57
|
-
// Existing receipts remain readable. Omission cannot resolve tracked findings.
|
|
58
|
-
if(value.verifiedResolutions!==undefined&&(!Array.isArray(value.verifiedResolutions)||value.verifiedResolutions.some(r=>!r||Object.keys(r).some(k=>!['id','evidence'].includes(k))||typeof r.id!=='string'||!r.id.trim()||typeof r.evidence!=='string'||!r.evidence.trim())||new Set(value.verifiedResolutions.map(r=>r.id)).size!==value.verifiedResolutions.length))throw Error('Invalid verified resolutions.');
|
|
59
|
-
const ids = new Set();
|
|
60
|
-
for (const f of value.findings) {
|
|
61
|
-
if (!f || typeof f !== 'object' || Array.isArray(f) ||
|
|
62
|
-
Object.keys(f).some(key => !findingSchema.required.includes(key)) ||
|
|
63
|
-
findingSchema.required.some(key => typeof f[key] !== 'string' || !f[key].trim()) ||
|
|
64
|
-
!findingSchema.properties.severity.enum.includes(f.severity) ||
|
|
65
|
-
!findingSchema.properties.category.enum.includes(f.category) || ids.has(f.id) ||
|
|
66
|
-
(f.category === 'preference' && isMajor(f))) throw new Error('Invalid structured finding; checkpoint stays closed.');
|
|
67
|
-
ids.add(f.id);
|
|
68
|
-
}
|
|
69
|
-
if ((value.verdict === 'pass' && value.findings.some(isMajor)) ||
|
|
70
|
-
(value.verdict === 'revise' && !value.findings.length) ||
|
|
71
|
-
(value.verdict === 'blocked' && !value.findings.some(isMajor))) throw new Error('Inconsistent reviewer verdict; checkpoint stays closed.');
|
|
72
|
-
return value;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function workerEnvironment(source = process.env) {
|
|
76
|
-
const env = {};
|
|
77
|
-
const allowed = /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC|TEMP|TMP|TMPDIR|HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_STATE_HOME|XDG_CACHE_HOME|USERPROFILE|APPDATA|LOCALAPPDATA|HOMEDRIVE|HOMEPATH|USERNAME|USERDOMAIN|PROGRAMFILES|PROGRAMFILES\(X86\)|PROGRAMW6432|OS|SYSTEMDRIVE|LANG|LC_ALL)$/i;
|
|
78
|
-
for (const [key, value] of Object.entries(source)) if (allowed.test(key)) env[key] = value;
|
|
79
|
-
// Official CLIs retain their normal per-user
|
|
80
|
-
return commandEnvironment(env);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function
|
|
84
|
-
const lane=reviewer(config,who);
|
|
85
|
-
if(lane.adapter==='opencode')return;
|
|
86
|
-
const executable=lane.executable;
|
|
87
|
-
const args = lane.adapter === 'codex-cli' ? ['login', 'status'] : ['auth', 'status'];
|
|
88
|
-
const spec=invoke===spawnSync?commandSpec(executable):{executable,args:[]};
|
|
89
|
-
const result = invoke(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, encoding: 'utf8', timeout: 15000 });
|
|
90
|
-
let valid = false;
|
|
91
|
-
if (result.status === 0 && lane.adapter === 'codex-cli') valid =
|
|
92
|
-
if (result.status === 0 && lane.adapter === 'claude-cli') {
|
|
93
|
-
try { const auth = JSON.parse(result.stdout); valid = auth.loggedIn === true &&
|
|
94
|
-
}
|
|
95
|
-
if (!valid) throw new Error(`${who}:
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export async function runProcess(executable, args, folder, input, timeoutMs, signal, observer = {}) {
|
|
99
|
-
if (signal?.aborted) throw new Error('Cancelled before launch.');
|
|
100
|
-
return await new Promise((resolve, reject) => {
|
|
101
|
-
const spec=commandSpec(executable);
|
|
102
|
-
const child = spawn(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, detached:process.platform!=='win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
103
|
-
child.once('spawn', () => observer.onStart?.(child.pid));
|
|
104
|
-
let output = '', failure, size = 0, reapTimer, settled=false;
|
|
105
|
-
const stop = reason => {
|
|
106
|
-
if(failure)return;
|
|
107
|
-
failure ??= new Error(reason);
|
|
108
|
-
if (process.platform === 'win32' && child.pid && child.exitCode === null) {
|
|
109
|
-
spawnSync(path.join(process.env.SystemRoot ?? 'C:/Windows', 'System32/taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 });
|
|
110
|
-
} else if(child.pid) { try {process.kill(-child.pid,'SIGTERM');} catch {child.kill();} }
|
|
111
|
-
// A failed OS termination must not hang the tool forever. Retain an explicit
|
|
112
|
-
// live-worker guard so returning an error never unlocks concurrent writers.
|
|
113
|
-
reapTimer=setTimeout(()=>{
|
|
114
|
-
if(settled)return;settled=true;clearTimeout(timer);signal?.removeEventListener('abort',abort);
|
|
115
|
-
failure.unreapedPid=child.pid;failure.workerIdentity=processIdentity(child.pid);
|
|
116
|
-
// An exited root does not prove its descendants exited: inherited pipes
|
|
117
|
-
// can keep this call open. Never clear this uncertainty by PID lookup.
|
|
118
|
-
failure.descendantsUnconfirmed=true;
|
|
119
|
-
child.stdout.destroy();child.stderr.destroy();child.stdin.destroy();child.unref();reject(failure);
|
|
120
|
-
},5000);
|
|
121
|
-
};
|
|
122
|
-
const abort = () => stop('Review cancelled; checkpoint remains closed.');
|
|
123
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
124
|
-
const timer = Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(() => stop('Process timed out; checkpoint remains closed.'), timeoutMs) : undefined;
|
|
125
|
-
child.stdout.setEncoding('utf8');
|
|
126
|
-
child.stdout.on('data', data => {
|
|
127
|
-
if (observer.streamOnly) {
|
|
128
|
-
// Reviewer CLI tool reads can be much larger than the final report.
|
|
129
|
-
// Consume all events without retaining the full transcript in RAM.
|
|
130
|
-
output = (output + data).slice(-12000); observer.onData?.(data);
|
|
131
|
-
} else {
|
|
132
|
-
size += Buffer.byteLength(data);
|
|
133
|
-
output += data; observer.onData?.(data);
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
child.stderr.setEncoding('utf8');
|
|
137
|
-
child.stderr.on('data', data => { if(observer.captureStderr) output=(output+data).slice(-12000); }); // Premium CLI stderr remains private.
|
|
138
|
-
child.stdin.on('error', () => {});
|
|
139
|
-
child.on('error', e => { failure = e; });
|
|
140
|
-
child.on('close', code => {
|
|
141
|
-
clearTimeout(timer);clearTimeout(reapTimer); signal?.removeEventListener('abort', abort);
|
|
142
|
-
if(settled)return;settled=true;
|
|
143
|
-
if (failure || code !== 0) {
|
|
144
|
-
const error = failure ?? new Error(`Process exited ${code}; no approval recorded.`);
|
|
145
|
-
error.output = output.slice(-12000); error.exitCode = code; reject(error);
|
|
146
|
-
}
|
|
147
|
-
else resolve(output);
|
|
148
|
-
});
|
|
149
|
-
child.stdin.end(input);
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export async function runWorker(who, prompt, config, folder, signal, metadata = {}) {
|
|
154
|
-
const lane=reviewer(config,who);
|
|
155
|
-
if(lane.adapter==='opencode') {
|
|
156
|
-
if(!config.nativeRunner)throw Error('OpenCode reviewer adapter requires the active OpenCode plugin host.');
|
|
157
|
-
return config.nativeRunner(who,prompt,config,folder,signal,metadata);
|
|
158
|
-
}
|
|
159
|
-
const telemetry = new Telemetry(who, config, folder, metadata);
|
|
160
|
-
let envelope;
|
|
161
|
-
const stream = jsonLineReader(event => { telemetry.event(event); if (event.type === 'result') envelope = event; });
|
|
162
|
-
const heartbeat = setInterval(() => telemetry.save(), 5000); heartbeat.unref();
|
|
163
|
-
try {
|
|
164
|
-
if (signal?.aborted) throw new Error('Cancelled before launch.');
|
|
165
|
-
|
|
166
|
-
writeFileSync(path.join(folder, 'schema.json'), JSON.stringify(resultSchema));
|
|
167
|
-
const browser=prepareBrowser(config,folder);
|
|
168
|
-
const evidence=evidenceServer(folder);
|
|
169
|
-
const args = workerArgs(who, config, folder, metadata.images ?? [],browser,evidence);
|
|
170
|
-
if(evidence)prompt=queryInstructions+'\n\n'+prompt;
|
|
171
|
-
if(browser){prompt=browserInstructions+'\n\n'+prompt;telemetry.value.browserAvailable=true;telemetry.save();}
|
|
172
|
-
telemetry.prompt(prompt);
|
|
173
|
-
const executable = lane.executable;
|
|
174
|
-
const stdout = await runProcess(executable, args, folder, prompt, config.reviewTimeoutMs ?? 0, signal, {
|
|
175
|
-
streamOnly: true, onStart: pid => telemetry.start(pid), onData: text => stream.push(text),
|
|
176
|
-
});
|
|
177
|
-
stream.finish();
|
|
178
|
-
let result;
|
|
179
|
-
if (lane.adapter === 'codex-cli') result = JSON.parse(readFileSync(path.join(folder, 'result.json'), 'utf8'));
|
|
180
|
-
else {
|
|
181
|
-
if (!envelope) { try { envelope = JSON.parse(stdout); telemetry.event(envelope); } catch {} }
|
|
182
|
-
if (!envelope) throw new Error('Claude produced no final result event; checkpoint stays closed.');
|
|
183
|
-
if (envelope.is_error || envelope.subtype !== 'success') throw new Error('Claude did not complete successfully; checkpoint stays closed.');
|
|
184
|
-
result = envelope.structured_output;
|
|
185
|
-
if (!result) {
|
|
186
|
-
// Some CLI runs return the schema JSON as the final result string.
|
|
187
|
-
// Accept only exact JSON validated below, never prose or inferred approval.
|
|
188
|
-
try { result = JSON.parse(envelope.result); } catch {}
|
|
189
|
-
if (!result) throw new Error(`Claude returned no schema result (fields: ${Object.keys(envelope).join(',')}; result type: ${typeof envelope.result}); checkpoint stays closed.`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
try { validateResult(result); telemetry.end('completed', result.verdict); return result; }
|
|
193
|
-
catch {
|
|
194
|
-
throw new Error(`${who}: invalid reviewer schema (verdict type ${typeof result?.verdict}; report type ${typeof result?.report}; findings count ${Array.isArray(result?.findings) ? result.findings.length : 'not-array'}). Checkpoint stays closed.`);
|
|
195
|
-
}
|
|
196
|
-
} catch (error) {
|
|
197
|
-
if(error.unreapedPid){telemetry.value.workerExitUnconfirmed=true;telemetry.value.descendantsUnconfirmed=!!error.descendantsUnconfirmed;}
|
|
198
|
-
stream.finish(); telemetry.end(signal?.aborted ? 'cancelled' : 'failed');
|
|
199
|
-
delete error.output; // Never expose raw verbose CLI event tails to OpenCode logs.
|
|
200
|
-
throw error;
|
|
201
|
-
} finally { clearInterval(heartbeat); }
|
|
202
|
-
}
|
|
1
|
+
import {documentTool} from '../lib/public-document.mjs';
|
|
2
|
+
import {reviewer} from '../lib/reviewers.mjs';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { Telemetry, jsonLineReader } from './telemetry.mjs';
|
|
7
|
+
import { findingSchema, isMajor } from './decision.mjs';
|
|
8
|
+
import {processIdentity} from './process-identity.mjs';
|
|
9
|
+
import {commandSpec} from '../lib/commands.mjs';
|
|
10
|
+
import {commandEnvironment} from '../lib/platform.mjs';
|
|
11
|
+
import {prepareBrowser,browserTools,browserInstructions} from '../lib/reviewer-browser.mjs';
|
|
12
|
+
import {evidenceServer,queryInstructions,queryTool} from '../lib/evidence-mcp.mjs';
|
|
13
|
+
|
|
14
|
+
export const resultSchema = {
|
|
15
|
+
type: 'object', additionalProperties: false,
|
|
16
|
+
properties: {
|
|
17
|
+
verdict: { type: 'string', enum: ['pass', 'revise', 'blocked'] },
|
|
18
|
+
report: { type: 'string' },
|
|
19
|
+
findings: { type: 'array', items: findingSchema },
|
|
20
|
+
verifiedResolutions: {type:'array',items:{type:'object',additionalProperties:false,properties:{id:{type:'string'},evidence:{type:'string'}},required:['id','evidence']}},
|
|
21
|
+
}, required: ['verdict', 'report', 'findings', 'verifiedResolutions'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function workerArgs(who, config, folder, images = [], browser = null, evidence = null) {
|
|
25
|
+
const lane=reviewer(config,who);
|
|
26
|
+
// Attachment count is a transport choice, never an evidence quota. Larger
|
|
27
|
+
// sets stay in the frozen images/ manifest and are opened with view_image.
|
|
28
|
+
const inlineImages = images.length <= 4 && images.join('').length < 2048 ? images : [];
|
|
29
|
+
if (lane.adapter === 'codex-cli') return ['exec', '--json', '--ignore-user-config', '--ignore-rules', '--ephemeral', '--skip-git-repo-check',
|
|
30
|
+
'--sandbox', 'read-only', '-C', folder, '-m', lane.model,
|
|
31
|
+
// User config is intentionally isolated, so Windows' required sandbox
|
|
32
|
+
// implementation must be explicit too. Without it shell reads are denied.
|
|
33
|
+
...(process.platform === 'win32' ? ['-c', 'windows.sandbox="elevated"'] : []),
|
|
34
|
+
'-c', 'approval_policy="never"',
|
|
35
|
+
...(lane.effort==='default'?[]:['-c', `model_reasoning_effort="${lane.effort??'high'}"`]), '-c', 'web_search="disabled"',
|
|
36
|
+
...(browser ? ['-c',`mcp_servers.review_browser.command=${JSON.stringify(browser.command)}`,
|
|
37
|
+
'-c',`mcp_servers.review_browser.args=${JSON.stringify(browser.args)}`,
|
|
38
|
+
'-c','mcp_servers.review_browser.startup_timeout_sec=60',
|
|
39
|
+
'-c','mcp_servers.review_browser.tool_timeout_sec=120',
|
|
40
|
+
'-c','mcp_servers.review_browser.default_tools_approval_mode="approve"',
|
|
41
|
+
'-c',`mcp_servers.review_browser.enabled_tools=${JSON.stringify(browserTools)}`] : []),
|
|
42
|
+
...(evidence?['-c',`mcp_servers.review_evidence.command=${JSON.stringify(evidence.command)}`,'-c',`mcp_servers.review_evidence.args=${JSON.stringify(evidence.args)}`,
|
|
43
|
+
'-c','mcp_servers.review_evidence.default_tools_approval_mode="approve"','-c',`mcp_servers.review_evidence.enabled_tools=${JSON.stringify([queryTool,documentTool])}`]:[]),
|
|
44
|
+
...inlineImages.flatMap(image=>['--image',image]),
|
|
45
|
+
'--output-schema', path.join(folder, 'schema.json'), '-o', path.join(folder, 'result.json'), '-'];
|
|
46
|
+
return ['-p', ...(browser||evidence?['--setting-sources','','--disable-slash-commands','--settings','{"disableAllHooks":true,"enabledPlugins":{}}']:['--safe-mode']), '--restricted', '--strict-mcp-config', '--mcp-config', JSON.stringify({mcpServers:{...(browser?{review_browser:browser}:{}),...(evidence?{review_evidence:evidence}:{})}}),
|
|
47
|
+
'--no-chrome', '--no-session-persistence', '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
|
|
48
|
+
'--tools', 'Read,Glob,Grep', '--allowedTools', ['Read','Glob','Grep',...(browser?browserTools.map(name=>'mcp__review_browser__'+name):[]),...(evidence?[queryTool,documentTool].map(name=>'mcp__review_evidence__'+name):[])].join(','),
|
|
49
|
+
'--model', lane.model, ...(lane.effort==='default'?[]:['--effort',lane.effort??'high']), '--output-format', 'stream-json', '--verbose',
|
|
50
|
+
'--json-schema', JSON.stringify(resultSchema)];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateResult(value) {
|
|
54
|
+
if (!value || !['pass', 'revise', 'blocked'].includes(value.verdict) || typeof value.report !== 'string' || !value.report.trim() ||
|
|
55
|
+
!Array.isArray(value.findings)) throw new Error('Invalid reviewer result; checkpoint stays closed.');
|
|
56
|
+
if (Object.keys(value).some(key => !['verdict', 'report', 'findings','verifiedResolutions'].includes(key))) throw new Error('Invalid reviewer result fields.');
|
|
57
|
+
// Existing receipts remain readable. Omission cannot resolve tracked findings.
|
|
58
|
+
if(value.verifiedResolutions!==undefined&&(!Array.isArray(value.verifiedResolutions)||value.verifiedResolutions.some(r=>!r||Object.keys(r).some(k=>!['id','evidence'].includes(k))||typeof r.id!=='string'||!r.id.trim()||typeof r.evidence!=='string'||!r.evidence.trim())||new Set(value.verifiedResolutions.map(r=>r.id)).size!==value.verifiedResolutions.length))throw Error('Invalid verified resolutions.');
|
|
59
|
+
const ids = new Set();
|
|
60
|
+
for (const f of value.findings) {
|
|
61
|
+
if (!f || typeof f !== 'object' || Array.isArray(f) ||
|
|
62
|
+
Object.keys(f).some(key => !findingSchema.required.includes(key)) ||
|
|
63
|
+
findingSchema.required.some(key => typeof f[key] !== 'string' || !f[key].trim()) ||
|
|
64
|
+
!findingSchema.properties.severity.enum.includes(f.severity) ||
|
|
65
|
+
!findingSchema.properties.category.enum.includes(f.category) || ids.has(f.id) ||
|
|
66
|
+
(f.category === 'preference' && isMajor(f))) throw new Error('Invalid structured finding; checkpoint stays closed.');
|
|
67
|
+
ids.add(f.id);
|
|
68
|
+
}
|
|
69
|
+
if ((value.verdict === 'pass' && value.findings.some(isMajor)) ||
|
|
70
|
+
(value.verdict === 'revise' && !value.findings.length) ||
|
|
71
|
+
(value.verdict === 'blocked' && !value.findings.some(isMajor))) throw new Error('Inconsistent reviewer verdict; checkpoint stays closed.');
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function workerEnvironment(source = process.env) {
|
|
76
|
+
const env = {};
|
|
77
|
+
const allowed = /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC|TEMP|TMP|TMPDIR|HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_STATE_HOME|XDG_CACHE_HOME|USERPROFILE|APPDATA|LOCALAPPDATA|HOMEDRIVE|HOMEPATH|USERNAME|USERDOMAIN|PROGRAMFILES|PROGRAMFILES\(X86\)|PROGRAMW6432|OS|SYSTEMDRIVE|LANG|LC_ALL)$/i;
|
|
78
|
+
for (const [key, value] of Object.entries(source)) if (allowed.test(key)) env[key] = value;
|
|
79
|
+
// Official CLIs retain their normal per-user official login; never extract tokens.
|
|
80
|
+
return commandEnvironment(env);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function requireCliAuthentication(who, config, folder, invoke = spawnSync) {
|
|
84
|
+
const lane=reviewer(config,who);
|
|
85
|
+
if(lane.adapter==='opencode')return;
|
|
86
|
+
const executable=lane.executable;
|
|
87
|
+
const args = lane.adapter === 'codex-cli' ? ['login', 'status'] : ['auth', 'status'];
|
|
88
|
+
const spec=invoke===spawnSync?commandSpec(executable):{executable,args:[]};
|
|
89
|
+
const result = invoke(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, encoding: 'utf8', timeout: 15000 });
|
|
90
|
+
let valid = false;
|
|
91
|
+
if (result.status === 0 && lane.adapter === 'codex-cli') valid = /^Logged in using (?:ChatGPT|(?:an? )?API key)(?:\s|$)/im.test((result.stdout ?? '') + '\n' + (result.stderr ?? ''));
|
|
92
|
+
if (result.status === 0 && lane.adapter === 'claude-cli') {
|
|
93
|
+
try { const auth = JSON.parse(result.stdout); valid = auth.loggedIn === true && ['claude.ai','oauth_token','api_key'].includes(auth.authMethod) && auth.apiProvider === 'firstParty'; } catch {}
|
|
94
|
+
}
|
|
95
|
+
if (!valid) throw new Error(`${who}: official CLI authentication could not be confirmed. Sign in through the selected CLI using its subscription or Console/API login. Unknown or unsupported authentication is refused.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runProcess(executable, args, folder, input, timeoutMs, signal, observer = {}) {
|
|
99
|
+
if (signal?.aborted) throw new Error('Cancelled before launch.');
|
|
100
|
+
return await new Promise((resolve, reject) => {
|
|
101
|
+
const spec=commandSpec(executable);
|
|
102
|
+
const child = spawn(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, detached:process.platform!=='win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
103
|
+
child.once('spawn', () => observer.onStart?.(child.pid));
|
|
104
|
+
let output = '', failure, size = 0, reapTimer, settled=false;
|
|
105
|
+
const stop = reason => {
|
|
106
|
+
if(failure)return;
|
|
107
|
+
failure ??= new Error(reason);
|
|
108
|
+
if (process.platform === 'win32' && child.pid && child.exitCode === null) {
|
|
109
|
+
spawnSync(path.join(process.env.SystemRoot ?? 'C:/Windows', 'System32/taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 });
|
|
110
|
+
} else if(child.pid) { try {process.kill(-child.pid,'SIGTERM');} catch {child.kill();} }
|
|
111
|
+
// A failed OS termination must not hang the tool forever. Retain an explicit
|
|
112
|
+
// live-worker guard so returning an error never unlocks concurrent writers.
|
|
113
|
+
reapTimer=setTimeout(()=>{
|
|
114
|
+
if(settled)return;settled=true;clearTimeout(timer);signal?.removeEventListener('abort',abort);
|
|
115
|
+
failure.unreapedPid=child.pid;failure.workerIdentity=processIdentity(child.pid);
|
|
116
|
+
// An exited root does not prove its descendants exited: inherited pipes
|
|
117
|
+
// can keep this call open. Never clear this uncertainty by PID lookup.
|
|
118
|
+
failure.descendantsUnconfirmed=true;
|
|
119
|
+
child.stdout.destroy();child.stderr.destroy();child.stdin.destroy();child.unref();reject(failure);
|
|
120
|
+
},5000);
|
|
121
|
+
};
|
|
122
|
+
const abort = () => stop('Review cancelled; checkpoint remains closed.');
|
|
123
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
124
|
+
const timer = Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(() => stop('Process timed out; checkpoint remains closed.'), timeoutMs) : undefined;
|
|
125
|
+
child.stdout.setEncoding('utf8');
|
|
126
|
+
child.stdout.on('data', data => {
|
|
127
|
+
if (observer.streamOnly) {
|
|
128
|
+
// Reviewer CLI tool reads can be much larger than the final report.
|
|
129
|
+
// Consume all events without retaining the full transcript in RAM.
|
|
130
|
+
output = (output + data).slice(-12000); observer.onData?.(data);
|
|
131
|
+
} else {
|
|
132
|
+
size += Buffer.byteLength(data);
|
|
133
|
+
output += data; observer.onData?.(data);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
child.stderr.setEncoding('utf8');
|
|
137
|
+
child.stderr.on('data', data => { if(observer.captureStderr) output=(output+data).slice(-12000); }); // Premium CLI stderr remains private.
|
|
138
|
+
child.stdin.on('error', () => {});
|
|
139
|
+
child.on('error', e => { failure = e; });
|
|
140
|
+
child.on('close', code => {
|
|
141
|
+
clearTimeout(timer);clearTimeout(reapTimer); signal?.removeEventListener('abort', abort);
|
|
142
|
+
if(settled)return;settled=true;
|
|
143
|
+
if (failure || code !== 0) {
|
|
144
|
+
const error = failure ?? new Error(`Process exited ${code}; no approval recorded.`);
|
|
145
|
+
error.output = output.slice(-12000); error.exitCode = code; reject(error);
|
|
146
|
+
}
|
|
147
|
+
else resolve(output);
|
|
148
|
+
});
|
|
149
|
+
child.stdin.end(input);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function runWorker(who, prompt, config, folder, signal, metadata = {}) {
|
|
154
|
+
const lane=reviewer(config,who);
|
|
155
|
+
if(lane.adapter==='opencode') {
|
|
156
|
+
if(!config.nativeRunner)throw Error('OpenCode reviewer adapter requires the active OpenCode plugin host.');
|
|
157
|
+
return config.nativeRunner(who,prompt,config,folder,signal,metadata);
|
|
158
|
+
}
|
|
159
|
+
const telemetry = new Telemetry(who, config, folder, metadata);
|
|
160
|
+
let envelope;
|
|
161
|
+
const stream = jsonLineReader(event => { telemetry.event(event); if (event.type === 'result') envelope = event; });
|
|
162
|
+
const heartbeat = setInterval(() => telemetry.save(), 5000); heartbeat.unref();
|
|
163
|
+
try {
|
|
164
|
+
if (signal?.aborted) throw new Error('Cancelled before launch.');
|
|
165
|
+
requireCliAuthentication(who, config, folder);
|
|
166
|
+
writeFileSync(path.join(folder, 'schema.json'), JSON.stringify(resultSchema));
|
|
167
|
+
const browser=prepareBrowser(config,folder);
|
|
168
|
+
const evidence=evidenceServer(folder);
|
|
169
|
+
const args = workerArgs(who, config, folder, metadata.images ?? [],browser,evidence);
|
|
170
|
+
if(evidence)prompt=queryInstructions+'\n\n'+prompt;
|
|
171
|
+
if(browser){prompt=browserInstructions+'\n\n'+prompt;telemetry.value.browserAvailable=true;telemetry.save();}
|
|
172
|
+
telemetry.prompt(prompt);
|
|
173
|
+
const executable = lane.executable;
|
|
174
|
+
const stdout = await runProcess(executable, args, folder, prompt, config.reviewTimeoutMs ?? 0, signal, {
|
|
175
|
+
streamOnly: true, onStart: pid => telemetry.start(pid), onData: text => stream.push(text),
|
|
176
|
+
});
|
|
177
|
+
stream.finish();
|
|
178
|
+
let result;
|
|
179
|
+
if (lane.adapter === 'codex-cli') result = JSON.parse(readFileSync(path.join(folder, 'result.json'), 'utf8'));
|
|
180
|
+
else {
|
|
181
|
+
if (!envelope) { try { envelope = JSON.parse(stdout); telemetry.event(envelope); } catch {} }
|
|
182
|
+
if (!envelope) throw new Error('Claude produced no final result event; checkpoint stays closed.');
|
|
183
|
+
if (envelope.is_error || envelope.subtype !== 'success') throw new Error('Claude did not complete successfully; checkpoint stays closed.');
|
|
184
|
+
result = envelope.structured_output;
|
|
185
|
+
if (!result) {
|
|
186
|
+
// Some CLI runs return the schema JSON as the final result string.
|
|
187
|
+
// Accept only exact JSON validated below, never prose or inferred approval.
|
|
188
|
+
try { result = JSON.parse(envelope.result); } catch {}
|
|
189
|
+
if (!result) throw new Error(`Claude returned no schema result (fields: ${Object.keys(envelope).join(',')}; result type: ${typeof envelope.result}); checkpoint stays closed.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
try { validateResult(result); telemetry.end('completed', result.verdict); return result; }
|
|
193
|
+
catch {
|
|
194
|
+
throw new Error(`${who}: invalid reviewer schema (verdict type ${typeof result?.verdict}; report type ${typeof result?.report}; findings count ${Array.isArray(result?.findings) ? result.findings.length : 'not-array'}). Checkpoint stays closed.`);
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if(error.unreapedPid){telemetry.value.workerExitUnconfirmed=true;telemetry.value.descendantsUnconfirmed=!!error.descendantsUnconfirmed;}
|
|
198
|
+
stream.finish(); telemetry.end(signal?.aborted ? 'cancelled' : 'failed');
|
|
199
|
+
delete error.output; // Never expose raw verbose CLI event tails to OpenCode logs.
|
|
200
|
+
throw error;
|
|
201
|
+
} finally { clearInterval(heartbeat); }
|
|
202
|
+
}
|
package/docs/configuration.md
CHANGED
|
@@ -34,6 +34,12 @@ redirect reviewer executable settings to its own file. The installer supports
|
|
|
34
34
|
`--config-dir PATH` for a separate profile.
|
|
35
35
|
Do not put credentials in this file; OpenCode or the selected official CLI owns authentication.
|
|
36
36
|
|
|
37
|
+
CLI reviewers use the account already signed in through that CLI, including
|
|
38
|
+
Claude Console/API billing or a subscription. The plugin does not switch accounts,
|
|
39
|
+
copy keys or inherit API-key environment overrides. Billing follows the CLI login
|
|
40
|
+
you selected. For environment-key or cloud-provider setups, connect the provider
|
|
41
|
+
through OpenCode and select that provider/model in Hybrid Workflow settings.
|
|
42
|
+
|
|
37
43
|
`opencode-hybrid configure` opens an interactive selector: choose one or two
|
|
38
44
|
reviewers, OpenCode provider or external CLI for each, model, supported variant/CLI effort, and the
|
|
39
45
|
planning/design lead. Either slot can use any supported adapter, including two
|