@ckanner/flywheel 0.32.1 → 0.33.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.
Files changed (134) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +7 -0
  3. package/README.zh-CN.md +5 -0
  4. package/THIRD_PARTY_NOTICES.md +42 -0
  5. package/bin/runtime.js +25 -31
  6. package/cordis.patch.yml +5 -0
  7. package/dist/bridge.mjs +421 -0
  8. package/dist/claude-code-pro-bridge.mjs +82 -0
  9. package/dist/claude-code-pro-worker.mjs +5 -0
  10. package/dist/client.js +4140 -0
  11. package/dist/codex-pro-bridge.mjs +75 -0
  12. package/dist/codex-pro-worker.mjs +5 -0
  13. package/dist/console-read.mjs +51 -0
  14. package/dist/control.mjs +218 -0
  15. package/dist/index.mjs +185 -0
  16. package/dist/installation.mjs +56 -0
  17. package/dist/machine-worker.mjs +241 -0
  18. package/dist/policy.mjs +91 -0
  19. package/dist/prewarm.mjs +136 -0
  20. package/dist/protocol.mjs +260 -0
  21. package/dist/task-association.mjs +127 -0
  22. package/dist/worker.mjs +52 -0
  23. package/dist/workspaces.mjs +42 -0
  24. package/docs/README.md +1 -1
  25. package/docs/README.zh-CN.md +1 -1
  26. package/docs/design.md +8 -5
  27. package/docs/en/configuration.md +1 -1
  28. package/docs/en/contributing.md +5 -6
  29. package/docs/en/flow-console.md +2 -2
  30. package/docs/en/getting-started.md +9 -2
  31. package/docs/en/releasing.md +132 -174
  32. package/docs/validation/2026-11-13-release-0.32.1.md +83 -0
  33. package/docs/validation/2026-11-14-release-0.33.0.md +49 -0
  34. package/docs/validation/2026-11-14-single-package.md +60 -0
  35. package/docs/validation/README.md +3 -0
  36. package/docs/zh-CN/configuration.md +1 -1
  37. package/docs/zh-CN/contributing.md +4 -5
  38. package/docs/zh-CN/flow-console.md +2 -2
  39. package/docs/zh-CN/getting-started.md +7 -3
  40. package/docs/zh-CN/releasing.md +97 -81
  41. package/native/darwin-amd64/flywheel +0 -0
  42. package/native/darwin-arm64/flywheel +0 -0
  43. package/native/linux-amd64/flywheel +0 -0
  44. package/native/linux-arm64/flywheel +0 -0
  45. package/native/manifest.json +22 -0
  46. package/node_modules/dsh-subagent-codex-pro/CHANGELOG.md +403 -0
  47. package/node_modules/dsh-subagent-codex-pro/LICENSE +21 -0
  48. package/node_modules/dsh-subagent-codex-pro/README.md +557 -0
  49. package/node_modules/dsh-subagent-codex-pro/cordis.patch.yml +30 -0
  50. package/node_modules/dsh-subagent-codex-pro/docs/README.zh.md +355 -0
  51. package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle-validation.md +65 -0
  52. package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle.md +144 -0
  53. package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle.zh.md +98 -0
  54. package/node_modules/dsh-subagent-codex-pro/docs/machine.md +194 -0
  55. package/node_modules/dsh-subagent-codex-pro/docs/orchestration.zh.md +86 -0
  56. package/node_modules/dsh-subagent-codex-pro/lib/codex-exec.js +68 -0
  57. package/node_modules/dsh-subagent-codex-pro/lib/codex-exec.js.map +1 -0
  58. package/node_modules/dsh-subagent-codex-pro/lib/dsh-types.js +15 -0
  59. package/node_modules/dsh-subagent-codex-pro/lib/dsh-types.js.map +1 -0
  60. package/node_modules/dsh-subagent-codex-pro/lib/index.js +830 -0
  61. package/node_modules/dsh-subagent-codex-pro/lib/index.js.map +1 -0
  62. package/node_modules/dsh-subagent-codex-pro/lib/jobs.js +35 -0
  63. package/node_modules/dsh-subagent-codex-pro/lib/jobs.js.map +1 -0
  64. package/node_modules/dsh-subagent-codex-pro/lib/jsonrpc.js +229 -0
  65. package/node_modules/dsh-subagent-codex-pro/lib/jsonrpc.js.map +1 -0
  66. package/node_modules/dsh-subagent-codex-pro/lib/machine-cli.js +101 -0
  67. package/node_modules/dsh-subagent-codex-pro/lib/machine-cli.js.map +1 -0
  68. package/node_modules/dsh-subagent-codex-pro/lib/machine-local.js +122 -0
  69. package/node_modules/dsh-subagent-codex-pro/lib/machine-local.js.map +1 -0
  70. package/node_modules/dsh-subagent-codex-pro/lib/machine-recovery.js +167 -0
  71. package/node_modules/dsh-subagent-codex-pro/lib/machine-recovery.js.map +1 -0
  72. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervision.js +141 -0
  73. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervision.js.map +1 -0
  74. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervisor.js +128 -0
  75. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervisor.js.map +1 -0
  76. package/node_modules/dsh-subagent-codex-pro/lib/machine.js +104 -0
  77. package/node_modules/dsh-subagent-codex-pro/lib/machine.js.map +1 -0
  78. package/node_modules/dsh-subagent-codex-pro/lib/process-guardian.js +156 -0
  79. package/node_modules/dsh-subagent-codex-pro/lib/process-guardian.js.map +1 -0
  80. package/node_modules/dsh-subagent-codex-pro/lib/run.js +842 -0
  81. package/node_modules/dsh-subagent-codex-pro/lib/run.js.map +1 -0
  82. package/node_modules/dsh-subagent-codex-pro/lib/structured.js +329 -0
  83. package/node_modules/dsh-subagent-codex-pro/lib/structured.js.map +1 -0
  84. package/node_modules/dsh-subagent-codex-pro/lib/tool.js +295 -0
  85. package/node_modules/dsh-subagent-codex-pro/lib/tool.js.map +1 -0
  86. package/node_modules/dsh-subagent-codex-pro/lib/types/codex-exec.d.ts +88 -0
  87. package/node_modules/dsh-subagent-codex-pro/lib/types/codex-exec.d.ts.map +1 -0
  88. package/node_modules/dsh-subagent-codex-pro/lib/types/dsh-types.d.ts +179 -0
  89. package/node_modules/dsh-subagent-codex-pro/lib/types/dsh-types.d.ts.map +1 -0
  90. package/node_modules/dsh-subagent-codex-pro/lib/types/index.d.ts +266 -0
  91. package/node_modules/dsh-subagent-codex-pro/lib/types/index.d.ts.map +1 -0
  92. package/node_modules/dsh-subagent-codex-pro/lib/types/jobs.d.ts +34 -0
  93. package/node_modules/dsh-subagent-codex-pro/lib/types/jobs.d.ts.map +1 -0
  94. package/node_modules/dsh-subagent-codex-pro/lib/types/jsonrpc.d.ts +113 -0
  95. package/node_modules/dsh-subagent-codex-pro/lib/types/jsonrpc.d.ts.map +1 -0
  96. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-cli.d.ts +3 -0
  97. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-cli.d.ts.map +1 -0
  98. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-local.d.ts +4 -0
  99. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-local.d.ts.map +1 -0
  100. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-recovery.d.ts +53 -0
  101. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-recovery.d.ts.map +1 -0
  102. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervision.d.ts +8 -0
  103. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervision.d.ts.map +1 -0
  104. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervisor.d.ts +2 -0
  105. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervisor.d.ts.map +1 -0
  106. package/node_modules/dsh-subagent-codex-pro/lib/types/machine.d.ts +70 -0
  107. package/node_modules/dsh-subagent-codex-pro/lib/types/machine.d.ts.map +1 -0
  108. package/node_modules/dsh-subagent-codex-pro/lib/types/process-guardian.d.ts +22 -0
  109. package/node_modules/dsh-subagent-codex-pro/lib/types/process-guardian.d.ts.map +1 -0
  110. package/node_modules/dsh-subagent-codex-pro/lib/types/run.d.ts +386 -0
  111. package/node_modules/dsh-subagent-codex-pro/lib/types/run.d.ts.map +1 -0
  112. package/node_modules/dsh-subagent-codex-pro/lib/types/structured.d.ts +35 -0
  113. package/node_modules/dsh-subagent-codex-pro/lib/types/structured.d.ts.map +1 -0
  114. package/node_modules/dsh-subagent-codex-pro/lib/types/tool.d.ts +107 -0
  115. package/node_modules/dsh-subagent-codex-pro/lib/types/tool.d.ts.map +1 -0
  116. package/node_modules/dsh-subagent-codex-pro/lib/types/usage.d.ts +131 -0
  117. package/node_modules/dsh-subagent-codex-pro/lib/types/usage.d.ts.map +1 -0
  118. package/node_modules/dsh-subagent-codex-pro/lib/types/wire.d.ts +321 -0
  119. package/node_modules/dsh-subagent-codex-pro/lib/types/wire.d.ts.map +1 -0
  120. package/node_modules/dsh-subagent-codex-pro/lib/usage.js +356 -0
  121. package/node_modules/dsh-subagent-codex-pro/lib/usage.js.map +1 -0
  122. package/node_modules/dsh-subagent-codex-pro/lib/wire.js +986 -0
  123. package/node_modules/dsh-subagent-codex-pro/lib/wire.js.map +1 -0
  124. package/node_modules/dsh-subagent-codex-pro/package.json +76 -0
  125. package/npm/install.js +59 -6
  126. package/package.json +74 -12
  127. package/plugins/dsh/LICENSE +21 -0
  128. package/plugins/dsh/PROTOCOL.md +609 -0
  129. package/plugins/dsh/README.en.md +4 -0
  130. package/plugins/dsh/README.md +342 -0
  131. package/plugins/dsh/README.zh-CN.md +279 -0
  132. package/plugins/dsh/THIRD_PARTY_NOTICES.md +42 -0
  133. package/plugins/dsh/vendor/README.md +17 -0
  134. package/plugins/dsh/vendor/codex-pro.json +16 -0
