@rockysoft/opencode-hybrid-workflow 0.0.0-stage → 0.3.2-beta.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/LICENSE +21 -0
- package/README.md +252 -3
- package/THIRD_PARTY_NOTICES.md +18 -0
- package/assets/agents/backend-engineer.md +167 -0
- package/assets/agents/code-reviewer.md +129 -0
- package/assets/agents/database-engineer.md +162 -0
- package/assets/agents/devops-engineer.md +115 -0
- package/assets/agents/frontend-engineer.md +38 -0
- package/assets/agents/fullstack-architect.md +43 -0
- package/assets/agents/security-auditor.md +132 -0
- package/assets/agents/technical-writer.md +78 -0
- package/assets/agents/test-engineer.md +154 -0
- package/assets/rules.md +93 -0
- package/assets/skills/frontend-design/INSTALLATION.json +9 -0
- package/assets/skills/frontend-design/LICENSE.txt +177 -0
- package/assets/skills/frontend-design/SKILL.md +78 -0
- package/assets/skills/ui-ux-pro-max/INSTALLATION.json +10 -0
- package/assets/skills/ui-ux-pro-max/LICENSE +21 -0
- package/assets/skills/ui-ux-pro-max/SKILL.md +221 -0
- package/assets/skills/ui-ux-pro-max/data/app-interface.csv +33 -0
- package/assets/skills/ui-ux-pro-max/data/catalog-summary.json +78 -0
- package/assets/skills/ui-ux-pro-max/data/charts.csv +26 -0
- package/assets/skills/ui-ux-pro-max/data/colors.csv +193 -0
- package/assets/skills/ui-ux-pro-max/data/data-provenance.json +1348 -0
- package/assets/skills/ui-ux-pro-max/data/google-font-licenses.json +20185 -0
- package/assets/skills/ui-ux-pro-max/data/google-fonts.csv +1935 -0
- package/assets/skills/ui-ux-pro-max/data/icons.csv +106 -0
- package/assets/skills/ui-ux-pro-max/data/landing.csv +35 -0
- package/assets/skills/ui-ux-pro-max/data/motion.csv +18 -0
- package/assets/skills/ui-ux-pro-max/data/phosphor-icons-upstream.json +32582 -0
- package/assets/skills/ui-ux-pro-max/data/products.csv +193 -0
- package/assets/skills/ui-ux-pro-max/data/react-performance.csv +45 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/angular.csv +51 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/astro.csv +54 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/avalonia.csv +57 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +60 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/javafx.csv +76 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/laravel.csv +51 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/nextjs.csv +62 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +77 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +68 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/react.csv +67 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/shadcn.csv +69 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/svelte.csv +56 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/threejs.csv +54 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/uno.csv +60 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/uwp.csv +56 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/vue.csv +50 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/winui.csv +60 -0
- package/assets/skills/ui-ux-pro-max/data/stacks/wpf.csv +57 -0
- package/assets/skills/ui-ux-pro-max/data/styles.csv +89 -0
- package/assets/skills/ui-ux-pro-max/data/typography.csv +75 -0
- package/assets/skills/ui-ux-pro-max/data/ui-reasoning.csv +193 -0
- package/assets/skills/ui-ux-pro-max/data/ux-guidelines.csv +120 -0
- package/assets/skills/ui-ux-pro-max/references/pro-rules.md +117 -0
- package/assets/skills/ui-ux-pro-max/references/quick-reference.md +256 -0
- package/assets/skills/ui-ux-pro-max/scripts/core.py +993 -0
- package/assets/skills/ui-ux-pro-max/scripts/design_system.py +1668 -0
- package/assets/skills/ui-ux-pro-max/scripts/reasoning_contract.py +123 -0
- package/assets/skills/ui-ux-pro-max/scripts/search.py +177 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-api.json +36 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-catalog.json +33 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-existing.csv +3 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-metadata.json +17 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-overrides.json +7 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/icons-curated.csv +4 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-core.json +23 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-package.json +13 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-react-exports.json +4 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-react-package.json +5 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-baseline.json +3366 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-cases.json +111 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-thresholds.json +100 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_catalog_refresh.py +354 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py +78 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_core.py +343 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_core_data_quality.py +198 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_data_contracts.py +421 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_design_system_mode.py +233 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_design_system_stack.py +61 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_native_desktop_stack_freshness.py +179 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_relevance_evaluator.py +182 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_skill_script_paths.py +82 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_style_taxonomy.py +175 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_text_layout_resilience.py +120 -0
- package/assets/skills/ui-ux-pro-max/scripts/tests/test_web_stack_freshness.py +177 -0
- package/assets/skills/ui-ux-pro-max/scripts/validate_data.py +1095 -0
- package/assets/skills/web-design-guidelines/INSTALLATION.json +1 -0
- package/assets/skills/web-design-guidelines/SKILL.md +18 -0
- package/cli.mjs +91 -0
- package/core/controller.mjs +367 -0
- package/core/dashboard-state.js +48 -0
- package/core/dashboard.css +2 -0
- package/core/dashboard.html +12 -0
- package/core/dashboard.js +72 -0
- package/core/decision.mjs +22 -0
- package/core/design.mjs +139 -0
- package/core/evidence.mjs +82 -0
- package/core/frontend-policy.mjs +34 -0
- package/core/images.mjs +35 -0
- package/core/monitor-data.mjs +58 -0
- package/core/monitor.mjs +24 -0
- package/core/operation-lock.mjs +82 -0
- package/core/plugin.mjs +91 -0
- package/core/posix-lock.mjs +31 -0
- package/core/posix-lock.py +23 -0
- package/core/pricing.mjs +53 -0
- package/core/process-identity.mjs +19 -0
- package/core/research-tools.mjs +37 -0
- package/core/review-context.mjs +43 -0
- package/core/snapshot.mjs +95 -0
- package/core/telemetry.mjs +107 -0
- package/core/workers.mjs +192 -0
- package/core/workflow-policy.mjs +10 -0
- package/index.js +19 -0
- package/lib/commands.mjs +48 -0
- package/lib/config.mjs +63 -0
- package/lib/install.mjs +111 -0
- package/lib/native-browser.mjs +79 -0
- package/lib/opencode-reviewer.mjs +129 -0
- package/lib/platform.mjs +14 -0
- package/lib/reviewer-browser-proxy.mjs +61 -0
- package/lib/reviewer-browser.mjs +35 -0
- package/lib/reviewers.mjs +11 -0
- package/lib/select-reviewers.mjs +58 -0
- package/lib/settings-rpc.mjs +9 -0
- package/lib/settings-service.mjs +129 -0
- package/package.json +63 -6
- package/sidebar/reader.mjs +80 -0
- package/sidebar/settings.mjs +99 -0
- package/tui.js +303 -0
package/core/workers.mjs
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {reviewer} from '../lib/reviewers.mjs';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Telemetry, jsonLineReader } from './telemetry.mjs';
|
|
6
|
+
import { findingSchema, isMajor } from './decision.mjs';
|
|
7
|
+
import {processIdentity} from './process-identity.mjs';
|
|
8
|
+
import {commandSpec} from '../lib/commands.mjs';
|
|
9
|
+
import {commandEnvironment} from '../lib/platform.mjs';
|
|
10
|
+
import {prepareBrowser,browserTools,browserInstructions} from '../lib/reviewer-browser.mjs';
|
|
11
|
+
|
|
12
|
+
export const resultSchema = {
|
|
13
|
+
type: 'object', additionalProperties: false,
|
|
14
|
+
properties: {
|
|
15
|
+
verdict: { type: 'string', enum: ['pass', 'revise', 'blocked'] },
|
|
16
|
+
report: { type: 'string' },
|
|
17
|
+
findings: { type: 'array', items: findingSchema },
|
|
18
|
+
}, required: ['verdict', 'report', 'findings'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function workerArgs(who, config, folder, images = [], browser = null) {
|
|
22
|
+
const lane=reviewer(config,who);
|
|
23
|
+
// Attachment count is a transport choice, never an evidence quota. Larger
|
|
24
|
+
// sets stay in the frozen images/ manifest and are opened with view_image.
|
|
25
|
+
const inlineImages = images.length <= 4 && images.join('').length < 2048 ? images : [];
|
|
26
|
+
if (lane.adapter === 'codex-cli') return ['exec', '--json', '--ignore-user-config', '--ignore-rules', '--ephemeral', '--skip-git-repo-check',
|
|
27
|
+
'--sandbox', 'read-only', '-C', folder, '-m', lane.model,
|
|
28
|
+
// User config is intentionally isolated, so Windows' required sandbox
|
|
29
|
+
// implementation must be explicit too. Without it shell reads are denied.
|
|
30
|
+
...(process.platform === 'win32' ? ['-c', 'windows.sandbox="elevated"'] : []),
|
|
31
|
+
'-c', 'approval_policy="never"',
|
|
32
|
+
'-c', `model_reasoning_effort="${lane.effort??'high'}"`, '-c', 'web_search="live"',
|
|
33
|
+
...(browser ? ['-c',`mcp_servers.review_browser.command=${JSON.stringify(browser.command)}`,
|
|
34
|
+
'-c',`mcp_servers.review_browser.args=${JSON.stringify(browser.args)}`,
|
|
35
|
+
'-c','mcp_servers.review_browser.startup_timeout_sec=60',
|
|
36
|
+
'-c','mcp_servers.review_browser.tool_timeout_sec=120',
|
|
37
|
+
'-c','mcp_servers.review_browser.default_tools_approval_mode="approve"',
|
|
38
|
+
'-c',`mcp_servers.review_browser.enabled_tools=${JSON.stringify(browserTools)}`] : []),
|
|
39
|
+
...inlineImages.flatMap(image=>['--image',image]),
|
|
40
|
+
'--output-schema', path.join(folder, 'schema.json'), '-o', path.join(folder, 'result.json'), '-'];
|
|
41
|
+
return ['-p', ...(browser?['--setting-sources','','--disable-slash-commands','--settings','{"disableAllHooks":true,"enabledPlugins":{}}']:['--safe-mode']), '--restricted', '--strict-mcp-config', '--mcp-config', JSON.stringify({mcpServers:browser?{review_browser:browser}:{}}),
|
|
42
|
+
'--no-chrome', '--no-session-persistence', '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
|
|
43
|
+
'--tools', 'Read,Glob,Grep,WebSearch,WebFetch', '--allowedTools', ['Read','Glob','Grep','WebSearch','WebFetch',...(browser?browserTools.map(name=>'mcp__review_browser__'+name):[])].join(','),
|
|
44
|
+
'--model', lane.model, '--effort', lane.effort??'high', '--output-format', 'stream-json', '--verbose',
|
|
45
|
+
'--json-schema', JSON.stringify(resultSchema)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validateResult(value) {
|
|
49
|
+
if (!value || !['pass', 'revise', 'blocked'].includes(value.verdict) || typeof value.report !== 'string' || !value.report.trim() ||
|
|
50
|
+
!Array.isArray(value.findings)) throw new Error('Invalid reviewer result; checkpoint stays closed.');
|
|
51
|
+
if (Object.keys(value).some(key => !['verdict', 'report', 'findings'].includes(key))) throw new Error('Invalid reviewer result fields.');
|
|
52
|
+
const ids = new Set();
|
|
53
|
+
for (const f of value.findings) {
|
|
54
|
+
if (!f || typeof f !== 'object' || Array.isArray(f) ||
|
|
55
|
+
Object.keys(f).some(key => !findingSchema.required.includes(key)) ||
|
|
56
|
+
findingSchema.required.some(key => typeof f[key] !== 'string' || !f[key].trim()) ||
|
|
57
|
+
!findingSchema.properties.severity.enum.includes(f.severity) ||
|
|
58
|
+
!findingSchema.properties.category.enum.includes(f.category) || ids.has(f.id) ||
|
|
59
|
+
(f.category === 'preference' && isMajor(f))) throw new Error('Invalid structured finding; checkpoint stays closed.');
|
|
60
|
+
ids.add(f.id);
|
|
61
|
+
}
|
|
62
|
+
if ((value.verdict === 'pass' && value.findings.some(isMajor)) ||
|
|
63
|
+
(value.verdict === 'revise' && !value.findings.length) ||
|
|
64
|
+
(value.verdict === 'blocked' && !value.findings.some(isMajor))) throw new Error('Inconsistent reviewer verdict; checkpoint stays closed.');
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function workerEnvironment(source = process.env) {
|
|
69
|
+
const env = {};
|
|
70
|
+
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;
|
|
71
|
+
for (const [key, value] of Object.entries(source)) if (allowed.test(key)) env[key] = value;
|
|
72
|
+
// Official CLIs retain their normal per-user subscription login; never extract tokens.
|
|
73
|
+
return commandEnvironment(env);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function requireSubscription(who, config, folder, invoke = spawnSync) {
|
|
77
|
+
const lane=reviewer(config,who);
|
|
78
|
+
if(lane.adapter==='opencode')return;
|
|
79
|
+
const executable=lane.executable;
|
|
80
|
+
const args = lane.adapter === 'codex-cli' ? ['login', 'status'] : ['auth', 'status'];
|
|
81
|
+
const spec=invoke===spawnSync?commandSpec(executable):{executable,args:[]};
|
|
82
|
+
const result = invoke(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, encoding: 'utf8', timeout: 15000 });
|
|
83
|
+
let valid = false;
|
|
84
|
+
if (result.status === 0 && lane.adapter === 'codex-cli') valid = /Logged in using ChatGPT/i.test((result.stdout ?? '') + (result.stderr ?? ''));
|
|
85
|
+
if (result.status === 0 && lane.adapter === 'claude-cli') {
|
|
86
|
+
try { const auth = JSON.parse(result.stdout); valid = auth.loggedIn === true && auth.authMethod === 'claude.ai' && auth.apiProvider === 'firstParty' && !!auth.subscriptionType; } catch {}
|
|
87
|
+
}
|
|
88
|
+
if (!valid) throw new Error(`${who}: subscription authentication could not be confirmed. Sign in through the official CLI; API/unknown authentication is refused.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runProcess(executable, args, folder, input, timeoutMs, signal, observer = {}) {
|
|
92
|
+
if (signal?.aborted) throw new Error('Cancelled before launch.');
|
|
93
|
+
return await new Promise((resolve, reject) => {
|
|
94
|
+
const spec=commandSpec(executable);
|
|
95
|
+
const child = spawn(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, detached:process.platform!=='win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
96
|
+
child.once('spawn', () => observer.onStart?.(child.pid));
|
|
97
|
+
let output = '', failure, size = 0, reapTimer, settled=false;
|
|
98
|
+
const stop = reason => {
|
|
99
|
+
if(failure)return;
|
|
100
|
+
failure ??= new Error(reason);
|
|
101
|
+
if (process.platform === 'win32' && child.pid && child.exitCode === null) {
|
|
102
|
+
spawnSync(path.join(process.env.SystemRoot ?? 'C:/Windows', 'System32/taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 });
|
|
103
|
+
} else if(child.pid) { try {process.kill(-child.pid,'SIGTERM');} catch {child.kill();} }
|
|
104
|
+
// A failed OS termination must not hang the tool forever. Retain an explicit
|
|
105
|
+
// live-worker guard so returning an error never unlocks concurrent writers.
|
|
106
|
+
reapTimer=setTimeout(()=>{
|
|
107
|
+
if(settled)return;settled=true;clearTimeout(timer);signal?.removeEventListener('abort',abort);
|
|
108
|
+
failure.unreapedPid=child.pid;failure.workerIdentity=processIdentity(child.pid);
|
|
109
|
+
// An exited root does not prove its descendants exited: inherited pipes
|
|
110
|
+
// can keep this call open. Never clear this uncertainty by PID lookup.
|
|
111
|
+
failure.descendantsUnconfirmed=true;
|
|
112
|
+
child.stdout.destroy();child.stderr.destroy();child.stdin.destroy();child.unref();reject(failure);
|
|
113
|
+
},5000);
|
|
114
|
+
};
|
|
115
|
+
const abort = () => stop('Review cancelled; checkpoint remains closed.');
|
|
116
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
117
|
+
const timer = Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(() => stop('Process timed out; checkpoint remains closed.'), timeoutMs) : undefined;
|
|
118
|
+
child.stdout.setEncoding('utf8');
|
|
119
|
+
child.stdout.on('data', data => {
|
|
120
|
+
if (observer.streamOnly) {
|
|
121
|
+
// Reviewer CLI tool reads can be much larger than the final report.
|
|
122
|
+
// Consume all events without retaining the full transcript in RAM.
|
|
123
|
+
output = (output + data).slice(-12000); observer.onData?.(data);
|
|
124
|
+
} else {
|
|
125
|
+
size += Buffer.byteLength(data);
|
|
126
|
+
output += data; observer.onData?.(data);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
child.stderr.setEncoding('utf8');
|
|
130
|
+
child.stderr.on('data', data => { if(observer.captureStderr) output=(output+data).slice(-12000); }); // Premium CLI stderr remains private.
|
|
131
|
+
child.stdin.on('error', () => {});
|
|
132
|
+
child.on('error', e => { failure = e; });
|
|
133
|
+
child.on('close', code => {
|
|
134
|
+
clearTimeout(timer);clearTimeout(reapTimer); signal?.removeEventListener('abort', abort);
|
|
135
|
+
if(settled)return;settled=true;
|
|
136
|
+
if (failure || code !== 0) {
|
|
137
|
+
const error = failure ?? new Error(`Process exited ${code}; no approval recorded.`);
|
|
138
|
+
error.output = output.slice(-12000); reject(error);
|
|
139
|
+
}
|
|
140
|
+
else resolve(output);
|
|
141
|
+
});
|
|
142
|
+
child.stdin.end(input);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function runWorker(who, prompt, config, folder, signal, metadata = {}) {
|
|
147
|
+
const lane=reviewer(config,who);
|
|
148
|
+
if(lane.adapter==='opencode') {
|
|
149
|
+
if(!config.nativeRunner)throw Error('OpenCode reviewer adapter requires the active OpenCode plugin host.');
|
|
150
|
+
return config.nativeRunner(who,prompt,config,folder,signal,metadata);
|
|
151
|
+
}
|
|
152
|
+
const telemetry = new Telemetry(who, config, folder, metadata);
|
|
153
|
+
let envelope;
|
|
154
|
+
const stream = jsonLineReader(event => { telemetry.event(event); if (event.type === 'result') envelope = event; });
|
|
155
|
+
const heartbeat = setInterval(() => telemetry.save(), 5000); heartbeat.unref();
|
|
156
|
+
try {
|
|
157
|
+
if (signal?.aborted) throw new Error('Cancelled before launch.');
|
|
158
|
+
requireSubscription(who, config, folder);
|
|
159
|
+
writeFileSync(path.join(folder, 'schema.json'), JSON.stringify(resultSchema));
|
|
160
|
+
const browser=prepareBrowser(config,folder);
|
|
161
|
+
const args = workerArgs(who, config, folder, metadata.images ?? [],browser);
|
|
162
|
+
if(browser){prompt=browserInstructions+'\n\n'+prompt;telemetry.value.browserAvailable=true;telemetry.save();}
|
|
163
|
+
const executable = lane.executable;
|
|
164
|
+
const stdout = await runProcess(executable, args, folder, prompt, config.reviewTimeoutMs ?? 0, signal, {
|
|
165
|
+
streamOnly: true, onStart: pid => telemetry.start(pid), onData: text => stream.push(text),
|
|
166
|
+
});
|
|
167
|
+
stream.finish();
|
|
168
|
+
let result;
|
|
169
|
+
if (lane.adapter === 'codex-cli') result = JSON.parse(readFileSync(path.join(folder, 'result.json'), 'utf8'));
|
|
170
|
+
else {
|
|
171
|
+
if (!envelope) { try { envelope = JSON.parse(stdout); telemetry.event(envelope); } catch {} }
|
|
172
|
+
if (!envelope) throw new Error('Claude produced no final result event; checkpoint stays closed.');
|
|
173
|
+
if (envelope.is_error || envelope.subtype !== 'success') throw new Error('Claude did not complete successfully; checkpoint stays closed.');
|
|
174
|
+
result = envelope.structured_output;
|
|
175
|
+
if (!result) {
|
|
176
|
+
// Some CLI runs return the schema JSON as the final result string.
|
|
177
|
+
// Accept only exact JSON validated below, never prose or inferred approval.
|
|
178
|
+
try { result = JSON.parse(envelope.result); } catch {}
|
|
179
|
+
if (!result) throw new Error(`Claude returned no schema result (fields: ${Object.keys(envelope).join(',')}; result type: ${typeof envelope.result}); checkpoint stays closed.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
try { validateResult(result); telemetry.end('completed', result.verdict); return result; }
|
|
183
|
+
catch {
|
|
184
|
+
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.`);
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if(error.unreapedPid){telemetry.value.workerExitUnconfirmed=true;telemetry.value.descendantsUnconfirmed=!!error.descendantsUnconfirmed;}
|
|
188
|
+
stream.finish(); telemetry.end(signal?.aborted ? 'cancelled' : 'failed');
|
|
189
|
+
delete error.output; // Never expose raw verbose CLI event tails to OpenCode logs.
|
|
190
|
+
throw error;
|
|
191
|
+
} finally { clearInterval(heartbeat); }
|
|
192
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Shared workflow guidance for the host agents and isolated reviewer processes.
|
|
2
|
+
export const WORKFLOW_POLICY = `Proportionate hybrid workflow:
|
|
3
|
+
Classify by actual change, not page count or code length. Use mode=ui for a visual redesign of an existing app that preserves architecture, data contracts and behavior: go directly to configured design-lead authoring and reviewer validation, then implement and review. Use mode=feature for new applications, substantive new behavior or architecture changes; investigate and plan those before design. Do not label a redesign feature merely because it is substantial visually. Exact cosmetic changes may use cosmetic; faithful bounded extensions use small with existing/supplied design evidence.
|
|
4
|
+
Decision ownership: the configured premium reviewers own material architecture and structural decisions, including new frameworks/dependencies, data models, shared API contracts, server/client boundaries and substantial changes to the approved plan. The implementation model and its native subagents gather facts, present alternatives and implement the approved decisions; they do not independently approve or replace them. The native fullstack-architect is a read-only researcher supporting premium planning. Route a newly discovered material choice through hybrid_checkpoint replan/plan (or design for UI/UX) before dependent implementation. Mechanical implementation details and faithful small/medium extensions within established patterns do not need a new planning ceremony.
|
|
5
|
+
Design acceptance must be risk-based: list changed component families, affected behavior, representative viewports/states and the concrete risk each check covers. Exercise genuinely distinct responsive layouts, error/interaction paths and print behavior that changed. Reuse shared-component coverage; do not demand the Cartesian product of every state and every width by default. Expand coverage when an observed defect or distinct implementation warrants it. An exhaustive matrix invented by an agent is not an explicit user requirement. If an already-approved contract is excessive, obtain a focused design amendment; do not silently ignore it or falsify evidence.
|
|
6
|
+
Separate product defects, missing evidence and preferences. Medium or higher requires a concrete material consequence or an essential unknown about the affected work. A missing screenshot alone is not medium unless that screenshot is necessary to resolve such a risk. Minor documentation wording, optional polish or preferred aesthetics are low/info. Never lower a real defect to force approval.
|
|
7
|
+
Before re-review, address the entire consolidated blocking checklist. Supply checks linking each finding ID to the actual fix and verification; use resolutions [{id,evidence}] for an explicit checklist. Do not send partial submissions hoping another full review will discover what is still missing. If evidence is unavailable, collect it or state the genuine external blocker. No numeric code-line, screenshot-count, correction-round or lifetime-call quota applies.
|
|
8
|
+
On follow-up, verify all earlier blocking findings, the new delta and affected dependencies. Reuse prior review coverage for unchanged responsibilities; do not retrace unrelated persistence/security paths for an evidence-only update. Full frozen source remains available whenever a correction affects those paths. Both reviewers still return fresh verdicts for this artifact; a stale pass never approves changed work. Continue only with at least one pass and no unresolved medium/high/critical finding from either completed reviewer.
|
|
9
|
+
All supplied screenshots remain available as frozen local files. Large submissions use file delivery, not rejection or silent omission. Inspect the relevant changed pixels and requested states; do not reread unchanged images solely because they remain in the manifest. Keep evidence honest: captions and hashes do not establish visual or behavioral correctness.
|
|
10
|
+
Independent reviews run concurrently; source remains locked until both settle. No automatic model/provider substitution. Preserve current task history and genuine unresolved findings.`;
|
package/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {resolveNativeGuards} from './core/operation-lock.mjs';
|
|
2
|
+
import {installNativeReviewer} from './lib/opencode-reviewer.mjs';
|
|
3
|
+
import {createPlugin} from './core/plugin.mjs';
|
|
4
|
+
import {readSettings,runtimeFor} from './lib/config.mjs';
|
|
5
|
+
import {registerSettings} from './lib/settings-service.mjs';
|
|
6
|
+
export default {id:'personal.hybrid-checkpoints', async setup(ctx) {
|
|
7
|
+
await registerSettings(ctx,ctx.options);
|
|
8
|
+
const config=readSettings(ctx.options);
|
|
9
|
+
if(!config.enabled)return;
|
|
10
|
+
if(!/^2\./.test(ctx.app?.version ?? '')||typeof ctx.session?.hook!=='function'||typeof ctx.tool?.transform!=='function'||typeof ctx.tool?.hook!=='function')
|
|
11
|
+
throw Error('Hybrid workflow needs OpenCode 2 with session/tool plugin hooks. Run opencode-hybrid doctor.');
|
|
12
|
+
// Validate the actual hook surface, not a pinned patch version.
|
|
13
|
+
config.compatibleOpenCode=[ctx.app.version];
|
|
14
|
+
config.storageFor=directory=>runtimeFor(config,directory);
|
|
15
|
+
config.reloadSettings=()=>readSettings(ctx.options);
|
|
16
|
+
config.nativeRunner=await installNativeReviewer(ctx);
|
|
17
|
+
config.resolveNativeSessions=file=>resolveNativeGuards(file,async sessionID=>{const value=await ctx.session.get({sessionID});return value.data??value;});
|
|
18
|
+
await createPlugin(config).setup(ctx);
|
|
19
|
+
}};
|
package/lib/commands.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {existsSync,readFileSync,readdirSync,statSync,accessSync,constants} from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {commandDirectories} from './platform.mjs';
|
|
4
|
+
// Never invoke a shell or interpolate arguments. Windows package-manager shims
|
|
5
|
+
// are resolved to their literal Node/native target, or rejected with a useful error.
|
|
6
|
+
export function commandSpec(command, env=process.env, platform=process.platform) {
|
|
7
|
+
if(typeof command!=='string'||!command)throw Error('Executable must be a nonempty string.');
|
|
8
|
+
const suffixes=platform==='win32'?['.exe','.cmd','']:[''];
|
|
9
|
+
const dirs=path.isAbsolute(command)?['']:commandDirectories(env,platform);
|
|
10
|
+
let found;
|
|
11
|
+
for(const dir of dirs) {
|
|
12
|
+
for(const suffix of path.extname(command)?['']:suffixes) {
|
|
13
|
+
const file=dir?path.resolve(dir,command+suffix):command+suffix;
|
|
14
|
+
try{if(statSync(file).isFile()){if(platform!=='win32')accessSync(file,constants.X_OK);found=file;break;}}catch{}
|
|
15
|
+
}
|
|
16
|
+
if(found)break;
|
|
17
|
+
}
|
|
18
|
+
if(!found)throw Error(`Executable not found: ${command}. Install it or set its absolute path in hybrid/config.json.`);
|
|
19
|
+
if(platform!=='win32'||!/\.(cmd|bat)$/i.test(found))return {executable:found,args:[]};
|
|
20
|
+
const dir=path.dirname(found),text=readFileSync(found,'utf8');
|
|
21
|
+
// npm/pnpm generated wrappers quote the %dp0% or %~dp0 relative executable.
|
|
22
|
+
const candidates=[...text.matchAll(/"%(?:dp0|~dp0)%?[\\/]([^"\r\n]+\.(?:[cm]?js|exe))"/gi)].map(m=>path.resolve(dir,m[1]));
|
|
23
|
+
// Current Node installers use a variable-based npm wrapper. Resolve its
|
|
24
|
+
// literal bundled CLI assignment, without executing its prefix lookup shell.
|
|
25
|
+
const npmCLI=text.match(/SET "NPM_CLI_JS=%~dp0[\\/]([^"\r\n]*npm-cli\.js)"/i);
|
|
26
|
+
if(npmCLI)candidates.unshift(path.resolve(dir,npmCLI[1]));
|
|
27
|
+
const target=candidates.find(f=>existsSync(f)&&!/node\.exe$/i.test(f));
|
|
28
|
+
if(!target)throw Error(`Cannot safely resolve shim ${found}. Configure the native executable or Node script path.`);
|
|
29
|
+
if(/\.exe$/i.test(target))return {executable:target,args:[]};
|
|
30
|
+
const nodeCandidates=[path.join(dir,'node.exe'),...String(env.PATH??env.Path??'').split(path.delimiter).filter(Boolean).map(p=>path.join(p,'node.exe'))];
|
|
31
|
+
const node=nodeCandidates.find(file=>existsSync(file));
|
|
32
|
+
if(!node)throw Error('Node executable not found for package-manager shim; install Node or add it to PATH.');
|
|
33
|
+
return {executable:node,args:[target]};
|
|
34
|
+
}
|
|
35
|
+
export function nativeCodex(command) {
|
|
36
|
+
const spec=commandSpec(command);
|
|
37
|
+
if(!spec.args.length)return command;
|
|
38
|
+
const script=spec.args[0];
|
|
39
|
+
const modules=path.resolve(script,'../../..');
|
|
40
|
+
const arch=process.arch==='arm64'?'arm64':'x64';
|
|
41
|
+
const platform=process.platform==='win32'?`codex-win32-${arch}`:process.platform==='darwin'?`codex-darwin-${arch}`:`codex-linux-${arch}`;
|
|
42
|
+
const vendor=path.join(modules,'codex','node_modules','@openai',platform,'vendor');
|
|
43
|
+
if(existsSync(vendor))for(const entry of readdirSync(vendor)) {
|
|
44
|
+
const executable=path.join(vendor,entry,'bin',process.platform==='win32'?'codex.exe':'codex');
|
|
45
|
+
if(existsSync(executable))return executable;
|
|
46
|
+
}
|
|
47
|
+
return command;
|
|
48
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {existsSync, readFileSync, realpathSync} from 'node:fs';
|
|
2
|
+
import {homedir} from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
import {lanes} from './reviewers.mjs';
|
|
6
|
+
import {validateBrowser} from './reviewer-browser.mjs';
|
|
7
|
+
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
export function configDirectory(env=process.env, home=homedir()) {
|
|
9
|
+
return path.resolve(env.OPENCODE_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(home,'.config'),'opencode'));
|
|
10
|
+
}
|
|
11
|
+
export function defaultSettings(env=process.env, home=homedir()) {
|
|
12
|
+
const data = process.platform==='win32' ? (env.LOCALAPPDATA || path.join(home,'AppData','Local')) : (env.XDG_DATA_HOME || path.join(home,'.local','share'));
|
|
13
|
+
return {version:1, enabled:true, runtimeDirectory:path.join(data,'opencode-hybrid','runtime'), projectRuntimeOverrides:{},
|
|
14
|
+
reviewers:{astra:{adapter:'codex-cli',model:'gpt-6-astra',effort:'high',executable:'codex'},opus:{adapter:'claude-cli',model:'opus',effort:'high',executable:'claude'}},
|
|
15
|
+
verification:{executable:'pnpm',timeoutMs:0},reviewTimeoutMs:0,maxPacketChars:400000,
|
|
16
|
+
design:{pythonExecutable:process.platform==='win32'?'python':'python3',skillsDirectory:path.join(packageRoot,'assets','skills')}};
|
|
17
|
+
}
|
|
18
|
+
export function settingsFile(options={},env=process.env) {
|
|
19
|
+
const trusted=env.OPENCODE_HYBRID_CONFIG || path.join(configDirectory(env),'hybrid','config.json');
|
|
20
|
+
const file=options.configFile || trusted;
|
|
21
|
+
if(!path.isAbsolute(file))throw Error('Hybrid configFile must be an absolute path to user-owned configuration.');
|
|
22
|
+
const normalize=value=>process.platform==='win32'?path.resolve(value).toLowerCase():path.resolve(value);
|
|
23
|
+
if(normalize(file)!==normalize(trusted))throw Error('Plugin options cannot select executable settings outside the user config. Use OPENCODE_CONFIG_DIR or OPENCODE_HYBRID_CONFIG from your own launcher.');
|
|
24
|
+
if(!env.OPENCODE_HYBRID_CONFIG&&existsSync(file)) {
|
|
25
|
+
const base=realpathSync(configDirectory(env)),resolved=realpathSync(file),relative=path.relative(base,resolved);
|
|
26
|
+
if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative))throw Error('Hybrid config symlink escapes the user configuration directory.');
|
|
27
|
+
}
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
export function readSettings(options={},env=process.env) {
|
|
31
|
+
const file=settingsFile(options,env), defaults=defaultSettings(env);
|
|
32
|
+
const supplied=existsSync(file)?JSON.parse(readFileSync(file,'utf8')):{};
|
|
33
|
+
if(supplied.version!==undefined&&supplied.version!==1)throw Error('Unsupported hybrid configuration version.');
|
|
34
|
+
const config={...defaults,...supplied,reviewers:Object.fromEntries(['astra','opus'].map(who=>[who,supplied.reviewers?.[who]===null?null:supplied.reviewers?.[who]?.adapter&&supplied.reviewers[who].adapter!==defaults.reviewers[who].adapter?supplied.reviewers[who]:{...defaults.reviewers[who],...supplied.reviewers?.[who]}])),
|
|
35
|
+
design:{...defaults.design,...supplied.design},verification:{...defaults.verification,...supplied.verification}};
|
|
36
|
+
if(options.enabled===false)config.enabled=false;
|
|
37
|
+
validateBrowser(config.reviewBrowser);
|
|
38
|
+
for(const who of ['astra','opus']) {
|
|
39
|
+
if(supplied.reviewers?.[who]===null)config.reviewers[who]=null;
|
|
40
|
+
const lane=config.reviewers[who];
|
|
41
|
+
if(lane===null||lane.enabled===false)continue;
|
|
42
|
+
if(!['codex-cli','claude-cli','opencode'].includes(lane.adapter))throw Error(`Unsupported reviewer adapter: ${lane.adapter}`);
|
|
43
|
+
for(const key of lane.adapter==='opencode'?['model','providerID']:['model','executable'])if(typeof lane[key]!=='string'||!lane[key].trim())throw Error(`reviewers.${who}.${key} is required.`);
|
|
44
|
+
if(lane.effort && !/^[a-zA-Z0-9_-]+$/.test(lane.effort))throw Error('Invalid reasoning effort.');
|
|
45
|
+
}
|
|
46
|
+
if(!lanes(config).includes('astra'))throw Error('Reviewer 1 (astra storage slot) is required; disable reviewer 2 for single-reviewer mode.');
|
|
47
|
+
if(config.designAuthor && !lanes(config).includes(config.designAuthor))throw Error('designAuthor must name an enabled reviewer slot.');
|
|
48
|
+
if(!path.isAbsolute(config.runtimeDirectory))throw Error('runtimeDirectory must be absolute.');
|
|
49
|
+
for(const [project,root] of Object.entries(config.projectRuntimeOverrides))if(!path.isAbsolute(project)||typeof root!=='string'||!path.isAbsolute(root))throw Error('Project/runtime overrides must use absolute paths.');
|
|
50
|
+
for(const n of [config.reviewTimeoutMs,config.verification.timeoutMs])if(!Number.isFinite(n)||n<0)throw Error('Timeout must be zero (disabled) or a positive duration.');
|
|
51
|
+
return {...config,configFile:file, codex:config.reviewers.astra,claude:config.reviewers.opus,pnpmExecutable:config.verification.executable,verificationTimeoutMs:config.verification.timeoutMs};
|
|
52
|
+
}
|
|
53
|
+
export function canonicalProject(directory) {
|
|
54
|
+
const value=realpathSync(directory);
|
|
55
|
+
return process.platform==='win32'?value.toLowerCase():value;
|
|
56
|
+
}
|
|
57
|
+
export function runtimeFor(config,directory) {
|
|
58
|
+
const project=canonicalProject(directory);
|
|
59
|
+
for(const [key,runtime] of Object.entries(config.projectRuntimeOverrides ?? {})) {
|
|
60
|
+
try {if(canonicalProject(key)===project)return runtime;}catch { /* An absent old project does not affect other projects. */ }
|
|
61
|
+
}
|
|
62
|
+
return config.runtimeDirectory;
|
|
63
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {existsSync,readFileSync,writeFileSync,mkdirSync,readdirSync,lstatSync,realpathSync,renameSync,unlinkSync} from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {createHash,randomUUID} from 'node:crypto';
|
|
4
|
+
import {packageRoot,configDirectory,defaultSettings} from './config.mjs';
|
|
5
|
+
const digest=text=>createHash('sha256').update(text).digest('hex');
|
|
6
|
+
const json=text=>JSON.stringify(text,null,2)+'\n';
|
|
7
|
+
export function parseJsonc(text) {
|
|
8
|
+
let stripped='',quoted=false,escape=false;
|
|
9
|
+
for(let i=0;i<text.length;i++) {
|
|
10
|
+
const c=text[i],next=text[i+1];
|
|
11
|
+
if(quoted){stripped+=c;if(escape)escape=false;else if(c==='\\')escape=true;else if(c==='"')quoted=false;continue;}
|
|
12
|
+
if(c==='"'){quoted=true;stripped+=c;continue;}
|
|
13
|
+
if(c==='/'&&next==='/'){while(i<text.length&&text[i]!=='\n')i++;stripped+='\n';continue;}
|
|
14
|
+
if(c==='/'&&next==='*'){i+=2;while(i<text.length&&!(text[i]==='*'&&text[i+1]==='/'))i++;if(i>=text.length)throw Error('Unterminated JSON comment');i++;stripped+=' ';continue;}
|
|
15
|
+
stripped+=c;
|
|
16
|
+
}
|
|
17
|
+
let result='';quoted=false;escape=false;
|
|
18
|
+
for(let i=0;i<stripped.length;i++) {
|
|
19
|
+
const c=stripped[i];
|
|
20
|
+
if(quoted){result+=c;if(escape)escape=false;else if(c==='\\')escape=true;else if(c==='"')quoted=false;continue;}
|
|
21
|
+
if(c==='"')quoted=true;
|
|
22
|
+
if(c===','&&/^[\s]*[}\]]/.test(stripped.slice(i+1)))continue;
|
|
23
|
+
result+=c;
|
|
24
|
+
}
|
|
25
|
+
return JSON.parse(result.replace(/^\uFEFF/,''));
|
|
26
|
+
}
|
|
27
|
+
export function safeTarget(root,file) {
|
|
28
|
+
const absolute=path.resolve(file),relative=path.relative(path.resolve(root),absolute);
|
|
29
|
+
if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative))throw Error('Install target is outside config directory.');
|
|
30
|
+
let current=path.resolve(root);
|
|
31
|
+
if(existsSync(current)&&lstatSync(current).isSymbolicLink())throw Error('Config directory must not be a symlink.');
|
|
32
|
+
for(const part of relative.split(path.sep)) {
|
|
33
|
+
current=path.join(current,part);
|
|
34
|
+
if(existsSync(current)&&lstatSync(current).isSymbolicLink())throw Error(`Refusing symlink install target: ${current}`);
|
|
35
|
+
}
|
|
36
|
+
return absolute;
|
|
37
|
+
}
|
|
38
|
+
const pluginName=value=>typeof value==='string'?value:value?.package;
|
|
39
|
+
const ours=value=>/(?:^|[/\\])opencode-hybrid-workflow(?:@[^/\\]+)?$/.test(pluginName(value)??'');
|
|
40
|
+
const legacySidebar=value=>/(?:^|[/\\])hybrid-usage$/.test(pluginName(value)??'');
|
|
41
|
+
function assetFiles(root,prefix='') {
|
|
42
|
+
return readdirSync(root,{withFileTypes:true}).flatMap(e=>{
|
|
43
|
+
if(e.isSymbolicLink())throw Error('Bundled assets must not contain symlinks.');
|
|
44
|
+
return e.isDirectory()?assetFiles(path.join(root,e.name),prefix+e.name+'/'):[prefix+e.name];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function configPath(root,name) {
|
|
48
|
+
const plain=path.join(root,name+'.json'),comments=path.join(root,name+'.jsonc');
|
|
49
|
+
if(existsSync(plain)&&existsSync(comments))throw Error(`Both ${name}.json and ${name}.jsonc exist. Resolve ambiguity before installation.`);
|
|
50
|
+
return existsSync(comments)?comments:plain;
|
|
51
|
+
}
|
|
52
|
+
export function install({root=configDirectory(),source=packageRoot,settingsPatch={},replaceLegacyPackage,apply=true,registerPlugin=true}={}) {
|
|
53
|
+
root=path.resolve(root);source=realpathSync(source);
|
|
54
|
+
const edits=[],conflicts=[];
|
|
55
|
+
const read=(file,fallback={})=>existsSync(file)?parseJsonc(readFileSync(safeTarget(root,file),'utf8')):fallback;
|
|
56
|
+
const settingsPath=path.join(root,'hybrid','config.json');
|
|
57
|
+
const existing=read(settingsPath);
|
|
58
|
+
const settings={...defaultSettings(),...existing,...settingsPatch};
|
|
59
|
+
// Package-owned bundled skills resolve at runtime; only explicit user overrides persist.
|
|
60
|
+
if(!existing.design&&!settingsPatch.design)delete settings.design;
|
|
61
|
+
const managedPath=path.join(root,'hybrid','installed.json');
|
|
62
|
+
const managed=read(managedPath,{files:{}}),files={...managed.files};
|
|
63
|
+
function stage(file,body) {
|
|
64
|
+
safeTarget(root,file);
|
|
65
|
+
const before=existsSync(file)?readFileSync(file,'utf8'):null;
|
|
66
|
+
if(before!==body)edits.push({file,before,body});
|
|
67
|
+
}
|
|
68
|
+
stage(settingsPath,json(settings));
|
|
69
|
+
for(const name of ['opencode','cli']) {
|
|
70
|
+
const file=configPath(root,name),value=read(file);
|
|
71
|
+
if(value.plugins!==undefined&&!Array.isArray(value.plugins))throw Error('plugins must be an array.');
|
|
72
|
+
// Remove only entries owned by this package (and the superseded personal sidebar).
|
|
73
|
+
if(registerPlugin){
|
|
74
|
+
const plugins=(value.plugins??[]).filter(p=>!ours(p)&&path.normalize(pluginName(p)??'')!==path.normalize(source)&&!(name==='opencode'&&replaceLegacyPackage&&path.normalize(pluginName(p)??'')===path.normalize(replaceLegacyPackage))&&!(name==='cli'&&legacySidebar(p)));
|
|
75
|
+
const entry={package:source.replaceAll('\\','/'),options:{configFile:settingsPath}};
|
|
76
|
+
value.plugins=[...plugins,entry];
|
|
77
|
+
}
|
|
78
|
+
if(name==='opencode') {
|
|
79
|
+
const rules=path.join(root,'hybrid','rules.md');
|
|
80
|
+
if(value.instructions!==undefined&&!Array.isArray(value.instructions))throw Error('instructions must be an array.');
|
|
81
|
+
value.instructions=[...new Set([...(value.instructions??[]),rules])];
|
|
82
|
+
}
|
|
83
|
+
if(registerPlugin||name==='opencode')stage(file,json(value));
|
|
84
|
+
}
|
|
85
|
+
for(const relative of ['hybrid/rules.md',...assetFiles(path.join(source,'assets','agents')).map(n=>'agents/'+n),...assetFiles(path.join(source,'assets','skills')).map(n=>'skills/'+n)]) {
|
|
86
|
+
const asset=relative==='hybrid/rules.md'?'rules.md':relative;
|
|
87
|
+
const body=readFileSync(path.join(source,'assets',asset),'utf8'),file=path.join(root,relative);
|
|
88
|
+
safeTarget(root,file);
|
|
89
|
+
const before=existsSync(file)?readFileSync(file,'utf8'):null;
|
|
90
|
+
if(before!==null&&before!==body&&files[relative]!==digest(before)){conflicts.push(relative);continue;}
|
|
91
|
+
stage(file,body);files[relative]=digest(body);
|
|
92
|
+
}
|
|
93
|
+
const manifest={version:1,packageVersion:JSON.parse(readFileSync(path.join(source,'package.json'),'utf8')).version,files};
|
|
94
|
+
stage(managedPath,json(manifest));
|
|
95
|
+
const backup=path.join(root,'backups','hybrid-'+Date.now()+'-'+randomUUID());
|
|
96
|
+
if(apply&&edits.length) {
|
|
97
|
+
for(const edit of edits) {
|
|
98
|
+
if(edit.before!==null){const copy=path.join(backup,path.relative(root,edit.file));safeTarget(root,copy);mkdirSync(path.dirname(copy),{recursive:true,mode:0o700});writeFileSync(copy,edit.before,{flag:'wx',mode:0o600});}
|
|
99
|
+
}
|
|
100
|
+
// No task/session/runtime files are touched by installation.
|
|
101
|
+
const prepared=[],committed=[];
|
|
102
|
+
try {
|
|
103
|
+
for(const edit of edits){mkdirSync(path.dirname(edit.file),{recursive:true});const temporary=edit.file+'.hybrid-'+randomUUID();writeFileSync(temporary,edit.body,{flag:'wx',mode:0o600});prepared.push({...edit,temporary});}
|
|
104
|
+
for(const edit of prepared){renameSync(edit.temporary,edit.file);committed.push(edit);}
|
|
105
|
+
}catch(error){
|
|
106
|
+
for(const edit of committed.reverse()){if(edit.before===null)unlinkSync(edit.file);else writeFileSync(edit.file,edit.before,{mode:0o600});}
|
|
107
|
+
throw error;
|
|
108
|
+
}finally{for(const edit of prepared)if(existsSync(edit.temporary))unlinkSync(edit.temporary);}
|
|
109
|
+
}
|
|
110
|
+
return {configDirectory:root,settingsPath,changed:edits.map(e=>path.relative(root,e.file)),preservedCustomFiles:conflicts,backup:apply&&edits.length?backup:null,applied:apply};
|
|
111
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
2
|
+
import {createInterface} from 'node:readline';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {prepareBrowser,browserTools} from './reviewer-browser.mjs';
|
|
5
|
+
import {workerEnvironment} from '../core/workers.mjs';
|
|
6
|
+
import {processIdentity} from '../core/process-identity.mjs';
|
|
7
|
+
|
|
8
|
+
// One transport per reviewer, started only if that reviewer uses the browser.
|
|
9
|
+
export class NativeBrowser {
|
|
10
|
+
constructor(config,folder) { this.config=config;this.folder=folder;this.pending=new Map();this.nextID=0; }
|
|
11
|
+
async start() {
|
|
12
|
+
if(this.stopping)throw Error('Review browser has closed.');
|
|
13
|
+
return this.starting??=(async()=>{
|
|
14
|
+
const launch=prepareBrowser(this.config,this.folder);
|
|
15
|
+
if(!launch)throw Error('Reviewer browser access is disabled.');
|
|
16
|
+
const child=this.child=spawn(launch.command,launch.args,{cwd:this.folder,env:workerEnvironment(),shell:false,windowsHide:true,detached:process.platform!=='win32',stdio:['pipe','pipe','pipe']});
|
|
17
|
+
this.closed=new Promise(resolve=>child.once('close',()=>{this.exited=true;this.fail(Error('Review browser transport closed.'));resolve();}));
|
|
18
|
+
child.on('error',error=>this.fail(error));child.stdin.on('error',error=>this.fail(error));child.stderr.resume();
|
|
19
|
+
this.lines=createInterface({input:child.stdout});
|
|
20
|
+
this.lines.on('line',line=>{
|
|
21
|
+
let message;try{message=JSON.parse(line);}catch{return;}
|
|
22
|
+
if(message.method){if(message.id!==undefined)child.stdin.write(JSON.stringify({jsonrpc:'2.0',id:message.id,error:{code:-32601,message:'Unsupported client request'}})+'\n');return;}
|
|
23
|
+
const request=this.pending.get(message.id);if(!request)return;
|
|
24
|
+
this.pending.delete(message.id);clearTimeout(request.timer);
|
|
25
|
+
if(message.error)request.reject(Error(message.error.message??'Browser MCP request failed.'));else request.resolve(message.result);
|
|
26
|
+
});
|
|
27
|
+
await this.request('initialize',{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'opencode-hybrid-reviewer',version:'1'}});
|
|
28
|
+
child.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})+'\n');
|
|
29
|
+
const listing=await this.request('tools/list',{});
|
|
30
|
+
this.tools=(listing.tools??[]).filter(tool=>browserTools.includes(tool.name));
|
|
31
|
+
})();
|
|
32
|
+
}
|
|
33
|
+
fail(error){this.failure=error;for(const request of this.pending.values()){clearTimeout(request.timer);request.reject(error);}this.pending.clear();}
|
|
34
|
+
request(method,params){
|
|
35
|
+
if(this.failure||this.stopping)return Promise.reject(this.failure??Error('Review browser is closing.'));
|
|
36
|
+
return new Promise((resolve,reject)=>{
|
|
37
|
+
const id=++this.nextID;
|
|
38
|
+
const timer=setTimeout(()=>{this.pending.delete(id);reject(Error('Browser operation timed out. Retry the observation; no review approval is implied.'));},120000);
|
|
39
|
+
this.pending.set(id,{resolve,reject,timer});
|
|
40
|
+
this.child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async execute(input,vision) {
|
|
44
|
+
if(input.tool&&!browserTools.includes(input.tool))throw Error('Tool is not available to reviewers.');
|
|
45
|
+
if(input.tool==='browser_take_screenshot'&&!vision)throw Error('Selected reviewer cannot inspect image pixels. Select a vision-capable model for visual review.');
|
|
46
|
+
await this.start();
|
|
47
|
+
if(!input.tool)return {content:JSON.stringify({tools:this.tools})};
|
|
48
|
+
if(!this.tools.some(tool=>tool.name===input.tool))throw Error('Tool is unavailable in the installed browser MCP.');
|
|
49
|
+
const result=await this.request('tools/call',{name:input.tool,arguments:input.arguments??{}});
|
|
50
|
+
return browserContent(result,vision);
|
|
51
|
+
}
|
|
52
|
+
async close(){return this.closing??=(async()=>{
|
|
53
|
+
this.stopping=true;this.fail(Error('Review browser is closing.'));
|
|
54
|
+
if(!this.child)return;
|
|
55
|
+
const child=this.child;
|
|
56
|
+
child.stdin.end();
|
|
57
|
+
const wait=ms=>new Promise(resolve=>{const timer=setTimeout(()=>resolve(false),ms);this.closed.then(()=>{clearTimeout(timer);resolve(true);});});
|
|
58
|
+
if(await wait(3000))return;
|
|
59
|
+
if(process.platform==='win32'&&child.pid&&child.exitCode===null)spawnSync(path.join(process.env.SystemRoot??'C:/Windows','System32/taskkill.exe'),['/PID',String(child.pid),'/T','/F'],{windowsHide:true,timeout:5000});
|
|
60
|
+
else if(child.pid){try{process.kill(-child.pid,'SIGKILL');}catch{child.kill('SIGKILL');}}
|
|
61
|
+
if(await wait(5000))return;
|
|
62
|
+
const error=Error('Reviewer browser termination could not be confirmed; checkpoint remains closed.');
|
|
63
|
+
error.unreapedPid=child.pid;error.workerIdentity=processIdentity(child.pid);error.descendantsUnconfirmed=true;
|
|
64
|
+
this.lines?.close();child.stdin.destroy();child.stdout.destroy();child.stderr.destroy();child.unref();throw error;
|
|
65
|
+
})();}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function browserContent(result,vision){
|
|
69
|
+
const content=[];
|
|
70
|
+
for(const item of result.content??[]){
|
|
71
|
+
if(item.type==='text')content.push({type:'text',text:item.text});
|
|
72
|
+
else if(item.type==='image'){
|
|
73
|
+
if(vision)content.push({type:'file',uri:`data:${item.mimeType};base64,${item.data}`,mime:item.mimeType,name:'browser-capture'});
|
|
74
|
+
else content.push({type:'text',text:'Image pixels unavailable to this model. Do not claim visual inspection.'});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if(result.isError)content.unshift({type:'text',text:'Browser operation failed. The following is an error, not successful evidence.'});
|
|
78
|
+
return {content:content.length?content:[{type:'text',text:'Browser returned no inspectable content.'}]};
|
|
79
|
+
}
|