@ckanner/dsh-plugin-flywheel 0.0.0-stage → 0.28.0

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/dist/client.js ADDED
@@ -0,0 +1,206 @@
1
+ window.__ModuleLoader__.load({id:"@ckanner/dsh-plugin-flywheel",factory:(require)=>{
2
+ const module={exports:{}};const exports=module.exports;
3
+ // The build wraps this file in rc.5's browser ModuleLoader factory.
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+
7
+ const taskLabels = {
8
+ pending: '待开始', paused: '已暂停', stopped: '已停止', completed: '已完成',
9
+ escalated: '需要人工处理', 'budget-stopped': '预算已停止',
10
+ };
11
+ const packetLabels = {
12
+ pending: '待执行', 'awaiting-credentials': '等待凭证', executing: '执行中', executed: '待验收',
13
+ 'l1-passed': '验收已通过', 'l1-failed': '验收未通过', 'review-failed': '评审退回',
14
+ completed: '已完成', interrupted: '已中断', escalated: '需要人工处理', inbox: '等待处理',
15
+ };
16
+ const checkLabels = { pass: '通过', fail: '失败', warn: '注意', unknown: '未验证', skipped: '跳过' };
17
+ const roleLabels = { plan: '规划', execute: '执行', review: '评审', judge: '终验',
18
+ intake: '需求收集', garden: '知识维护', 'judge-panel': '第二意见' };
19
+ const buttonStyle = { border: '1px solid #8888', padding: '4px 10px', borderRadius: 5 };
20
+ const tableStyle = { width: '100%', textAlign: 'left', fontSize: 11, borderCollapse: 'collapse' };
21
+ const cellStyle = { padding: '5px 8px 5px 0', verticalAlign: 'top', overflowWrap: 'anywhere' };
22
+ const rawStyle = { fontSize: 11, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: 240, overflow: 'auto' };
23
+
24
+ function taskLabel(value) {
25
+ if (value.running) return '运行中';
26
+ if (!value.state) return '尚未初始化';
27
+ if (value.state.status === 'running') return '引擎未运行,状态待核对';
28
+ return taskLabels[value.state.status] ?? `未知状态(${value.state.status ?? '未报告'})`;
29
+ }
30
+
31
+ function Configuration({ value }) {
32
+ if (!value) return null;
33
+ return h('section', { 'aria-label': '实际模型配置' },
34
+ h('h4', { style: { marginBottom: 6 } }, '实际模型配置'),
35
+ h('p', { style: { overflowWrap: 'anywhere' } }, `来源:${value.source || '内嵌模板'} · 范围:${value.scope}`),
36
+ h('div', { style: { overflowX: 'auto' } }, h('table', { style: tableStyle },
37
+ h('thead', null, h('tr', null, ...['阶段 / 路由', '模型 / Profile', 'Harness / Provider / 渠道', '推理 / 阶段权限要求 / 会话']
38
+ .map(title => h('th', { key: title, scope: 'col', style: cellStyle }, title)))),
39
+ h('tbody', null, ...value.bindings.map(binding => h('tr', { key: binding.id },
40
+ h('td', { style: cellStyle }, h('strong', null, roleLabels[binding.role] ?? binding.role),
41
+ h('div', null, binding.route || binding.id),
42
+ binding.inScope === false && h('div', null, '未用于本次检查')),
43
+ h('td', { style: cellStyle }, h('code', null, binding.model || '未配置'),
44
+ binding.profile && h('div', null, `Profile:${binding.profile}`)),
45
+ h('td', { style: cellStyle }, h('div', null, binding.harness),
46
+ h('div', null, `Provider:${binding.provider || (binding.harness === 'dsh' ? '未配置' : '由原生 CLI 管理')}`),
47
+ h('div', null, `渠道:${binding.channel || '未配置'}`)),
48
+ h('td', { style: cellStyle }, h('div', null, `推理:${binding.thinking || '默认'}`),
49
+ h('div', null, `阶段权限要求:${binding.permissionMode || '未声明'}`),
50
+ h('div', null, binding.residentSession ? '会话恢复:启用' : '会话恢复:关闭'))))))));
51
+ }
52
+
53
+ function Diagnostic({ value }) {
54
+ if (!value) return null;
55
+ return h('section', { 'aria-label': '环境检查结果' },
56
+ h('h4', { style: { marginBottom: 6 } }, value.ok ? '检查结束:未发现阻断项' : '检查结束:存在阻断项'),
57
+ h('p', null, value.probeRequested ? '已请求 SDK 无推理检查。' : '本次只检查本地环境,未启动 SDK 探测。'),
58
+ h('p', null, '未发送推理请求;账号授权、额度和真实模型能力未通过本次检查验证。'),
59
+ h('ul', { style: { paddingLeft: 20 } }, ...value.checks.map((check, index) =>
60
+ h('li', { key: `${check.id}:${index}`, style: { marginBottom: 9, overflowWrap: 'anywhere' } },
61
+ h('strong', null, `${checkLabels[check.status]} · ${check.id}`),
62
+ h('div', null, check.summary),
63
+ check.code && h('div', null, `错误码:${check.code}`),
64
+ check.remedy && h('div', null, `处理建议:${check.remedy}`)))));
65
+ }
66
+
67
+ function Budget({ value }) {
68
+ if (!value) return null;
69
+ const cost = value.totals?.['cost-usd'];
70
+ const unknownCost = value['unknown-cost-calls'] ?? 0;
71
+ const unknownUsage = value['unknown-usage-calls'] ?? 0;
72
+ let costText = Number.isFinite(cost) ? `已报告成本合计:$${cost.toFixed(4)}` : '成本:未知';
73
+ if (unknownCost > 0) {
74
+ costText = Number.isFinite(cost) && cost > 0 ?
75
+ `已知成本合计:$${cost.toFixed(4)};另有 ${unknownCost} 次调用成本未知,合计不完整` :
76
+ `成本:未知(${unknownCost} 次调用未报告结算成本)`;
77
+ }
78
+ return h('section', { 'aria-label': '用量与成本' },
79
+ h('p', null, costText),
80
+ h('p', null, `已报告 Token:${value.totals?.tokens ?? '未知'}` +
81
+ (unknownUsage > 0 ? `;${unknownUsage} 次调用用量未知,合计不完整` : '')),
82
+ value['daily-token-limit'] != null && h('p', null,
83
+ `每日 Token 限额:${value['daily-token-limit']} · 预算策略:${value.enforce}`),
84
+ ...(value.warnings ?? []).map((warning, index) => h('p', { key: index }, `预算提示:${warning}`)));
85
+ }
86
+
87
+ function Panel({ execute }) {
88
+ const [open, setOpen] = React.useState(false);
89
+ const [taskDir, setTaskDir] = React.useState('');
90
+ const [cwd, setCwd] = React.useState('');
91
+ const [busy, setBusy] = React.useState(false);
92
+ const [status, setStatus] = React.useState(null);
93
+ const [configuration, setConfiguration] = React.useState(null);
94
+ const [diagnostic, setDiagnostic] = React.useState(null);
95
+ const [raw, setRaw] = React.useState(null);
96
+ const [error, setError] = React.useState('');
97
+ const requestVersion = React.useRef(0);
98
+ const panelId = React.useId();
99
+ React.useEffect(() => () => { requestVersion.current += 1; }, []);
100
+ const changePath = (setPath, value) => {
101
+ // Editing a path changes the view's identity, even while a request is in
102
+ // flight. It never implies stopping an independently running Go task.
103
+ requestVersion.current += 1;
104
+ setPath(value);
105
+ setBusy(false);
106
+ setStatus(null);
107
+ setConfiguration(null);
108
+ setDiagnostic(null);
109
+ setRaw(null);
110
+ setError('');
111
+ };
112
+ const packets = Object.entries(status?.state?.packets ?? {});
113
+ const run = async (action, probe = false) => {
114
+ const version = ++requestVersion.current;
115
+ setBusy(true);
116
+ setError('');
117
+ try {
118
+ const request = { action, ...(taskDir.trim() ? { taskDir: taskDir.trim() } : {}),
119
+ ...(cwd.trim() ? { cwd: cwd.trim() } : {}), ...(probe ? { probe: true } : {}) };
120
+ const value = await execute(request);
121
+ if (version !== requestVersion.current) return;
122
+ if (['start', 'status', 'stop'].includes(action)) setStatus(value);
123
+ if (['config', 'doctor'].includes(action)) {
124
+ setConfiguration(value.configuration ?? value.diagnostic?.configuration ?? null);
125
+ setDiagnostic(value.diagnostic ?? null);
126
+ }
127
+ setRaw(value);
128
+ } catch (error) {
129
+ if (version === requestVersion.current) setError(error instanceof Error ? error.message : String(error));
130
+ } finally {
131
+ if (version === requestVersion.current) setBusy(false);
132
+ }
133
+ };
134
+ const button = (action, label, needsTask, probe = false) => h('button', {
135
+ key: label, type: 'button', disabled: busy || (needsTask && !taskDir.trim()),
136
+ onClick: () => run(action, probe), style: buttonStyle,
137
+ }, label);
138
+ return h('div', { style: { position: 'relative', fontSize: 12 } },
139
+ h('button', { type: 'button', onClick: () => setOpen(!open), 'aria-expanded': open, 'aria-controls': panelId,
140
+ style: { border: '1px solid currentColor', borderRadius: 6, padding: '2px 8px' } },
141
+ `Flywheel${status ? ` · ${taskLabel(status)}` : ''}`),
142
+ open && h('section', { id: panelId, 'aria-label': 'Flywheel 任务控制', style: {
143
+ position: 'absolute', bottom: 'calc(100% + 8px)', left: 0, width: 'min(720px, 88vw)',
144
+ maxHeight: '70vh', overflow: 'auto', padding: 14, border: '1px solid #8888', borderRadius: 10,
145
+ background: 'var(--color-background, Canvas)', color: 'var(--color-foreground, CanvasText)',
146
+ boxShadow: '0 8px 32px #0003', zIndex: 40,
147
+ } },
148
+ h('strong', null, 'Flywheel · Go 引擎'),
149
+ h('p', { style: { margin: '8px 0' } }, '任务独立于桌面会话运行。切换下方路径只切换查看对象;停止任务请使用“停止”。'),
150
+ h('label', { style: { display: 'block' } }, '任务目录(绝对路径,配置与环境检查可留空)',
151
+ h('input', { value: taskDir, onChange: e => changePath(setTaskDir, e.target.value),
152
+ placeholder: '/path/to/repo/docs/task',
153
+ style: { width: '100%', boxSizing: 'border-box', padding: 6, border: '1px solid #8888', borderRadius: 4, margin: '4px 0 8px' } })),
154
+ h('label', { style: { display: 'block' } }, '仓库目录(留空使用当前 DSH 会话目录)',
155
+ h('input', { value: cwd, onChange: e => changePath(setCwd, e.target.value), placeholder: '/path/to/repo',
156
+ style: { width: '100%', boxSizing: 'border-box', padding: 6, border: '1px solid #8888', borderRadius: 4, margin: '4px 0 8px' } })),
157
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 } },
158
+ button('status', '刷新状态', true), button('start', '启动', true), button('stop', '停止', true)),
159
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 } },
160
+ button('config', '查看模型配置', false), button('doctor', '检查环境', false),
161
+ button('doctor', 'SDK 无推理检查', false, true)),
162
+ busy && h('p', { role: 'status' }, '正在等待引擎确认…'),
163
+ error && h('p', { role: 'alert', style: { color: '#d34b4b', whiteSpace: 'pre-wrap' } }, error),
164
+ status && h('section', { 'aria-label': '任务状态' },
165
+ h('h4', { style: { marginBottom: 6 } }, '任务状态(上次刷新)'),
166
+ h('p', null, `${status.state?.slug ?? '任务'} · ${taskLabel(status)}` + (status.running && status.pid > 0 ? ` · PID ${status.pid}` : '')),
167
+ status.state?.['current-milestone'] && h('p', null, `当前里程碑:${status.state['current-milestone']}`),
168
+ packets.length > 0 && h('div', { style: { maxHeight: 260, overflow: 'auto' } },
169
+ h('table', { 'aria-label': '工作单元', style: tableStyle },
170
+ h('caption', { style: { textAlign: 'left' } }, `工作单元(共 ${packets.length} 个)`),
171
+ h('thead', null, h('tr', null, ...['工作单元', '状态', '路由'].map(title => h('th', { key: title, scope: 'col', style: cellStyle }, title)))),
172
+ h('tbody', null, ...packets.map(([id, packet]) => h('tr', { key: id },
173
+ h('td', { style: cellStyle }, id),
174
+ h('td', { style: cellStyle }, packetLabels[packet?.status] ?? packet?.status ?? '未知'),
175
+ h('td', { style: cellStyle }, packet?.route ?? '—')))))),
176
+ h(Budget, { value: status.state?.budget }),
177
+ status.state?.inbox?.length > 0 && h('details', null,
178
+ h('summary', null, `待人工处理记录:${status.state.inbox.length} 条`),
179
+ h('pre', { style: rawStyle }, JSON.stringify(status.state.inbox, null, 2)))),
180
+ h(Diagnostic, { value: diagnostic }),
181
+ h(Configuration, { value: configuration }),
182
+ raw && h('details', null, h('summary', null, '引擎原始结果(JSON)'),
183
+ h('pre', { style: rawStyle }, JSON.stringify(raw, null, 2))),
184
+ ));
185
+ }
186
+
187
+ exports.inject = ['slots', 'remote', 'remote.commands'];
188
+ exports.apply = function apply(ctx) {
189
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
190
+ name: 'conversation.composer.dock', id: 'flywheel', order: 50,
191
+ inject: sessionId => ({
192
+ execute: async request => {
193
+ const reply = await ctx.remote.commands.execute(sessionId, `/flywheel ${JSON.stringify(request)}`, []);
194
+ if (!reply?.ok) throw new Error(reply?.error?.message ?? 'DSH command transport failed');
195
+ const result = reply.value?.result;
196
+ if (result?.kind !== 'success') throw new Error(result?.text ?? 'Flywheel command unavailable');
197
+ const value = JSON.parse(result.text);
198
+ if (value?.protocol !== 1) throw new Error('Flywheel control protocol mismatch');
199
+ return value;
200
+ },
201
+ }),
202
+ }, Panel));
203
+ };
204
+
205
+ return module.exports;
206
+ }});
@@ -0,0 +1,60 @@
1
+ // Public machine API consumer. Go remains the owner of scheduling and acceptance.
2
+ import { createRequire } from 'node:module';
3
+
4
+ export const ADAPTER = Object.freeze({ package: 'dsh-subagent-codex-pro', version: '0.8.0', protocol: 1 });
5
+ export const LIMITS = Object.freeze({ requestBytes: 1024 * 1024, machineOutputBytes: 1024 * 1024,
6
+ progressBytes: 1024 * 1024, receiptFrameBytes: 8 * 1024 * 1024, admissionMs: 10000 });
7
+ const require = createRequire(import.meta.url);
8
+
9
+ export class WorkerError extends Error {
10
+ constructor(code, message) { super(message); this.name = 'WorkerError'; this.code = code; }
11
+ }
12
+
13
+ /** The doctor executes this same import, but never calls runMachine. */
14
+ export async function loadAdapter() {
15
+ const [major, minor] = process.versions.node.split('.').map(Number);
16
+ if (major < 22 || (major === 22 && minor < 18)) throw new WorkerError('UNSUPPORTED_RUNTIME', 'Codex Pro requires Node.js 22.18.0 or newer');
17
+ if (!['darwin', 'linux'].includes(process.platform)) throw new WorkerError('UNSUPPORTED_RUNTIME', 'Codex Pro requires a supported POSIX platform');
18
+ let manifest, api;
19
+ try {
20
+ manifest = require('dsh-subagent-codex-pro/package.json');
21
+ } catch {
22
+ throw new WorkerError('ADAPTER_UNAVAILABLE', 'Install the exact dsh-subagent-codex-pro@0.8.0 dependency with Flywheel');
23
+ }
24
+ if (manifest.name !== ADAPTER.package || manifest.version !== ADAPTER.version) {
25
+ throw new WorkerError('ADAPTER_MISMATCH', 'Codex Pro package identity or version does not match the pinned contract');
26
+ }
27
+ try { api = await import('dsh-subagent-codex-pro/machine'); }
28
+ catch { throw new WorkerError('ADAPTER_UNAVAILABLE', 'The pinned Codex Pro public machine API could not be imported'); }
29
+ if (api.MACHINE_PROTOCOL_VERSION !== ADAPTER.protocol ||
30
+ typeof api.parseMachineRequest !== 'function' || typeof api.runMachine !== 'function' || typeof api.MachineInputError !== 'function') {
31
+ throw new WorkerError('ADAPTER_MISMATCH', 'Codex Pro package identity, version or public machine API does not match the pinned contract');
32
+ }
33
+ return api;
34
+ }
35
+
36
+ /** Reject unsupported fields and permissions before any process or prompt starts. */
37
+ export function machineRequest(value, api, env) {
38
+ const keys = new Set(['type', 'protocol', 'requestId', 'cwd', 'model', 'reasoningEffort', 'permissionMode', 'prompt', 'timeoutMs']);
39
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || value.type !== 'start' || value.protocol !== 1 ||
40
+ Object.keys(value).some(key => !keys.has(key)) || typeof value.requestId !== 'string' || value.requestId.length > 256 ||
41
+ !['read-only', 'workspace-write'].includes(value.permissionMode)) {
42
+ throw new WorkerError('INVALID_REQUEST', 'Expected one protocol=1 start request with an explicit supported permission mode');
43
+ }
44
+ if (value.timeoutMs !== undefined && (!Number.isSafeInteger(value.timeoutMs) || value.timeoutMs < 1 || value.timeoutMs > 2147483647)) {
45
+ throw new WorkerError('INVALID_REQUEST', 'timeoutMs must be a positive safe integer no greater than 2147483647');
46
+ }
47
+ try {
48
+ return api.parseMachineRequest({ protocol: 1, requestId: value.requestId, cwd: value.cwd, model: value.model,
49
+ effort: value.reasoningEffort, permissionMode: value.permissionMode, prompt: value.prompt,
50
+ ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }),
51
+ command: env.FLYWHEEL_CODEX_BIN || 'codex', threadMode: 'ephemeral', maxOutputBytes: LIMITS.machineOutputBytes });
52
+ } catch (error) {
53
+ throw new WorkerError('INVALID_REQUEST', error instanceof api.MachineInputError ? error.message : 'Invalid Codex Pro machine request');
54
+ }
55
+ }
56
+
57
+ export function doctorResult() {
58
+ return { protocol: 1, type: 'doctor', package: ADAPTER.package, version: ADAPTER.version,
59
+ machineProtocol: ADAPTER.protocol, available: true, inferenceTested: false };
60
+ }
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // One owned invocation. stdin remains a lifeline to the Go parent after admission.
3
+ import { ADAPTER, LIMITS, WorkerError, loadAdapter, machineRequest, doctorResult } from './codex-pro-bridge.js';
4
+
5
+ const controller = new AbortController();
6
+ let cancelledBy, inputFailure, outputFailure, requestId = 'invalid-request', finished = false;
7
+ const cancel = signal => { cancelledBy ??= signal; controller.abort(); };
8
+ const handlers = new Map(['SIGTERM', 'SIGINT', 'SIGHUP'].map(signal => [signal, () => cancel(signal)]));
9
+ for (const [signal, handler] of handlers) process.on(signal, handler);
10
+ process.stdout.on('error', () => {
11
+ outputFailure ??= new WorkerError('OUTPUT_ERROR', 'The result consumer closed or failed');
12
+ process.exitCode = 1;
13
+ controller.abort();
14
+ });
15
+
16
+ function write(frame) {
17
+ if (outputFailure) return;
18
+ const line = JSON.stringify(frame) + '\n';
19
+ if (Buffer.byteLength(line) > LIMITS.receiptFrameBytes) throw new WorkerError('OUTPUT_LIMIT', 'Codex Pro result exceeded the complete receipt frame limit');
20
+ process.stdout.write(line);
21
+ }
22
+
23
+ /** Do not close stdin after the first line: parent death must cancel the run. */
24
+ function input() {
25
+ let bytes = 0, buffer = Buffer.alloc(0), admitted = false, resolveInput, rejectInput;
26
+ const promise = new Promise((resolve, reject) => { resolveInput = resolve; rejectInput = reject; });
27
+ const fail = (code, message) => {
28
+ if (finished) return;
29
+ inputFailure ??= new WorkerError(code, message);
30
+ clearTimeout(timer);
31
+ rejectInput(inputFailure);
32
+ controller.abort();
33
+ };
34
+ const timer = setTimeout(() => fail('INPUT_TIMEOUT', 'No complete start request arrived within 10 seconds'), LIMITS.admissionMs);
35
+ const onData = chunk => {
36
+ bytes += chunk.length;
37
+ if (bytes > LIMITS.requestBytes) { fail('INPUT_LIMIT', 'The request exceeded the 1 MiB input limit'); return; }
38
+ if (admitted) {
39
+ if (chunk.toString('utf8').trim() !== '') fail('INVALID_REQUEST', 'Only one start request is allowed');
40
+ return;
41
+ }
42
+ buffer = Buffer.concat([buffer, chunk]);
43
+ const newline = buffer.indexOf(10);
44
+ if (newline < 0) return;
45
+ admitted = true;
46
+ clearTimeout(timer);
47
+ if (buffer.subarray(newline + 1).toString('utf8').trim() !== '') { fail('INVALID_REQUEST', 'Only one start request is allowed'); return; }
48
+ try { resolveInput(JSON.parse(buffer.subarray(0, newline).toString('utf8'))); }
49
+ catch { fail('INVALID_REQUEST', 'The first line must contain one JSON request'); }
50
+ buffer = Buffer.alloc(0);
51
+ };
52
+ const onEnd = () => fail('PARENT_DISCONNECTED', 'The parent stdin lifeline closed before completion');
53
+ const onError = () => fail('INPUT_ERROR', 'The parent stdin lifeline failed');
54
+ const onAbort = () => { clearTimeout(timer); rejectInput(new WorkerError('CANCELLED', 'Codex Pro worker was cancelled before admission')); };
55
+ process.stdin.on('data', onData);
56
+ process.stdin.once('end', onEnd);
57
+ process.stdin.once('error', onError);
58
+ controller.signal.addEventListener('abort', onAbort, { once: true });
59
+ if (controller.signal.aborted) onAbort();
60
+ return { promise, stop() {
61
+ clearTimeout(timer);
62
+ process.stdin.off('data', onData); process.stdin.off('end', onEnd); process.stdin.off('error', onError);
63
+ controller.signal.removeEventListener('abort', onAbort);
64
+ process.stdin.pause();
65
+ process.stdin.destroy();
66
+ } };
67
+ }
68
+
69
+ let lifeline;
70
+ try {
71
+ const args = process.argv.slice(2);
72
+ if (args.length > 1 || (args.length === 1 && args[0] !== '--doctor')) throw new WorkerError('INVALID_REQUEST', 'Use flywheel-codex-pro-worker [--doctor]');
73
+ if (args[0] === '--doctor') {
74
+ await loadAdapter();
75
+ write(doctorResult());
76
+ process.exitCode = 0;
77
+ } else {
78
+ lifeline = input();
79
+ const value = await lifeline.promise;
80
+ if (typeof value?.requestId === 'string' && value.requestId.trim() !== '' && value.requestId.length <= 256) requestId = value.requestId;
81
+ const api = await loadAdapter();
82
+ if (controller.signal.aborted) throw inputFailure || new WorkerError('CANCELLED', 'Codex Pro worker was cancelled before the run');
83
+ const request = machineRequest(value, api, process.env);
84
+ requestId = request.requestId;
85
+ let progressBytes = 0;
86
+ const receipt = await api.runMachine(request, { signal: controller.signal, env: process.env, onProgress(text) {
87
+ if (controller.signal.aborted) return;
88
+ const frame = { protocol: 1, type: 'progress', requestId, text };
89
+ progressBytes += Buffer.byteLength(JSON.stringify(frame) + '\n');
90
+ if (progressBytes > LIMITS.progressBytes) {
91
+ outputFailure ??= new WorkerError('OUTPUT_LIMIT', 'Codex Pro progress exceeded the 1 MiB stream limit');
92
+ controller.abort();
93
+ } else write(frame);
94
+ } });
95
+ finished = true;
96
+ lifeline.stop();
97
+ const error = inputFailure || outputFailure;
98
+ // An output-limit error still allows a bounded terminal receipt. A broken
99
+ // stdout cannot, but the run has already settled and cleanup was awaited.
100
+ if (outputFailure?.code === 'OUTPUT_LIMIT') outputFailure = undefined;
101
+ write({ protocol: 1, type: 'result', requestId, adapter: ADAPTER, receipt,
102
+ error: error ? { code: error.code, message: error.message } : null });
103
+ process.exitCode = receipt.ok && !error && !controller.signal.aborted ? 0 : 1;
104
+ }
105
+ } catch (error) {
106
+ finished = true;
107
+ lifeline?.stop();
108
+ const known = error instanceof WorkerError ? error : new WorkerError('WORKER_ERROR', 'Codex Pro worker failed before a complete receipt was available');
109
+ write({ protocol: 1, type: 'result', requestId, adapter: ADAPTER, receipt: null,
110
+ error: { code: known.code, message: known.message } });
111
+ process.exitCode = 1;
112
+ } finally {
113
+ finished = true;
114
+ lifeline?.stop();
115
+ if (cancelledBy) process.exitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }[cancelledBy];
116
+ for (const [signal, handler] of handlers) process.off(signal, handler);
117
+ }
@@ -0,0 +1,128 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { directory, nonempty, record } from './protocol.js';
3
+
4
+ const actions = ['start', 'status', 'stop', 'config', 'doctor'];
5
+ const checkStatuses = ['pass', 'fail', 'warn', 'unknown', 'skipped'];
6
+
7
+ export async function validateControl(input, defaultCwd) {
8
+ if (!record(input) || !actions.includes(input.action)) {
9
+ throw new Error('action must be start, status, stop, config, or doctor');
10
+ }
11
+ if (Object.keys(input).some(key => !['action', 'taskDir', 'cwd', 'probe'].includes(key))) {
12
+ throw new Error('unknown Flywheel control argument');
13
+ }
14
+ if ('probe' in input && (input.action !== 'doctor' || typeof input.probe !== 'boolean')) {
15
+ throw new Error('probe must be a boolean and is only supported by doctor');
16
+ }
17
+ const needsTask = !['config', 'doctor'].includes(input.action);
18
+ return {
19
+ action: input.action,
20
+ taskDir: needsTask || 'taskDir' in input ? await directory(input.taskDir, 'taskDir') : '',
21
+ cwd: await directory(input.cwd ?? defaultCwd, 'cwd'),
22
+ ...(input.action === 'doctor' ? { probe: input.probe ?? false } : {}),
23
+ };
24
+ }
25
+
26
+ function validConfiguration(value) {
27
+ return record(value) && typeof value.source === 'string' && typeof value.scope === 'string' &&
28
+ Array.isArray(value.bindings) && value.bindings.every(binding => record(binding) &&
29
+ ['id', 'role', 'harness'].every(key =>
30
+ typeof binding[key] === 'string' && binding[key].trim() !== '') &&
31
+ ['route', 'profile', 'provider', 'channel', 'model', 'thinking', 'permissionMode'].every(key =>
32
+ binding[key] === undefined || typeof binding[key] === 'string') &&
33
+ ['residentSession', 'inScope'].every(key => binding[key] === undefined || typeof binding[key] === 'boolean'));
34
+ }
35
+
36
+ function validDiagnostic(value, request) {
37
+ return record(value) && value.kind === 'doctor' && typeof value.ok === 'boolean' &&
38
+ typeof value.cwd === 'string' && value.cwd.trim() !== '' && value.taskDir === request.taskDir &&
39
+ value.probeRequested === request.probe && value.inferenceTested === false &&
40
+ (value.configuration === undefined || validConfiguration(value.configuration)) &&
41
+ Array.isArray(value.checks) && value.checks.every(check => record(check) &&
42
+ typeof check.id === 'string' && check.id.trim() !== '' && checkStatuses.includes(check.status) &&
43
+ typeof check.summary === 'string' && check.summary.trim() !== '' &&
44
+ ['code', 'remedy'].every(key => check[key] === undefined || typeof check[key] === 'string'));
45
+ }
46
+
47
+ /** Only the engine executable can mutate engine state. No shell interpolation. */
48
+ export async function runControl(input, { binary = 'flywheel', defaultCwd, signal, timeoutMs = 60000 } = {}) {
49
+ nonempty(binary, 'flywheelBinary', 4096);
50
+ if (binary.startsWith('-')) throw new Error('flywheelBinary must name an executable');
51
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 120000) {
52
+ throw new Error('controlTimeoutMs must be between 100 and 120000');
53
+ }
54
+ const request = await validateControl(input, defaultCwd);
55
+ if (signal?.aborted) throw new Error('Flywheel control request cancelled');
56
+ // Diagnostic commands own watchdog child groups. Its TERM grace, pipe
57
+ // WaitDelay, and quiescence confirmation may each take 5s; keep the Go owner
58
+ // alive long enough to finish all three instead of orphaning its workers.
59
+ const killGraceMs = ['config', 'doctor'].includes(request.action) ? 20000 : 500;
60
+ const args = ['control', request.action, '--cwd', request.cwd];
61
+ if (request.taskDir) args.push('--task-dir', request.taskDir);
62
+ if (request.probe) args.push('--probe');
63
+ return new Promise((resolve, reject) => {
64
+ const child = spawn(binary, args, {
65
+ cwd: request.cwd, detached: false, stdio: ['ignore', 'pipe', 'pipe'],
66
+ });
67
+ let stdout = '';
68
+ let stderr = '';
69
+ let failure;
70
+ let killTimer;
71
+ const stop = error => {
72
+ if (failure) return;
73
+ failure = error;
74
+ clearTimeout(timer);
75
+ child.kill('SIGTERM');
76
+ killTimer = setTimeout(() => child.kill('SIGKILL'), killGraceMs);
77
+ };
78
+ const cancel = () => stop(new Error('Flywheel control request cancelled'));
79
+ const timer = setTimeout(() => stop(new Error('Flywheel control request timed out')), timeoutMs);
80
+ signal?.addEventListener('abort', cancel, { once: true });
81
+ // Decode across chunks so a split UTF-8 code point is not replaced in a
82
+ // Chinese diagnostic, model name, or path.
83
+ child.stdout.setEncoding('utf8');
84
+ child.stderr.setEncoding('utf8');
85
+ child.stdout.on('data', chunk => {
86
+ if (failure) return;
87
+ stdout += chunk;
88
+ if (Buffer.byteLength(stdout) > 1024 * 1024) stop(new Error('Flywheel control response exceeds 1 MiB'));
89
+ });
90
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-8192); });
91
+ child.on('error', error => { failure = error; });
92
+ child.on('close', (code, childSignal) => {
93
+ clearTimeout(timer);
94
+ clearTimeout(killTimer);
95
+ signal?.removeEventListener('abort', cancel);
96
+ if (failure) return reject(failure);
97
+ let response;
98
+ try { response = JSON.parse(stdout); } catch {
99
+ return reject(new Error(`Flywheel returned invalid JSON (exit ${code}): ${stderr.trim()}`));
100
+ }
101
+ if (!record(response) || response.protocol !== 1 || response.taskDir !== request.taskDir ||
102
+ typeof response.ok !== 'boolean') {
103
+ return reject(new Error('Flywheel control response has incompatible protocol or taskDir'));
104
+ }
105
+ if ((response.configuration !== undefined && !validConfiguration(response.configuration)) ||
106
+ (response.diagnostic !== undefined && !validDiagnostic(response.diagnostic, request))) {
107
+ return reject(new Error('Flywheel control returned a malformed configuration or diagnostic'));
108
+ }
109
+ if (request.action === 'doctor' && response.diagnostic !== undefined &&
110
+ response.diagnostic.ok !== response.ok) {
111
+ return reject(new Error('Flywheel doctor result disagrees with its control envelope'));
112
+ }
113
+ // A failed preflight is a usable report, unlike transport/protocol errors.
114
+ // Only the documented failure exit is allowed through this exception.
115
+ if (request.action === 'doctor' && code === 1 && !childSignal && response.ok === false &&
116
+ response.diagnostic?.ok === false) return resolve(response);
117
+ if (code !== 0 || childSignal || response.ok !== true) {
118
+ return reject(new Error(typeof response.error === 'string' ? response.error :
119
+ `Flywheel control failed (exit ${code}): ${stderr.trim()}`));
120
+ }
121
+ if ((request.action === 'config' && !response.configuration) ||
122
+ (request.action === 'doctor' && !response.diagnostic)) {
123
+ return reject(new Error('Flywheel control response is missing its requested report'));
124
+ }
125
+ resolve(response);
126
+ });
127
+ });
128
+ }
package/dist/index.js ADDED
@@ -0,0 +1,74 @@
1
+ import { runControl } from './control.js';
2
+ import { resolveFlywheelBinary } from './installation.js';
3
+
4
+ export const inject = ['commands', 'tools'];
5
+
6
+ export function apply(ctx, config = {}) {
7
+ // Loading the desktop package accidentally into the SDK profile must not
8
+ // give a worker a tool that launches another orchestrator.
9
+ if (process.env.FLYWHEEL_DSH_NATIVE_WORKER === '1') return;
10
+ const binary = resolveFlywheelBinary(config);
11
+ const active = new Set();
12
+ const control = async (input, agent, signal) => {
13
+ const controller = new AbortController();
14
+ const cancel = () => controller.abort();
15
+ signal?.addEventListener('abort', cancel, { once: true });
16
+ if (signal?.aborted) cancel();
17
+ active.add(controller);
18
+ try {
19
+ return await runControl(input, {
20
+ binary,
21
+ defaultCwd: agent?.session?.header?.cwd,
22
+ timeoutMs: config.controlTimeoutMs ?? 60000,
23
+ signal: controller.signal,
24
+ });
25
+ } finally {
26
+ active.delete(controller);
27
+ signal?.removeEventListener('abort', cancel);
28
+ }
29
+ };
30
+ ctx.on('dispose', () => { for (const controller of active) controller.abort(); });
31
+ ctx.commands.register({
32
+ name: 'flywheel',
33
+ description: '通过 Go 引擎控制 Flywheel 任务、查看实际模型配置或执行无推理环境检查。',
34
+ input: { hint: '{"action":"doctor"} 或 {"action":"status","taskDir":"/absolute/docs/task"}' },
35
+ async handler({ rawInput, agent, signal }) {
36
+ try {
37
+ const value = await control(JSON.parse(rawInput), agent, signal);
38
+ return { kind: 'success', text: JSON.stringify(value, null, 2) };
39
+ } catch (error) {
40
+ return { kind: 'error', text: error.message };
41
+ }
42
+ },
43
+ });
44
+ // ToolDefinition is the public raw registry format. The same boundary
45
+ // validates both this entry and the human slash command before spawning.
46
+ ctx.tools.register({
47
+ name: 'flywheel_control',
48
+ description: 'Control an existing Flywheel task via its deterministic Go engine. ' +
49
+ 'Use status to inspect progress, start to launch in the background, stop to await cancellation. ' +
50
+ 'Use config to inspect effective model bindings or doctor for a local preflight; these may omit taskDir. ' +
51
+ 'Only doctor accepts probe=true to check the SDK without sending an inference prompt. ' +
52
+ 'A successful doctor does not prove account authorization, quota, or model inference. ' +
53
+ 'This tool never writes state.json. Do not use it to create a recursive Flywheel run.',
54
+ parameters: {
55
+ type: 'object', additionalProperties: false,
56
+ properties: {
57
+ action: { type: 'string', enum: ['start', 'status', 'stop', 'config', 'doctor'] },
58
+ taskDir: { type: 'string', description: 'Absolute existing task ledger directory; required for start/status/stop' },
59
+ cwd: { type: 'string', description: 'Absolute target repository directory; defaults to this DSH session' },
60
+ probe: { type: 'boolean', description: 'Doctor only: explicitly enable SDK checks without inference; defaults to false' },
61
+ },
62
+ required: ['action'],
63
+ },
64
+ output: {
65
+ schema: { type: 'string' },
66
+ render: (_args, value) => [{ type: 'text', text: value }],
67
+ },
68
+ timeoutMs: config.controlTimeoutMs ?? 60000,
69
+ isConcurrencySafe: args => ['status', 'config', 'doctor'].includes(args.action),
70
+ async execute(args, execution) {
71
+ return JSON.stringify(await control(args, execution.agent, execution.signal));
72
+ },
73
+ });
74
+ }
@@ -0,0 +1,37 @@
1
+ import { accessSync, constants, lstatSync, readFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, join } from 'node:path';
5
+
6
+ // The npm installer owns this small receipt. A desktop host can find the exact
7
+ // launcher without relying on the login shell's PATH or editing a user patch.
8
+ export function resolveFlywheelBinary(config = {}, environment = process.env) {
9
+ if (config.flywheelBinary !== undefined) {
10
+ if (typeof config.flywheelBinary !== 'string' || !config.flywheelBinary.trim()) throw new Error('flywheelBinary must be a nonempty command or path');
11
+ return config.flywheelBinary;
12
+ }
13
+ const filename = join(environment.HOME || homedir(), '.config', 'flywheel', 'installation.json');
14
+ let receipt;
15
+ try {
16
+ const stat = lstatSync(filename);
17
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384) throw new Error('invalid installation record');
18
+ receipt = JSON.parse(readFileSync(filename, 'utf8'));
19
+ } catch (error) {
20
+ if (error.code === 'ENOENT') return 'flywheel';
21
+ throw new Error('Flywheel installation record is invalid; rerun flywheel setup or set flywheelBinary explicitly');
22
+ }
23
+ const launcher = receipt?.launcher;
24
+ if (receipt?.protocol !== 1 || receipt?.owner !== '@ckanner/flywheel' || typeof launcher?.path !== 'string'
25
+ || !isAbsolute(launcher.path) || !/^[a-f0-9]{64}$/.test(launcher.sha256)) {
26
+ throw new Error('Flywheel installation record has an unsupported contract; rerun flywheel setup');
27
+ }
28
+ try {
29
+ const stat = lstatSync(launcher.path);
30
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536) throw new Error('invalid launcher');
31
+ accessSync(launcher.path, constants.X_OK);
32
+ if (createHash('sha256').update(readFileSync(launcher.path)).digest('hex') !== launcher.sha256) throw new Error('launcher was modified');
33
+ } catch {
34
+ throw new Error('Flywheel launcher is missing or changed; preserve custom edits and rerun setup or set flywheelBinary explicitly');
35
+ }
36
+ return launcher.path;
37
+ }