@@ -0,0 +1,75 @@
1
+ // Public machine API consumer. Go remains the owner of scheduling and acceptance.
2
+ import { createRequire } from 'node:module';
3
+ import { isAbsolute } from 'node:path';
4
+
5
+ export const ADAPTER = Object.freeze({ package: 'dsh-subagent-codex-pro', version: '0.9.0', protocol: 1 });
6
+ export const LIMITS = Object.freeze({ requestBytes: 1024 * 1024, machineOutputBytes: 1024 * 1024,
7
+ progressBytes: 1024 * 1024, receiptFrameBytes: 8 * 1024 * 1024, admissionMs: 10000 });
8
+ const require = createRequire(import.meta.url);
9
+
10
+ export class WorkerError extends Error {
11
+ constructor(code, message) { super(message); this.name = 'WorkerError'; this.code = code; }
12
+ }
13
+
14
+ /** The doctor executes this same import, but never calls runMachine. */
15
+ export async function loadAdapter() {
16
+ const [major, minor] = process.versions.node.split('.').map(Number);
17
+ if (major < 22 || (major === 22 && minor < 18)) throw new WorkerError('UNSUPPORTED_RUNTIME', 'Codex Pro requires Node.js 22.18.0 or newer');
18
+ if (!['darwin', 'linux'].includes(process.platform)) throw new WorkerError('UNSUPPORTED_RUNTIME', 'Codex Pro requires a supported POSIX platform');
19
+ let manifest, api;
20
+ try {
21
+ manifest = require('dsh-subagent-codex-pro/package.json');
22
+ } catch {
23
+ throw new WorkerError('ADAPTER_UNAVAILABLE', 'Install the exact dsh-subagent-codex-pro@0.9.0 dependency with Flywheel');
24
+ }
25
+ if (manifest.name !== ADAPTER.package || manifest.version !== ADAPTER.version) {
26
+ throw new WorkerError('ADAPTER_MISMATCH', 'Codex Pro package identity or version does not match the pinned contract');
27
+ }
28
+ try { api = await import('dsh-subagent-codex-pro/machine'); }
29
+ catch { throw new WorkerError('ADAPTER_UNAVAILABLE', 'The pinned Codex Pro public machine API could not be imported'); }
30
+ if (api.MACHINE_PROTOCOL_VERSION !== ADAPTER.protocol ||
31
+ typeof api.parseMachineRequest !== 'function' || typeof api.runMachine !== 'function' || typeof api.MachineInputError !== 'function' ||
32
+ api.MACHINE_LIFECYCLE_VERSION !== 1 || typeof api.recoverMachine !== 'function' ||
33
+ api.MACHINE_LIFECYCLE_CAPABILITIES?.durableJournal !== true || api.MACHINE_LIFECYCLE_CAPABILITIES?.readOnlyRecovery !== true ||
34
+ api.MACHINE_LIFECYCLE_CAPABILITIES?.threadReadyBeforeDispatch !== true) {
35
+ throw new WorkerError('ADAPTER_MISMATCH', 'Codex Pro package identity, version or public machine API does not match the pinned contract');
36
+ }
37
+ return api;
38
+ }
39
+
40
+ /** Reject unsupported fields and permissions before any process or prompt starts. */
41
+ export function machineRequest(value, api, env) {
42
+ const lifecycle = value?.protocol === 3;
43
+ const persistent = value?.protocol === 2 || (lifecycle && value.threadMode === 'persistent');
44
+ const keys = new Set(['type', 'protocol', 'requestId', 'cwd', 'model', 'reasoningEffort', 'permissionMode', 'prompt', 'timeoutMs',
45
+ ...(persistent || lifecycle ? ['threadMode', 'threadId'] : []), ...(lifecycle ? ['recoveryDirectory'] : [])]);
46
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || value.type !== 'start' || ![1, 2, 3].includes(value.protocol) ||
47
+ Object.keys(value).some(key => !keys.has(key)) || typeof value.requestId !== 'string' || value.requestId.length > 256 ||
48
+ (lifecycle && (!['ephemeral', 'persistent'].includes(value.threadMode) ||
49
+ typeof value.recoveryDirectory !== 'string' || !isAbsolute(value.recoveryDirectory) ||
50
+ value.recoveryDirectory.length > 4096 || value.recoveryDirectory.includes('\0') || (!persistent && value.threadId !== undefined))) ||
51
+ (persistent && (value.threadMode !== 'persistent' || (value.threadId !== undefined &&
52
+ (typeof value.threadId !== 'string' || !value.threadId.trim() || value.threadId.includes('\0'))))) ||
53
+ !['read-only', 'workspace-write', 'danger-full-access'].includes(value.permissionMode)) {
54
+ throw new WorkerError('INVALID_REQUEST', 'Expected one versioned start request with explicit supported permissions and history; protocol 3 requires an absolute recovery directory');
55
+ }
56
+ if (value.timeoutMs !== undefined && (!Number.isSafeInteger(value.timeoutMs) || value.timeoutMs < 1 || value.timeoutMs > 2147483647)) {
57
+ throw new WorkerError('INVALID_REQUEST', 'timeoutMs must be a positive safe integer no greater than 2147483647');
58
+ }
59
+ try {
60
+ return api.parseMachineRequest({ protocol: 1, requestId: value.requestId, cwd: value.cwd, model: value.model,
61
+ effort: value.reasoningEffort,
62
+ permissionMode: value.permissionMode === 'danger-full-access' ? 'dangerously-bypass-approvals-and-sandbox' : value.permissionMode,
63
+ prompt: value.prompt,
64
+ ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }),
65
+ command: env.FLYWHEEL_CODEX_BIN || 'codex', threadMode: persistent ? 'persistent' : 'ephemeral',
66
+ ...(persistent && value.threadId !== undefined ? { threadId: value.threadId } : {}), maxOutputBytes: LIMITS.machineOutputBytes });
67
+ } catch (error) {
68
+ throw new WorkerError('INVALID_REQUEST', error instanceof api.MachineInputError ? error.message : 'Invalid Codex Pro machine request');
69
+ }
70
+ }
71
+
72
+ export function doctorResult() {
73
+ return { protocol: 1, type: 'doctor', package: ADAPTER.package, version: ADAPTER.version,
74
+ machineProtocol: ADAPTER.protocol, available: true, inferenceTested: false };
75
+ }
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import * as adapter from './codex-pro-bridge.mjs';
3
+ import { runMachineWorker } from './machine-worker.mjs';
4
+
5
+ await runMachineWorker({ ...adapter, label: 'Codex Pro', commandName: 'flywheel-codex-pro-worker' });
@@ -0,0 +1,51 @@
1
+ import { Service } from '@deepseek-ai/cordis';
2
+ import { Remote, bindTypertRemote } from '@deepseek-ai/dsh-typert-protocol';
3
+ import { realpath } from 'node:fs/promises';
4
+ import { ControlNotDispatchedError } from './control.mjs';
5
+ import { taskProjectionKey } from './task-association.mjs';
6
+
7
+ // A read-only Remote, separate from logged human commands. Never add mutations
8
+ // here: their durable command/tool receipts own replay and uncertain outcomes.
9
+ export const consoleReadActions = new Set(['runs', 'status', 'config', 'config-edit', 'metrics', 'history', 'node', 'delivery', 'doctor']);
10
+ const initializers = [];
11
+ export class ConsoleReadService extends Service {
12
+ constructor(ctx, control, failure) {
13
+ super(ctx, 'flywheelConsole');
14
+ this.typertRemote = bindTypertRemote(this, 'flywheelConsole');
15
+ this.control = control;
16
+ this.failure = failure;
17
+ for (const initialize of initializers) initialize.call(this);
18
+ }
19
+ async read(agent, input, signal) {
20
+ try {
21
+ if (!agent?.session || !consoleReadActions.has(input?.action)) throw new ControlNotDispatchedError('Only session-scoped console reads are supported');
22
+ // Taskless configuration uses the real session directory. The client may
23
+ // not turn a read endpoint into arbitrary cross-session task discovery.
24
+ const projection = this.ctx.sessionProjections.stateOf(agent.session, taskProjectionKey);
25
+ const catalog = async () => {
26
+ const value = await this.control({ action: 'runs' }, agent, signal);
27
+ return { ...value, runs: (value.runs ?? []).filter(run => run.hostSessionId === agent.session.id) };
28
+ };
29
+ let value;
30
+ if (input.action === 'runs') {
31
+ value = await catalog();
32
+ } else {
33
+ if (input.taskDir) {
34
+ const taskDir = await realpath(input.taskDir);
35
+ const cwd = await realpath(input.cwd ?? this.ctx.workingDirectory.get(agent.session));
36
+ const matches = task => task?.taskDir === taskDir && task.cwd === cwd;
37
+ if (!matches(projection?.view) && !(await catalog()).runs.some(matches)) throw new ControlNotDispatchedError('This task is not associated with the current conversation');
38
+ } else if (input.cwd != null && await realpath(input.cwd) !== await realpath(this.ctx.workingDirectory.get(agent.session))) {
39
+ throw new ControlNotDispatchedError('Configuration must belong to the current conversation workspace');
40
+ }
41
+ value = await this.control(input, agent, signal);
42
+ }
43
+ return { result: { kind: 'success', text: JSON.stringify(value) } };
44
+ } catch (error) {
45
+ return { result: { kind: 'error', text: JSON.stringify(this.failure(input, error)) } };
46
+ }
47
+ }
48
+ }
49
+ // Invoke the public standard decorator in plain JS (no compiler/private symbols).
50
+ Remote(ConsoleReadService.prototype.read, { kind: 'method', name: 'read', static: false, private: false,
51
+ addInitializer: initialize => initializers.push(initialize) });
@@ -0,0 +1,218 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { directory, nonempty, record, validInvestigationCommand } from './protocol.mjs';
3
+
4
+ const actions = ['investigate', 'launch', 'start', 'status', 'stop', 'config', 'doctor', 'step', 'config-edit', 'config-save', 'runs', 'metrics', 'session', 'history', 'delivery', 'deliver', 'node'];
5
+ const checkStatuses = ['pass', 'fail', 'warn', 'unknown', 'skipped'];
6
+
7
+ // This marker is issued only where the host can prove that no control process
8
+ // started. An engine/transport error after spawn must never acquire it.
9
+ export class ControlNotDispatchedError extends Error {
10
+ constructor(cause) {
11
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
12
+ this.name = 'ControlNotDispatchedError';
13
+ }
14
+ }
15
+
16
+ export async function validateControl(input, defaultCwd) {
17
+ if (!record(input) || !actions.includes(input.action)) {
18
+ throw new Error('action must be launch, start, step, status, stop, config, config-edit, config-save, or doctor');
19
+ }
20
+ if (Object.keys(input).some(key => !['action', 'taskDir', 'cwd', 'probe', 'mode', 'only', 'packet', 'revision', 'retryReason', 'editor', 'session', 'sessionKey', 'delivery', 'runId', 'investigation'].includes(key))) {
21
+ throw new Error('unknown Flywheel control argument');
22
+ }
23
+ if (input.action === 'investigate' ? !validInvestigationCommand(input.investigation) : 'investigation' in input) throw new Error('investigate requires a typed, revision-bound preparation command');
24
+ if (input.investigation && Buffer.byteLength(JSON.stringify(input.investigation)) > 131072) throw new Error('investigation input exceeds 128 KiB');
25
+ if ('probe' in input && (input.action !== 'doctor' || typeof input.probe !== 'boolean')) {
26
+ throw new Error('probe must be a boolean and is only supported by doctor');
27
+ }
28
+ if ('mode' in input && (!['launch', 'start'].includes(input.action) || !['auto', 'quick', 'full'].includes(input.mode))) {
29
+ throw new Error('mode must be auto, quick, or full and is only supported by launch/start');
30
+ }
31
+ if (input.action === 'session' ? !record(input.session) || !['claim', 'send', 'return', 'reconcile', 'release'].includes(input.session.action) || typeof input.session.key !== 'string' || !Number.isSafeInteger(input.session.revision) || input.session.revision < 1 : 'session' in input) throw new Error('session requires a typed revision-bound command');
32
+ if (input.action === 'history' ? typeof input.sessionKey !== 'string' || !input.sessionKey : 'sessionKey' in input) throw new Error('history requires an exact session key');
33
+ if (input.session && Buffer.byteLength(JSON.stringify(input.session)) > 131072) throw new Error('session command too large');
34
+ if (['delivery','deliver'].includes(input.action) ? !record(input.delivery) || (input.action === 'delivery' ? input.delivery.action !== 'inspect' : !['create','merge'].includes(input.delivery.action) || input.delivery.confirmed !== true) : 'delivery' in input) throw new Error('delivery writes require explicit confirmation');
35
+ if (input.delivery && Buffer.byteLength(JSON.stringify(input.delivery)) > 131072) throw new Error('delivery request too large');
36
+ if ('runId' in input && (input.action !== 'metrics' || typeof input.runId !== 'string' || !input.runId || input.runId.length > 128 || /[\0\r\n]/.test(input.runId))) throw new Error('runId requires metrics and an exact engine ID');
37
+ const editing = ['config-edit', 'config-save'].includes(input.action);
38
+ if (editing ? !record(input.editor) || !['global', 'project'].includes(input.editor.scope) : 'editor' in input) {
39
+ throw new Error('editor requires config-edit/config-save and an explicit global/project scope');
40
+ }
41
+ if (input.action === 'step') {
42
+ if (!['plan', 'execute', 'review', 'verify', 'integrate', 'final'].includes(input.only) || !Number.isSafeInteger(input.revision) || input.revision < 1 ||
43
+ (input.packet !== undefined && (typeof input.packet !== 'string' || !/^M\d+-T\d+$/.test(input.packet)))) {
44
+ throw new Error('step requires a valid stage, state revision and optional packet id');
45
+ }
46
+ } else if (input.action === 'node') {
47
+ if (typeof input.packet !== 'string' || !/^M\d+-T\d+$/.test(input.packet) || 'only' in input || 'revision' in input) throw new Error('node requires an exact packet ID');
48
+ } else if (['only', 'packet', 'revision'].some(key => key in input)) {
49
+ throw new Error('stage arguments require step');
50
+ }
51
+ if ('retryReason' in input && (input.action !== 'step' || input.only !== 'execute' || typeof input.retryReason !== 'string' || !input.retryReason.trim() || Buffer.byteLength(input.retryReason) > 1000)) throw new Error('retryReason requires Execute and a bounded explanation');
52
+ if (editing && Buffer.byteLength(JSON.stringify(input.editor)) > 1024 * 1024) throw new Error('configuration exceeds 1 MiB');
53
+ const needsTask = !['config', 'doctor', 'config-edit', 'config-save', 'runs'].includes(input.action);
54
+ return {
55
+ action: input.action,
56
+ ...(input.investigation ? { investigation: input.investigation } : {}),
57
+ ...(input.action === 'node' ? { packet: input.packet } : {}),
58
+ ...(input.runId ? { runId: input.runId } : {}),
59
+ ...(input.delivery ? { delivery: input.delivery } : {}),
60
+ ...(input.session ? { session: input.session } : {}),
61
+ ...(input.sessionKey ? { sessionKey: input.sessionKey } : {}),
62
+ taskDir: needsTask || 'taskDir' in input ? await directory(input.taskDir, 'taskDir') : '',
63
+ cwd: await directory(input.cwd ?? defaultCwd, 'cwd'),
64
+ ...(input.action === 'doctor' ? { probe: input.probe ?? false } : {}),
65
+ ...(input.action === 'launch' || 'mode' in input ? { mode: input.mode ?? 'auto' } : {}),
66
+ ...(input.action === 'step' ? { only: input.only, packet: input.packet, revision: input.revision, ...(input.retryReason ? { retryReason: input.retryReason } : {}) } : {}),
67
+ ...(editing ? { editor: input.editor } : {}),
68
+ };
69
+ }
70
+
71
+ function validConfiguration(value) {
72
+ return record(value) && typeof value.source === 'string' && typeof value.scope === 'string' &&
73
+ Array.isArray(value.bindings) && value.bindings.every(binding => record(binding) &&
74
+ ['id', 'role', 'harness'].every(key =>
75
+ typeof binding[key] === 'string' && binding[key].trim() !== '') &&
76
+ ['route', 'profile', 'provider', 'channel', 'model', 'thinking', 'permissionMode'].every(key =>
77
+ binding[key] === undefined || typeof binding[key] === 'string') &&
78
+ ['residentSession', 'inScope'].every(key => binding[key] === undefined || typeof binding[key] === 'boolean'));
79
+ }
80
+
81
+ function validDiagnostic(value, request) {
82
+ return record(value) && value.kind === 'doctor' && typeof value.ok === 'boolean' &&
83
+ typeof value.cwd === 'string' && value.cwd.trim() !== '' && value.taskDir === request.taskDir &&
84
+ value.probeRequested === request.probe && value.inferenceTested === false &&
85
+ (value.configuration === undefined || validConfiguration(value.configuration)) &&
86
+ Array.isArray(value.checks) && value.checks.every(check => record(check) &&
87
+ typeof check.id === 'string' && check.id.trim() !== '' && checkStatuses.includes(check.status) &&
88
+ typeof check.summary === 'string' && check.summary.trim() !== '' &&
89
+ ['code', 'remedy'].every(key => check[key] === undefined || typeof check[key] === 'string'));
90
+ }
91
+
92
+ /** Only the engine executable can mutate engine state. No shell interpolation. */
93
+ export async function runControl(input, { binary = 'flywheel', defaultCwd, hostSessionID, signal, timeoutMs = 60000 } = {}) {
94
+ let request;
95
+ try {
96
+ nonempty(binary, 'flywheelBinary', 4096);
97
+ if (binary.startsWith('-')) throw new Error('flywheelBinary must name an executable');
98
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 120000) {
99
+ throw new Error('controlTimeoutMs must be between 100 and 120000');
100
+ }
101
+ request = await validateControl(input, defaultCwd);
102
+ if (signal?.aborted) throw new Error('Flywheel control request cancelled');
103
+ } catch (error) {
104
+ throw new ControlNotDispatchedError(error);
105
+ }
106
+ // Diagnostic commands own watchdog child groups. Its TERM grace, pipe
107
+ // WaitDelay, and quiescence confirmation may each take 5s; keep the Go owner
108
+ // alive long enough to finish all three instead of orphaning its workers.
109
+ const killGraceMs = ['config', 'doctor', 'config-edit', 'config-save', 'delivery', 'deliver'].includes(request.action) ? 20000 : 500;
110
+ const args = ['control', request.action, '--cwd', request.cwd, '--compact'];
111
+ if (request.taskDir) args.push('--task-dir', request.taskDir);
112
+ if (request.probe) args.push('--probe');
113
+ if (request.mode) args.push('--mode', request.mode);
114
+ if (request.only) args.push('--only', request.only, '--expect-revision', String(request.revision));
115
+ if (request.packet) args.push('--packet', request.packet);
116
+ if (request.runId) args.push('--run-id', request.runId);
117
+ if (request.sessionKey) args.push('--session-key', request.sessionKey);
118
+ if (request.retryReason) args.push('--retry-reason', request.retryReason);
119
+ const env = { ...process.env };
120
+ delete env.DSH_SESSION_ID;
121
+ if (typeof hostSessionID === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(hostSessionID)) env.DSH_SESSION_ID = hostSessionID;
122
+ return new Promise((resolve, reject) => {
123
+ let child;
124
+ try {
125
+ child = spawn(binary, args, {
126
+ cwd: request.cwd, env, detached: false, stdio: [request.editor || request.session || request.delivery || request.investigation ? 'pipe' : 'ignore', 'pipe', 'pipe'],
127
+ });
128
+ } catch (error) {
129
+ reject(new ControlNotDispatchedError(error));
130
+ return;
131
+ }
132
+ let spawned = false;
133
+ child.once('spawn', () => { spawned = true; });
134
+ let stdout = '';
135
+ let stderr = '';
136
+ let failure;
137
+ let killTimer;
138
+ const stop = error => {
139
+ if (failure) return;
140
+ failure = error;
141
+ clearTimeout(timer);
142
+ child.kill('SIGTERM');
143
+ killTimer = setTimeout(() => child.kill('SIGKILL'), killGraceMs);
144
+ };
145
+ const cancel = () => stop(new Error('Flywheel control request cancelled'));
146
+ const timer = setTimeout(() => stop(new Error('Flywheel control request timed out')), timeoutMs);
147
+ if (request.editor || request.session || request.delivery || request.investigation) {
148
+ child.stdin.on('error', error => stop(error));
149
+ child.stdin.end(JSON.stringify(request.editor ?? request.session ?? request.delivery ?? request.investigation));
150
+ }
151
+ signal?.addEventListener('abort', cancel, { once: true });
152
+ // Decode across chunks so a split UTF-8 code point is not replaced in a
153
+ // Chinese diagnostic, model name, or path.
154
+ child.stdout.setEncoding('utf8');
155
+ child.stderr.setEncoding('utf8');
156
+ child.stdout.on('data', chunk => {
157
+ if (failure) return;
158
+ stdout += chunk;
159
+ if (Buffer.byteLength(stdout) > 1024 * 1024) stop(new Error('Flywheel control response exceeds 1 MiB'));
160
+ });
161
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-8192); });
162
+ child.on('error', error => {
163
+ // Node emits spawn before other events after successful process creation.
164
+ // Require both the missing spawn event and missing PID for positive proof.
165
+ failure = !spawned && child.pid === undefined ? new ControlNotDispatchedError(error) : error;
166
+ });
167
+ child.on('close', (code, childSignal) => {
168
+ clearTimeout(timer);
169
+ clearTimeout(killTimer);
170
+ signal?.removeEventListener('abort', cancel);
171
+ if (failure) return reject(failure);
172
+ let response;
173
+ try { response = JSON.parse(stdout); } catch {
174
+ return reject(new Error(`Flywheel returned invalid JSON (exit ${code}): ${stderr.trim()}`));
175
+ }
176
+ if (!record(response) || response.protocol !== 1 || response.taskDir !== request.taskDir ||
177
+ (response.cwd !== undefined && response.cwd !== request.cwd) ||
178
+ typeof response.ok !== 'boolean') {
179
+ return reject(new Error('Flywheel control response has incompatible protocol, taskDir, or cwd'));
180
+ }
181
+ if ((response.configuration !== undefined && !validConfiguration(response.configuration)) ||
182
+ (response.diagnostic !== undefined && !validDiagnostic(response.diagnostic, request))) {
183
+ return reject(new Error('Flywheel control returned a malformed configuration or diagnostic'));
184
+ }
185
+ if (request.action === 'doctor' && response.diagnostic !== undefined &&
186
+ response.diagnostic.ok !== response.ok) {
187
+ return reject(new Error('Flywheel doctor result disagrees with its control envelope'));
188
+ }
189
+ // A failed preflight is a usable report, unlike transport/protocol errors.
190
+ // Only the documented failure exit is allowed through this exception.
191
+ if (request.action === 'doctor' && code === 1 && !childSignal && response.ok === false &&
192
+ response.diagnostic?.ok === false) return resolve(response);
193
+ if (code !== 0 || childSignal || response.ok !== true) {
194
+ return reject(new Error(typeof response.error === 'string' ? response.error :
195
+ `Flywheel control failed (exit ${code}): ${stderr.trim()}`));
196
+ }
197
+ // After a repair handoff the implementation ledger owns status. This
198
+ // read-only subcommand may return that ledger; mutations still require
199
+ // their preparation receipt and cannot be confirmed by unrelated state.
200
+ const investigationStatusAfterHandoff = request.investigation?.action === 'status' && response.preparation === undefined &&
201
+ record(response.state) && Number.isSafeInteger(response.state.revision) && response.state.revision > 0 &&
202
+ typeof response.state.status === 'string' && response.state.status !== '' && record(response.state.packets);
203
+ if ((request.action === 'investigate' && !investigationStatusAfterHandoff && (!record(response.preparation) || response.preparation.version !== 1 || !Number.isSafeInteger(response.preparation.revision) || response.preparation.revision < 1 || !record(response.preparation.contract) || !Array.isArray(response.preparation.observations))) ||
204
+ (request.action === 'node' && (!record(response.node) || response.node.id !== request.packet || !record(response.node.packet))) ||
205
+ (['delivery','deliver'].includes(request.action) && (!record(response.delivery) || !Array.isArray(response.delivery.pulls))) ||
206
+ (request.action === 'history' && (!record(response.history) || response.history.key !== request.sessionKey)) ||
207
+ (request.action === 'runs' && !Array.isArray(response.runs)) ||
208
+ (request.action === 'metrics' && (!record(response.metrics) || !Array.isArray(response.metrics.groups))) ||
209
+ (request.action === 'config' && !response.configuration) ||
210
+ (request.action === 'doctor' && !response.diagnostic) ||
211
+ (request.editor && (!record(response.editor) || response.editor.scope !== request.editor.scope ||
212
+ typeof response.editor.revision !== 'string' || !record(response.editor.values) || !record(response.editor.defaults)))) {
213
+ return reject(new Error('Flywheel control response is missing its requested report'));
214
+ }
215
+ resolve(response);
216
+ });
217
+ });
218
+ }
package/dist/index.mjs ADDED
@@ -0,0 +1,185 @@
1
+ import { reconcileWorkspaces, watchWorkspaces } from './workspaces.mjs';
2
+ import { ControlNotDispatchedError, runControl } from './control.mjs';
3
+ import { resolveFlywheelBinary } from './installation.mjs';
4
+ import { mutatesTask, taskAssociationProjection, taskProjectionKey } from './task-association.mjs';
5
+
6
+ export const inject = ['commands', 'tools', 'workingDirectory', 'sessionProjections'];
7
+
8
+ export function apply(ctx, config = {}) {
9
+ // Loading the desktop package accidentally into the SDK profile must not
10
+ // give a worker a tool that launches another orchestrator.
11
+ if (process.env.FLYWHEEL_DSH_NATIVE_WORKER === '1') return;
12
+ ctx.sessionProjections.register(taskAssociationProjection);
13
+ watchWorkspaces(ctx);
14
+ const binary = resolveFlywheelBinary(config);
15
+ const active = new Set();
16
+ const sessionMutations = new WeakMap();
17
+ const failure = (input, error) => ({
18
+ protocol: 1, type: 'flywheel-control-error',
19
+ action: typeof input?.action === 'string' ? input.action : null,
20
+ dispatch: error instanceof ControlNotDispatchedError ? 'not-dispatched' : 'unknown',
21
+ message: error instanceof Error ? error.message : String(error),
22
+ });
23
+ const control = async (input, agent, signal, operationId) => {
24
+ const session = agent?.session;
25
+ const mutation = mutatesTask(input?.action);
26
+ if (session && (mutation || input?.action === 'status')) {
27
+ try {
28
+ const projection = ctx.sessionProjections.stateOf(session, taskProjectionKey);
29
+ if (!projection) throw new Error('Flywheel task association is unavailable for this session');
30
+ const unresolved = projection.view;
31
+ if (sessionMutations.has(session) || (mutation && unresolved &&
32
+ unresolved.outcome !== 'confirmed' && unresolved.operationId !== operationId)) {
33
+ throw new Error('A Flywheel mutation is still unconfirmed. Wait for it to finish, then query status for its original task before another launch/start/stop.');
34
+ }
35
+ // The projection can recover an omitted cwd only from the immutable
36
+ // header or a logged directory change. An external getter fallback
37
+ // cannot bind a missing response to the original project after reload.
38
+ if (mutation && input?.cwd == null && (typeof projection.cwd !== 'string' ||
39
+ !projection.cwd.startsWith('/') || /[\0\r\n]/.test(projection.cwd))) {
40
+ throw new Error('This session has no recorded project directory. Supply an explicit absolute cwd for launch/start/stop so the original task can be associated and reconciled.');
41
+ }
42
+ } catch (error) {
43
+ throw new ControlNotDispatchedError(error);
44
+ }
45
+ }
46
+ const controller = new AbortController();
47
+ if (session && mutation) sessionMutations.set(session, controller);
48
+ const cancel = () => controller.abort();
49
+ signal?.addEventListener('abort', cancel, { once: true });
50
+ if (signal?.aborted) cancel();
51
+ active.add(controller);
52
+ try {
53
+ let defaultCwd;
54
+ try {
55
+ // The immutable session header records its original project. Read the
56
+ // public current-directory projection without ensure() recovery effects.
57
+ if (input?.cwd == null && agent?.session) {
58
+ defaultCwd = ctx.workingDirectory.get(agent.session);
59
+ }
60
+ } catch (error) {
61
+ // No control process has been launched if resolving the default fails.
62
+ throw new ControlNotDispatchedError(error);
63
+ }
64
+ const response = await runControl(input, {
65
+ binary,
66
+ defaultCwd,
67
+ hostSessionID: session?.id,
68
+ timeoutMs: config.controlTimeoutMs ?? 60000,
69
+ signal: controller.signal,
70
+ });
71
+ try { await reconcileWorkspaces(ctx, response); }
72
+ catch (error) {
73
+ // Association failure does not undo a successful engine mutation.
74
+ response.workspaceWarnings = ['工作区归属未同步:' + error.message];
75
+ }
76
+ return response;
77
+ } finally {
78
+ if (session && sessionMutations.get(session) === controller) sessionMutations.delete(session);
79
+ active.delete(controller);
80
+ signal?.removeEventListener('abort', cancel);
81
+ }
82
+ };
83
+ ctx.on('dispose', () => { for (const controller of active) controller.abort(); });
84
+ // Optional injection keeps command-only SDK/legacy fixtures independent.
85
+ ctx.inject?.(['typert'], async scope => {
86
+ // Host peers remain optional for CLI-only imports and native workers.
87
+ const { ConsoleReadService } = await import('./console-read.mjs');
88
+ new ConsoleReadService(scope, control, failure);
89
+ });
90
+ ctx.commands.register({
91
+ name: 'flywheel',
92
+ description: '通过 Go 引擎控制 Flywheel 任务、查看实际模型配置或执行无推理环境检查。',
93
+ input: { hint: '{"action":"launch","taskDir":"/absolute/docs/task","mode":"auto"} 或 {"action":"status","taskDir":"/absolute/docs/task"}' },
94
+ async handler({ rawInput, agent, signal, commandId }) {
95
+ let input;
96
+ try {
97
+ try {
98
+ input = JSON.parse(rawInput);
99
+ } catch (error) {
100
+ throw new ControlNotDispatchedError(error);
101
+ }
102
+ const value = await control(input, agent, signal, 'command:' + commandId);
103
+ return { kind: 'success', text: JSON.stringify(value, null, 2) };
104
+ } catch (error) {
105
+ // CommandResult preserves only kind/text. Wrap every failure, including
106
+ // unknown outcomes, so a child error's text cannot impersonate host proof.
107
+ return { kind: 'error', text: JSON.stringify(failure(input, error), null, 2) };
108
+ }
109
+ },
110
+ });
111
+ const toolFailures = new WeakMap();
112
+ const executeTool = async (args, execution) => {
113
+ try {
114
+ return JSON.stringify(await control(args, execution.agent, execution.signal, 'tool:' + execution.callId));
115
+ } catch (error) {
116
+ toolFailures.set(execution, failure(args, error));
117
+ throw error;
118
+ }
119
+ };
120
+ // finalizeContent is the public last-mile ToolDefinition hook. Framework
121
+ // failures remain isError outcomes, with host-owned dispatch proof available
122
+ // to the durable projection instead of brittle matching of error prose.
123
+ const finalizeContent = (execution, result) => {
124
+ const known = toolFailures.get(execution);
125
+ toolFailures.delete(execution);
126
+ if (!result.isError) return;
127
+ const action = execution.name === 'flywheel_launch' ? 'launch' : execution.arguments?.action;
128
+ const value = known ?? failure({ action }, new Error(result.error?.message ?? 'Flywheel tool did not confirm its result'));
129
+ if (result.error?.info?.code === 'ABORTED_BEFORE_DISPATCH') value.dispatch = 'not-dispatched';
130
+ return [{ type: 'text', text: JSON.stringify(value) }];
131
+ };
132
+ const taskProperties = {
133
+ taskDir: { type: 'string', description: 'Absolute existing prepared task ledger directory; never infer a task by scanning directories' },
134
+ cwd: { type: 'string', description: 'Absolute target repository directory; defaults to the current working directory of this DSH session. Launch/start/stop require it explicitly if the session has no recorded directory.' },
135
+ mode: { type: 'string', enum: ['auto', 'quick', 'full'], description: 'Launch/start only. Launch defaults to auto; omitted on legacy start preserves its existing behavior. The Go engine persists and explains the effective mode.' },
136
+ };
137
+ ctx.tools.register({
138
+ name: 'flywheel_launch',
139
+ description: 'Launch one prepared Flywheel task with its deterministic Go engine and associate its exact directory with this DSH conversation’s Flywheel view. ' +
140
+ 'Prepare and validate the frozen contract first; this tool does not invent requirements or write state.json. ' +
141
+ 'Default mode auto lets the engine choose Quick or Full and explain why. Explicit quick/full selects that request. ' +
142
+ 'If the result is unconfirmed, query flywheel_control status for the same task before repeating any mutation. Do not launch recursively from a Flywheel worker.',
143
+ parameters: { type: 'object', additionalProperties: false, properties: taskProperties, required: ['taskDir'] },
144
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
145
+ timeoutMs: config.controlTimeoutMs ?? 60000,
146
+ isConcurrencySafe: () => false,
147
+ execute: (args, execution) => executeTool({ ...args, action: 'launch' }, execution),
148
+ finalizeContent,
149
+ });
150
+ // ToolDefinition is the public raw registry format. The same boundary
151
+ // validates both this entry and the human slash command before spawning.
152
+ ctx.tools.register({
153
+ name: 'flywheel_control',
154
+ description: 'Control an existing Flywheel task via its deterministic Go engine. ' +
155
+ 'Use investigate with a versioned preparation contract to gather bounded evidence; run starts the Go investigation owner in the background. Report-only investigation never authorizes implementation. Use launch for a prepared implementation task (Auto by default), status to inspect progress, start to resume, stop to await cancellation. ' +
156
+ 'Launch/start associates the exact task with the current conversation’s Flywheel view. After an unconfirmed mutation, query the same task before another mutation. ' +
157
+ 'Use config to inspect effective model bindings or doctor for a local preflight; these may omit taskDir. ' +
158
+ 'Only doctor accepts probe=true to check the SDK without sending an inference prompt. ' +
159
+ 'A successful doctor does not prove account authorization, quota, or model inference. ' +
160
+ 'This tool never writes state.json. Do not use it to create a recursive Flywheel run.',
161
+ parameters: {
162
+ type: 'object', additionalProperties: false,
163
+ properties: {
164
+ action: { type: 'string', enum: ['investigate', 'launch', 'start', 'status', 'stop', 'config', 'doctor'] },
165
+ ...taskProperties,
166
+ investigation: { type: 'object', additionalProperties: false, properties: {
167
+ action: { type: 'string', enum: ['start', 'run', 'status', 'report', 'cancel'] },
168
+ revision: { type: 'integer', minimum: 1 },
169
+ contract: { type: 'object', description: 'Exact version-1 preparation.schema.json contract; use flywheel template to read the installed schema. Store no credentials.' },
170
+ report: { type: 'object', description: 'Evidence-cited investigation report; references must name recorded invocation IDs. This never marks code completed.' },
171
+ }, required: ['action'] },
172
+ probe: { type: 'boolean', description: 'Doctor only: explicitly enable SDK checks without inference; defaults to false' },
173
+ },
174
+ required: ['action'],
175
+ },
176
+ output: {
177
+ schema: { type: 'string' },
178
+ render: (_args, value) => [{ type: 'text', text: value }],
179
+ },
180
+ timeoutMs: config.controlTimeoutMs ?? 60000,
181
+ isConcurrencySafe: args => ['status', 'config', 'doctor'].includes(args.action),
182
+ execute: executeTool,
183
+ finalizeContent,
184
+ });
185
+ }
@@ -0,0 +1,56 @@
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
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const ownPackage = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
8
+
9
+ function bundledLauncher() {
10
+ // The private source harness has no complete distribution. Installed packages
11
+ // own their CLI and must never silently borrow a different global version.
12
+ const root = new URL('../', import.meta.url);
13
+ if (ownPackage.private === true) return 'flywheel';
14
+ const launcher = fileURLToPath(new URL('bin/flywheel.js', root));
15
+ if (ownPackage.name !== '@ckanner/flywheel') throw new Error('Invalid bundled Flywheel package identity');
16
+ const stat = lstatSync(launcher);
17
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Bundled Flywheel launcher is missing or invalid; reinstall the package');
18
+ accessSync(launcher, constants.X_OK);
19
+ return launcher;
20
+ }
21
+
22
+ // The npm installer owns this small receipt. A desktop host can find the exact
23
+ // launcher without relying on the login shell's PATH or editing a user patch.
24
+ export function resolveFlywheelBinary(config = {}, environment = process.env) {
25
+ if (config.flywheelBinary !== undefined) {
26
+ if (typeof config.flywheelBinary !== 'string' || !config.flywheelBinary.trim()) throw new Error('flywheelBinary must be a nonempty command or path');
27
+ return config.flywheelBinary;
28
+ }
29
+ const filename = join(environment.HOME || homedir(), '.config', 'flywheel', 'installation.json');
30
+ let receipt;
31
+ try {
32
+ const stat = lstatSync(filename);
33
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384) throw new Error('invalid installation record');
34
+ receipt = JSON.parse(readFileSync(filename, 'utf8'));
35
+ } catch (error) {
36
+ if (error.code === 'ENOENT') return bundledLauncher();
37
+ throw new Error('Flywheel installation record is invalid; rerun flywheel setup or set flywheelBinary explicitly');
38
+ }
39
+ const launcher = receipt?.launcher;
40
+ if (receipt?.protocol !== 1 || receipt?.owner !== '@ckanner/flywheel' || typeof launcher?.path !== 'string'
41
+ || !isAbsolute(launcher.path) || !/^[a-f0-9]{64}$/.test(launcher.sha256)) {
42
+ throw new Error('Flywheel installation record has an unsupported contract; rerun flywheel setup');
43
+ }
44
+ try {
45
+ const stat = lstatSync(launcher.path);
46
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536) throw new Error('invalid launcher');
47
+ accessSync(launcher.path, constants.X_OK);
48
+ if (createHash('sha256').update(readFileSync(launcher.path)).digest('hex') !== launcher.sha256) throw new Error('launcher was modified');
49
+ } catch {
50
+ throw new Error('Flywheel launcher is missing or changed; preserve custom edits and rerun setup or set flywheelBinary explicitly');
51
+ }
52
+ if (ownPackage.private !== true && receipt.version !== ownPackage.version) {
53
+ throw new Error('Flywheel launcher version does not match this package; rerun flywheel setup for this version');
54
+ }
55
+ return launcher.path;
56
+ }