@ckanner/flywheel 0.32.1 → 0.33.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +10 -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 +4078 -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 +7 -4
  27. package/docs/en/configuration.md +1 -1
  28. package/docs/en/contributing.md +5 -6
  29. package/docs/en/flow-console.md +1 -1
  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-single-package.md +60 -0
  34. package/docs/validation/README.md +2 -0
  35. package/docs/zh-CN/configuration.md +1 -1
  36. package/docs/zh-CN/contributing.md +4 -5
  37. package/docs/zh-CN/flow-console.md +1 -1
  38. package/docs/zh-CN/getting-started.md +7 -3
  39. package/docs/zh-CN/releasing.md +97 -81
  40. package/native/darwin-amd64/flywheel +0 -0
  41. package/native/darwin-arm64/flywheel +0 -0
  42. package/native/linux-amd64/flywheel +0 -0
  43. package/native/linux-arm64/flywheel +0 -0
  44. package/native/manifest.json +22 -0
  45. package/node_modules/dsh-subagent-codex-pro/CHANGELOG.md +403 -0
  46. package/node_modules/dsh-subagent-codex-pro/LICENSE +21 -0
  47. package/node_modules/dsh-subagent-codex-pro/README.md +557 -0
  48. package/node_modules/dsh-subagent-codex-pro/cordis.patch.yml +30 -0
  49. package/node_modules/dsh-subagent-codex-pro/docs/README.zh.md +355 -0
  50. package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle-validation.md +65 -0
  51. package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle.md +144 -0
  52. package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle.zh.md +98 -0
  53. package/node_modules/dsh-subagent-codex-pro/docs/machine.md +194 -0
  54. package/node_modules/dsh-subagent-codex-pro/docs/orchestration.zh.md +86 -0
  55. package/node_modules/dsh-subagent-codex-pro/lib/codex-exec.js +68 -0
  56. package/node_modules/dsh-subagent-codex-pro/lib/codex-exec.js.map +1 -0
  57. package/node_modules/dsh-subagent-codex-pro/lib/dsh-types.js +15 -0
  58. package/node_modules/dsh-subagent-codex-pro/lib/dsh-types.js.map +1 -0
  59. package/node_modules/dsh-subagent-codex-pro/lib/index.js +830 -0
  60. package/node_modules/dsh-subagent-codex-pro/lib/index.js.map +1 -0
  61. package/node_modules/dsh-subagent-codex-pro/lib/jobs.js +35 -0
  62. package/node_modules/dsh-subagent-codex-pro/lib/jobs.js.map +1 -0
  63. package/node_modules/dsh-subagent-codex-pro/lib/jsonrpc.js +229 -0
  64. package/node_modules/dsh-subagent-codex-pro/lib/jsonrpc.js.map +1 -0
  65. package/node_modules/dsh-subagent-codex-pro/lib/machine-cli.js +101 -0
  66. package/node_modules/dsh-subagent-codex-pro/lib/machine-cli.js.map +1 -0
  67. package/node_modules/dsh-subagent-codex-pro/lib/machine-local.js +122 -0
  68. package/node_modules/dsh-subagent-codex-pro/lib/machine-local.js.map +1 -0
  69. package/node_modules/dsh-subagent-codex-pro/lib/machine-recovery.js +167 -0
  70. package/node_modules/dsh-subagent-codex-pro/lib/machine-recovery.js.map +1 -0
  71. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervision.js +141 -0
  72. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervision.js.map +1 -0
  73. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervisor.js +128 -0
  74. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervisor.js.map +1 -0
  75. package/node_modules/dsh-subagent-codex-pro/lib/machine.js +104 -0
  76. package/node_modules/dsh-subagent-codex-pro/lib/machine.js.map +1 -0
  77. package/node_modules/dsh-subagent-codex-pro/lib/process-guardian.js +156 -0
  78. package/node_modules/dsh-subagent-codex-pro/lib/process-guardian.js.map +1 -0
  79. package/node_modules/dsh-subagent-codex-pro/lib/run.js +842 -0
  80. package/node_modules/dsh-subagent-codex-pro/lib/run.js.map +1 -0
  81. package/node_modules/dsh-subagent-codex-pro/lib/structured.js +329 -0
  82. package/node_modules/dsh-subagent-codex-pro/lib/structured.js.map +1 -0
  83. package/node_modules/dsh-subagent-codex-pro/lib/tool.js +295 -0
  84. package/node_modules/dsh-subagent-codex-pro/lib/tool.js.map +1 -0
  85. package/node_modules/dsh-subagent-codex-pro/lib/types/codex-exec.d.ts +88 -0
  86. package/node_modules/dsh-subagent-codex-pro/lib/types/codex-exec.d.ts.map +1 -0
  87. package/node_modules/dsh-subagent-codex-pro/lib/types/dsh-types.d.ts +179 -0
  88. package/node_modules/dsh-subagent-codex-pro/lib/types/dsh-types.d.ts.map +1 -0
  89. package/node_modules/dsh-subagent-codex-pro/lib/types/index.d.ts +266 -0
  90. package/node_modules/dsh-subagent-codex-pro/lib/types/index.d.ts.map +1 -0
  91. package/node_modules/dsh-subagent-codex-pro/lib/types/jobs.d.ts +34 -0
  92. package/node_modules/dsh-subagent-codex-pro/lib/types/jobs.d.ts.map +1 -0
  93. package/node_modules/dsh-subagent-codex-pro/lib/types/jsonrpc.d.ts +113 -0
  94. package/node_modules/dsh-subagent-codex-pro/lib/types/jsonrpc.d.ts.map +1 -0
  95. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-cli.d.ts +3 -0
  96. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-cli.d.ts.map +1 -0
  97. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-local.d.ts +4 -0
  98. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-local.d.ts.map +1 -0
  99. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-recovery.d.ts +53 -0
  100. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-recovery.d.ts.map +1 -0
  101. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervision.d.ts +8 -0
  102. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervision.d.ts.map +1 -0
  103. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervisor.d.ts +2 -0
  104. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervisor.d.ts.map +1 -0
  105. package/node_modules/dsh-subagent-codex-pro/lib/types/machine.d.ts +70 -0
  106. package/node_modules/dsh-subagent-codex-pro/lib/types/machine.d.ts.map +1 -0
  107. package/node_modules/dsh-subagent-codex-pro/lib/types/process-guardian.d.ts +22 -0
  108. package/node_modules/dsh-subagent-codex-pro/lib/types/process-guardian.d.ts.map +1 -0
  109. package/node_modules/dsh-subagent-codex-pro/lib/types/run.d.ts +386 -0
  110. package/node_modules/dsh-subagent-codex-pro/lib/types/run.d.ts.map +1 -0
  111. package/node_modules/dsh-subagent-codex-pro/lib/types/structured.d.ts +35 -0
  112. package/node_modules/dsh-subagent-codex-pro/lib/types/structured.d.ts.map +1 -0
  113. package/node_modules/dsh-subagent-codex-pro/lib/types/tool.d.ts +107 -0
  114. package/node_modules/dsh-subagent-codex-pro/lib/types/tool.d.ts.map +1 -0
  115. package/node_modules/dsh-subagent-codex-pro/lib/types/usage.d.ts +131 -0
  116. package/node_modules/dsh-subagent-codex-pro/lib/types/usage.d.ts.map +1 -0
  117. package/node_modules/dsh-subagent-codex-pro/lib/types/wire.d.ts +321 -0
  118. package/node_modules/dsh-subagent-codex-pro/lib/types/wire.d.ts.map +1 -0
  119. package/node_modules/dsh-subagent-codex-pro/lib/usage.js +356 -0
  120. package/node_modules/dsh-subagent-codex-pro/lib/usage.js.map +1 -0
  121. package/node_modules/dsh-subagent-codex-pro/lib/wire.js +986 -0
  122. package/node_modules/dsh-subagent-codex-pro/lib/wire.js.map +1 -0
  123. package/node_modules/dsh-subagent-codex-pro/package.json +76 -0
  124. package/npm/install.js +59 -6
  125. package/package.json +74 -12
  126. package/plugins/dsh/LICENSE +21 -0
  127. package/plugins/dsh/PROTOCOL.md +607 -0
  128. package/plugins/dsh/README.en.md +4 -0
  129. package/plugins/dsh/README.md +342 -0
  130. package/plugins/dsh/README.zh-CN.md +279 -0
  131. package/plugins/dsh/THIRD_PARTY_NOTICES.md +42 -0
  132. package/plugins/dsh/vendor/README.md +17 -0
  133. package/plugins/dsh/vendor/codex-pro.json +16 -0
package/CHANGELOG.md CHANGED
@@ -5,7 +5,16 @@ results are recorded separately in the [release runbook](docs/en/releasing.md)
5
5
  and [validation index](docs/validation/README.md). Historical entries retain
6
6
  their original language.
7
7
 
8
- ## Unreleased
8
+ ## 0.33.0 — One complete npm distribution
9
+
10
+ - Consolidate the CLI, DSH host/client, workers, embedded skills and four native
11
+ engines into one `@ckanner/flywheel` npm tarball. Resolve and verify bundled
12
+ native files locally; do not download or borrow platform packages at runtime.
13
+ - Migrate the old desktop package through the official DSH CLI, verifying the
14
+ replacement before removal and retaining explicit rollback/cancellation state.
15
+ Keep user configuration, modified skills and task histories intact.
16
+ - Publish one immutable candidate through OIDC, test its registry installation,
17
+ then promote its single latest tag. Historical split packages remain unchanged.
9
18
 
10
19
  ## 0.32.1 — DSH compatible-series upgrades
11
20
 
package/README.md CHANGED
@@ -32,6 +32,13 @@ retries, budgets, workspaces, and delivery. Use it from the DeepSeek Harness
32
32
  - **Makes failures visible.** Unknown execution, usage, or cleanup stays
33
33
  unknown. The engine preserves work and stops when it cannot safely proceed.
34
34
 
35
+ From 0.33, `@ckanner/flywheel` is one complete package: CLI, DSH view/workers,
36
+ embedded skills and four platform engines. No separate platform package or first-run
37
+ engine download is needed. When upgrading from 0.32.x, quit DSH before running the
38
+ installer; it replaces the old desktop package registration and preserves your
39
+ configuration and histories. Reload DSH after setup. Existing old npm versions
40
+ remain available for historical installations.
41
+
35
42
  ## Install
36
43
 
37
44
  For the desktop path, install Node.js **22.18.0 or later**, npm, Git, and a
package/README.zh-CN.md CHANGED
@@ -8,6 +8,11 @@ Flywheel 是面向 coding agent 的确定性 Go 编排器。模型负责规划
8
8
 
9
9
  [快速开始](docs/zh-CN/getting-started.md) · [文档](docs/README.zh-CN.md) · [参与贡献](docs/zh-CN/contributing.md) · [MIT 许可证](LICENSE)
10
10
 
11
+ 从 0.33 起,`@ckanner/flywheel` 一个包包含 CLI、DSH 界面/worker、内嵌技能和
12
+ 四个平台引擎,不再需要独立平台包或首次运行下载引擎。从 0.32.x 升级前先退出 DSH,
13
+ 再运行原来的安装命令;安装器替换旧桌面包登记,保留配置和任务历史,完成后重载 DSH。
14
+ 历史 npm 版本继续保留。
15
+
11
16
  ## Flywheel 能做什么
12
17
 
13
18
  - **根据任务选择流程。** Auto 为满足条件、边界明确的任务选择 Quick,需要规划或更广泛验收时选择 Full。
@@ -0,0 +1,42 @@
1
+ # Third-party notices
2
+
3
+ ## DeepSeek Harness UI patterns
4
+
5
+ Flywheel's plugin-owned Button, Input, and ongoing StateDot treatments are
6
+ adapted from DeepSeek Harness at commit
7
+ `b830ec950faba0a46dda564be3e2d854afed94f1`:
8
+
9
+ - `packages/client/ui-primitives/src/Button.tsx` and its stylesheet
10
+ - `packages/client/ui-primitives/src/Input.tsx` and its stylesheet
11
+ - `packages/client/ui-primitives/src/StateDot.tsx` and its stylesheet
12
+
13
+ [Upstream source](https://github.com/deepseek-ai/deepseek-harness/tree/b830ec950faba0a46dda564be3e2d854afed94f1/packages/client/ui-primitives/src)
14
+
15
+ These adapted portions retain the upstream MIT license below. Flywheel's own
16
+ source is also licensed under MIT; see [LICENSE](LICENSE).
17
+
18
+ MIT License
19
+
20
+ Copyright (c) 2026 DeepSeek
21
+
22
+ Permission is hereby granted, free of charge, to any person obtaining a copy
23
+ of this software and associated documentation files (the "Software"), to deal
24
+ in the Software without restriction, including without limitation the rights
25
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
26
+ copies of the Software, and to permit persons to whom the Software is
27
+ furnished to do so, subject to the following conditions:
28
+
29
+ The above copyright notice and this permission notice shall be included in all
30
+ copies or substantial portions of the Software.
31
+
32
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38
+ SOFTWARE.
39
+
40
+ ## Bundled Codex Pro public package
41
+
42
+ This distribution bundles `dsh-subagent-codex-pro@0.9.0`, Copyright (c) 2026 ckanner, under the MIT License. Its complete license is retained at `node_modules/dsh-subagent-codex-pro/LICENSE` inside the complete package. Source identity and tarball integrity are recorded in `plugins/dsh/vendor/README.md` and `plugins/dsh/vendor/codex-pro.json` in the source repository. The bundled package is the upstream public machine API implementation, including its compiled supervisor; it contains no native Codex executable or user credentials.
package/bin/runtime.js CHANGED
@@ -8,26 +8,16 @@ const platforms = { darwin: "darwin", linux: "linux" };
8
8
  const architectures = { arm64: "arm64", x64: "amd64" };
9
9
  const forwardedSignals = ["SIGTERM", "SIGINT", "SIGHUP"];
10
10
  const ownPackage = require("../package.json");
11
+ const fs = require("node:fs");
12
+ const path = require("node:path");
13
+ const crypto = require("node:crypto");
11
14
 
12
- function packageEntry(name, entry, expectedVersion, missingMessage) {
13
- let installed;
14
- let entryPath;
15
- try {
16
- installed = require(`${name}/package.json`);
17
- entryPath = require.resolve(`${name}/${entry}`);
18
- } catch {
19
- throw new Error(missingMessage);
20
- }
21
- // npm may legitimately hoist dependencies. Check the resolved package's
22
- // identity, rather than assuming a particular machine's node_modules path.
23
- // A partial installation must not pick up an incompatible parent package.
24
- if (installed.name !== name) {
25
- throw new Error(`package identity mismatch for ${name}: found ${installed.name}; reinstall @ckanner/flywheel`);
26
- }
27
- if (typeof expectedVersion !== "string" || installed.version !== expectedVersion) {
28
- throw new Error(`version mismatch for ${name}: expected ${expectedVersion}, found ${installed.version}; reinstall @ckanner/flywheel`);
29
- }
30
- return entryPath;
15
+ function packageFile(relative) {
16
+ if (ownPackage.name !== '@ckanner/flywheel') throw new Error('Flywheel package identity mismatch');
17
+ const filename = path.resolve(__dirname, '..', relative);
18
+ const stat = fs.lstatSync(filename, { throwIfNoEntry: false });
19
+ if (!stat?.isFile() || stat.isSymbolicLink()) throw new Error(`Bundled ${relative} is missing or invalid; reinstall @ckanner/flywheel`);
20
+ return filename;
31
21
  }
32
22
 
33
23
  function loadNativePath() {
@@ -36,27 +26,31 @@ function loadNativePath() {
36
26
  if (!platform || !arch) {
37
27
  throw new Error(`unsupported platform ${process.platform}/${process.arch} (supported: darwin/linux × amd64/arm64)`);
38
28
  }
39
- const name = `@ckanner/flywheel-${platform}-${arch}`;
40
- return packageEntry(name, "flywheel", ownPackage.optionalDependencies?.[name],
41
- `platform package ${name} is missing; reinstall @ckanner/flywheel with optional dependencies enabled`);
29
+ const key = `${platform}-${arch}`;
30
+ const manifest = JSON.parse(fs.readFileSync(packageFile('native/manifest.json'), 'utf8'));
31
+ if (manifest.protocol !== 1 || manifest.version !== ownPackage.version) {
32
+ throw new Error('Bundled engine version mismatch; reinstall @ckanner/flywheel');
33
+ }
34
+ const entry = manifest.platforms?.[key];
35
+ const filename = packageFile(`native/${key}/flywheel`);
36
+ const bytes = fs.readFileSync(filename);
37
+ if (!entry || entry.bytes !== bytes.length || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '') ||
38
+ crypto.createHash('sha256').update(bytes).digest('hex') !== entry.sha256) {
39
+ throw new Error(`Bundled engine integrity mismatch for ${key}; reinstall @ckanner/flywheel`);
40
+ }
41
+ return filename;
42
42
  }
43
43
 
44
44
  function loadWorkerPath() {
45
- const name = "@ckanner/dsh-plugin-flywheel";
46
- return packageEntry(name, "worker", ownPackage.dependencies?.[name],
47
- "the matching @ckanner/dsh-plugin-flywheel worker is missing; reinstall @ckanner/flywheel");
45
+ return packageFile('dist/worker.mjs');
48
46
  }
49
47
 
50
48
  function loadCodexProWorkerPath() {
51
- const name = "@ckanner/dsh-plugin-flywheel";
52
- return packageEntry(name, "codex-pro-worker", ownPackage.dependencies?.[name],
53
- "the matching @ckanner/dsh-plugin-flywheel Codex Pro worker is missing; reinstall @ckanner/flywheel");
49
+ return packageFile('dist/codex-pro-worker.mjs');
54
50
  }
55
51
 
56
52
  function loadClaudeCodeProWorkerPath() {
57
- const name = "@ckanner/dsh-plugin-flywheel";
58
- return packageEntry(name, "claude-code-pro-worker", ownPackage.dependencies?.[name],
59
- "the matching @ckanner/dsh-plugin-flywheel Claude Code Pro worker is missing; reinstall @ckanner/flywheel");
53
+ return packageFile('dist/claude-code-pro-worker.mjs');
60
54
  }
61
55
 
62
56
  function assertSupportedNode(version = process.versions.node) {
@@ -0,0 +1,5 @@
1
+ - insert:
2
+ - id: flywheel
3
+ name: '@ckanner/flywheel'
4
+ config:
5
+ controlTimeoutMs: 60000
@@ -0,0 +1,421 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { validatePrepare, matchWarmStart, writeWarmReceipt } from './prewarm.mjs';
8
+ import { BridgeError, MAX_FRAME_BYTES, normalizedError, readPolicyReceipt, record, SESSION_PROTOCOL, SessionFold, validateStart } from './protocol.mjs';
9
+
10
+ export function makeOverlay(request, policyPath = fileURLToPath(new URL('./policy.mjs', import.meta.url)), admission = {}, historyRoot) {
11
+ // Each patch replaces an entire config row. Pin the checked rc.5/rc.1 enforcement
12
+ // implementations, not the user's mutable permission dropdown defaults.
13
+ const disable = ['permission', 'tool-plugin-manager', 'tool-working-directory',
14
+ 'tool-subagent', 'tool-subagent-fork', 'tool-subagent-control', 'tool-subagent-list-agents',
15
+ 'subagent-spawn-in-process', 'subagent-fork-in-process', 'tool-workflow',
16
+ 'tool-goal', 'goal-round-driver', 'schedule', 'tool-schedule',
17
+ 'session-title-llm', 'tool-web', 'compaction-basic', 'command-compact', 'hmr'];
18
+ return [
19
+ ...disable.map(id => ({ id, disabled: true })),
20
+ ...(historyRoot ? [{ id: 'session-persistence-jsonl', config: { root: historyRoot } }] : []),
21
+ { id: 'sandbox', name: '@deepseek-ai/dsh-sandbox-local', disabled: false, config: {} },
22
+ { id: 'sandbox-policy', name: '@deepseek-ai/dsh-sandbox-policy', disabled: false,
23
+ config: { mode: request.permissionMode, workspaceRoot: request.cwd } },
24
+ { id: 'bash-sandbox', name: '@deepseek-ai/dsh-bash-sandbox', disabled: false,
25
+ config: { timeoutMs: 60000, graceMs: 100 } },
26
+ { id: 'fs-sandbox', name: '@deepseek-ai/dsh-fs-sandbox', disabled: false, config: {} },
27
+ { id: 'approval', name: '@deepseek-ai/dsh-user-approval', disabled: false, config: { policy: 'never' } },
28
+ { id: 'sdk-jsonrpc-server', config: { maxTokensAsSuccess: false } },
29
+ { insert: [{ id: 'flywheel-worker-policy', name: policyPath,
30
+ config: { permissionMode: request.permissionMode, cwd: request.cwd,
31
+ ...(request.workspaceCwd ? { workspaceCwd: request.workspaceCwd } : {}),
32
+ ...(!request.sessionId && request.sessionTitle ? { sessionTitle: request.sessionTitle } : {}), ...admission } }] },
33
+ ];
34
+ }
35
+
36
+ async function bounded(promise, ms) {
37
+ let timer;
38
+ try {
39
+ return await Promise.race([promise, new Promise(resolve => { timer = setTimeout(resolve, ms); })]);
40
+ } finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+
45
+ async function abortable(promise, signal) {
46
+ signal.throwIfAborted();
47
+ let listener;
48
+ try {
49
+ return await Promise.race([promise, new Promise((_, reject) => {
50
+ listener = () => reject(signal.reason);
51
+ signal.addEventListener('abort', listener, { once: true });
52
+ })]);
53
+ } finally {
54
+ signal.removeEventListener('abort', listener);
55
+ }
56
+ }
57
+
58
+ /** A bounded JSON-RPC transport. It does not own a new process group. */
59
+ export class Runtime {
60
+ constructor(bin, args, options, notify) {
61
+ this.nextId = 0;
62
+ this.pending = new Map();
63
+ this.buffer = Buffer.alloc(0);
64
+ this.failed = null;
65
+ this.protocolFailure = null;
66
+ this.shutdownAcknowledged = false;
67
+ this.exit = null;
68
+ this.closed = false;
69
+ this.child = spawn(bin, args, { ...options, detached: false, stdio: ['pipe', 'pipe', 'pipe'] });
70
+ this.exited = new Promise(resolve => { this.resolveExit = resolve; });
71
+ this.child.on('error', error => {
72
+ this.fail(new BridgeError('TRANSPORT', `cannot start DSH: ${error.message}`));
73
+ if (this.child.pid === undefined) {
74
+ this.exit = { code: null, signal: null };
75
+ this.resolveExit(this.exit);
76
+ }
77
+ });
78
+ this.child.on('exit', (code, signal) => {
79
+ this.exit = { code, signal };
80
+ });
81
+ this.child.on('close', (code, signal) => {
82
+ this.closed = true;
83
+ if (this.buffer.length) {
84
+ this.protocolFailure ??= new BridgeError('TRANSPORT', 'DSH stdout ended in an incomplete frame');
85
+ this.fail(this.protocolFailure);
86
+ }
87
+ this.fail(new BridgeError('TRANSPORT', `DSH transport closed (exit=${code}, signal=${signal ?? 'none'})`));
88
+ this.resolveExit(this.exit ?? { code, signal });
89
+ });
90
+ this.child.stdin.on('error', error => this.fail(new BridgeError('TRANSPORT', `DSH stdin: ${error.message}`)));
91
+ this.child.stderr.on('data', chunk => {
92
+ // Logs stay separate from the engine's protocol. No prompt/credential dump.
93
+ options.stderr?.(chunk);
94
+ });
95
+ this.child.stdout.on('data', chunk => {
96
+ try {
97
+ this.buffer = Buffer.concat([this.buffer, chunk]);
98
+ let newline;
99
+ while ((newline = this.buffer.indexOf(10)) !== -1) {
100
+ if (newline > MAX_FRAME_BYTES) throw new BridgeError('TRANSPORT', 'DSH frame exceeds 8 MiB');
101
+ const line = this.buffer.subarray(0, newline).toString('utf8');
102
+ this.buffer = this.buffer.subarray(newline + 1);
103
+ if (line.trim() === '') continue;
104
+ const message = JSON.parse(line);
105
+ if (!record(message) || message.jsonrpc !== '2.0') {
106
+ throw new BridgeError('TRANSPORT', 'DSH stdout is not JSON-RPC 2.0');
107
+ }
108
+ if (Object.hasOwn(message, 'id')) {
109
+ if (message.id === 'flywheel-shutdown') {
110
+ this.shutdownAcknowledged = !message.error && Object.hasOwn(message, 'result');
111
+ }
112
+ const waiting = this.pending.get(message.id);
113
+ if (!waiting) continue; // Includes the best-effort shutdown acknowledgement.
114
+ this.pending.delete(message.id);
115
+ if (message.error) {
116
+ waiting.reject(new BridgeError('TRANSPORT', `${waiting.method}: ${message.error.message ?? 'JSON-RPC error'}`));
117
+ } else if (Object.hasOwn(message, 'result')) waiting.resolve(message.result);
118
+ else waiting.reject(new BridgeError('TRANSPORT', 'DSH response has no result or error'));
119
+ } else if (typeof message.method === 'string') notify(message.method, message.params);
120
+ else throw new BridgeError('TRANSPORT', 'malformed DSH notification');
121
+ }
122
+ if (this.buffer.length > MAX_FRAME_BYTES) throw new BridgeError('TRANSPORT', 'DSH frame exceeds 8 MiB');
123
+ } catch (error) {
124
+ this.protocolFailure = error instanceof BridgeError ? error : new BridgeError('TRANSPORT', `invalid DSH frame: ${error.message}`);
125
+ this.buffer = Buffer.alloc(0);
126
+ this.fail(this.protocolFailure);
127
+ }
128
+ });
129
+ }
130
+
131
+ fail(error) {
132
+ this.failed ??= error;
133
+ for (const waiter of this.pending.values()) waiter.reject(this.failed);
134
+ this.pending.clear();
135
+ }
136
+
137
+ request(method, params) {
138
+ if (this.failed) return Promise.reject(this.failed);
139
+ if (this.exit) return Promise.reject(new BridgeError('TRANSPORT', 'DSH already exited'));
140
+ const id = ++this.nextId;
141
+ return new Promise((resolve, reject) => {
142
+ this.pending.set(id, { resolve, reject, method });
143
+ this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
144
+ });
145
+ }
146
+
147
+ async close() {
148
+ // The Go watchdog owns the whole inherited process group. We request the
149
+ // SDK's child-first drain and bound the leader wait; Go performs the final
150
+ // group sweep even on normal exit. A child must not detach/setsid.
151
+ let forced = false;
152
+ if (!this.exit && !this.child.stdin.destroyed) {
153
+ this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 'flywheel-shutdown', method: 'shutdown', params: {} }) + '\n');
154
+ // SDK shutdown includes persistent history flush and plugin disposal.
155
+ // Keep this bounded below Go's default 5s cancellation grace, while
156
+ // allowing a normal fsync to complete under concurrent local workloads.
157
+ await bounded(this.exited, 2000);
158
+ }
159
+ if (!this.exit) {
160
+ forced = true;
161
+ this.child.stdin.end();
162
+ this.child.kill('SIGTERM');
163
+ await bounded(this.exited, 250);
164
+ }
165
+ if (!this.exit) {
166
+ this.child.kill('SIGKILL');
167
+ await bounded(this.exited, 350);
168
+ }
169
+ if (!this.exit) throw new BridgeError('TRANSPORT', 'DSH leader did not exit during bounded shutdown');
170
+ if (!this.closed && this.child.pid !== undefined) {
171
+ await bounded(this.exited, 350);
172
+ if (!this.closed) throw new BridgeError('TRANSPORT', 'DSH exited but its protocol streams did not reach quiescence');
173
+ }
174
+ return { ...this.exit, forced, graceful: this.shutdownAcknowledged, spawned: this.child.pid !== undefined };
175
+ }
176
+ }
177
+
178
+ export async function runWorker(input, { emit, env = process.env, signal, doctor = false, stderr, prewarm } = {}) {
179
+ let request;
180
+ let prepared;
181
+ let formalStarted = false;
182
+ let warmClosed = false;
183
+ let runtime;
184
+ let temp;
185
+ let sessionId;
186
+ let fold;
187
+ let outcome;
188
+ let failure;
189
+ let cancelled = false;
190
+ let settled = true;
191
+ let readySent = false;
192
+ let resumeAccepted = false;
193
+ let resumeUnconfirmed = false;
194
+ let dispatch = 'not-dispatched';
195
+ let promptId = '';
196
+ let waited = false;
197
+ const beforeReady = [];
198
+ let deadline;
199
+ let started = Date.now();
200
+ const controller = new AbortController();
201
+ const cancel = () => {
202
+ cancelled = true;
203
+ controller.abort(new BridgeError('TRANSPORT', 'worker cancelled'));
204
+ };
205
+ const abort = () => runtime?.fail(controller.signal.reason);
206
+ controller.signal.addEventListener('abort', abort);
207
+ signal?.addEventListener('abort', cancel, { once: true });
208
+ const frame = value => emit({ protocol: request?.protocol ?? (input?.protocol === 2 ? 2 : 1), requestId: request?.requestId ?? input?.requestId ?? 'invalid-request', ...value });
209
+ try {
210
+ const budget = Number.isSafeInteger(input?.timeoutMs) && input.timeoutMs > 0 && input.timeoutMs <= 86400000
211
+ ? input.timeoutMs : 30 * 60 * 1000;
212
+ deadline = setTimeout(() => controller.abort(new BridgeError('TIMEOUT', 'DSH worker lifecycle deadline exceeded')), budget);
213
+ if (signal?.aborted) cancel();
214
+ // Validate owned cleanup metadata even if an early close cancelled startup.
215
+ // This never creates a Session and lets EOF/close publish no-dispatch proof.
216
+ request = prewarm ? await validatePrepare(input) : await abortable(validateStart(input), controller.signal);
217
+ if (prewarm) {
218
+ prepared = request;
219
+ clearTimeout(deadline);
220
+ deadline = setTimeout(() => controller.abort(new BridgeError('TIMEOUT', 'SDK prewarm expired before dispatch')), request.ttlMs);
221
+ }
222
+ if (doctor && request.protocol === 2) throw new BridgeError('INVALID_REQUEST', 'doctor does not create or resume persistent histories');
223
+ controller.signal.throwIfAborted();
224
+ const bin = env.FLYWHEEL_DSH_BIN || 'dsh';
225
+ if (bin.includes('\0') || bin.startsWith('-')) throw new BridgeError('INVALID_REQUEST', 'invalid FLYWHEEL_DSH_BIN');
226
+ const profile = env.FLYWHEEL_DSH_PROFILE || 'sdk';
227
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile) || profile === 'desktop') {
228
+ throw new BridgeError('INVALID_REQUEST', 'FLYWHEEL_DSH_PROFILE must name an SDK profile, not desktop');
229
+ }
230
+ temp = await realpath(await mkdtemp(join(tmpdir(), 'flywheel-dsh-')));
231
+ const patch = join(temp, 'worker.patch.json');
232
+ sessionId = prepared ? undefined : request.sessionId ?? `flywheel-${randomUUID()}`;
233
+ const admission = { receiptPath: join(temp, 'policy.json'), requestId: prepared ? prepared.warmId : request.requestId,
234
+ ...(prepared ? { deferredAdmissionPath: join(temp, 'admission.json') } : { sessionId }), nonce: randomUUID() };
235
+ await writeFile(patch, JSON.stringify(makeOverlay(request, undefined, admission, doctor ? join(temp, 'probe-history') : undefined)), { mode: 0o600 });
236
+ controller.signal.throwIfAborted();
237
+ if (!prepared) fold = new SessionFold(sessionId, { strict: request.protocol === 2 });
238
+ runtime = new Runtime(bin, ['--profile', profile, '--patch', patch], {
239
+ cwd: request.cwd,
240
+ env: { ...env, DSH_PERMISSION_MODE: request.permissionMode,
241
+ DSH_MAX_TOKENS_AS_SUCCESS: 'false', DSH_TELEMETRY_DISABLED: '1', FLYWHEEL_DSH_NATIVE_WORKER: '1' },
242
+ stderr,
243
+ }, (method, params) => {
244
+ if (prepared && !formalStarted && method === 'session.event') {
245
+ throw new BridgeError('TRANSPORT', 'SDK emitted a Session event before formal prewarm dispatch');
246
+ }
247
+ if (method === 'session.event' && fold.accept(params)) {
248
+ const eventFrame = { type: 'event', sessionId, event: params.event };
249
+ if (readySent) frame(eventFrame);
250
+ else {
251
+ beforeReady.push(eventFrame);
252
+ if (beforeReady.length > 256) throw new BridgeError('TRANSPORT', 'excessive SDK events before readiness');
253
+ }
254
+ }
255
+ if (method === 'subagent.started' && params?.parentSessionId === sessionId) {
256
+ throw new BridgeError('INVALID_REQUEST', 'DSH child delegation is disabled: the Go engine owns workers');
257
+ }
258
+ });
259
+ if (controller.signal.aborted) runtime.fail(controller.signal.reason);
260
+ const initialized = await runtime.request('initialize', {
261
+ cwd: request.workspaceCwd ?? request.cwd, provider: request.provider, model: request.model,
262
+ ...(request.reasoningEffort === undefined ? {} : { reasoningEffort: request.reasoningEffort }),
263
+ ...(request.maxTokens === undefined ? {} : { maxTokens: request.maxTokens }),
264
+ });
265
+ if (initialized?.serverInfo?.name !== 'deepseek-harness-sdk-runtime') {
266
+ throw new BridgeError('TRANSPORT', 'unexpected SDK runtime identity');
267
+ }
268
+ if (prepared) {
269
+ prewarm.ready();
270
+ emit({ protocol: 1, type: 'warm-ready', warmId: prepared.warmId, binding: prepared.binding,
271
+ level: 'sdk-initialized', dispatched: false });
272
+ const next = await abortable(prewarm.dispatch, controller.signal);
273
+ if (next === null) { warmClosed = true; throw new BridgeError('TRANSPORT', 'unused prewarm closed'); }
274
+ // Once a full request arrives its lifecycle belongs to the already durable
275
+ // invocation, even if validation fails before any model input is sent.
276
+ formalStarted = true;
277
+ request = next;
278
+ // Receiving the owned start retires the prepare deadline before filesystem
279
+ // validation yields. The formal deadline covers that validation as well.
280
+ started = Date.now();
281
+ clearTimeout(deadline);
282
+ const formalBudget = Number.isSafeInteger(next?.timeoutMs) && next.timeoutMs > 0 && next.timeoutMs <= 86400000
283
+ ? next.timeoutMs : 30 * 60 * 1000;
284
+ deadline = setTimeout(() => controller.abort(new BridgeError('TIMEOUT', 'DSH worker lifecycle deadline exceeded')), formalBudget);
285
+ request = await abortable(validateStart(next), controller.signal);
286
+ matchWarmStart(prepared, request);
287
+ sessionId = request.sessionId ?? `flywheel-${randomUUID()}`;
288
+ fold = new SessionFold(sessionId, { strict: request.protocol === 2 });
289
+ await writeFile(admission.deferredAdmissionPath, JSON.stringify({ requestId: admission.requestId,
290
+ nonce: admission.nonce, sessionId, ...(!request.sessionId && request.sessionTitle ? { sessionTitle: request.sessionTitle } : {}) }),
291
+ { mode: 0o600, flag: 'wx' });
292
+ }
293
+ if (request.sessionId !== undefined) {
294
+ // Public SDK resume restores exactly this identity, without a new input
295
+ // and without a replacement on failure. A generic RPC error cannot tell
296
+ // missing history from another live owner; never silently cold-start it.
297
+ resumeUnconfirmed = true;
298
+ const restored = await runtime.request('session/resume', { sessionId });
299
+ if (!record(restored) || Object.keys(restored).length !== 0) {
300
+ throw new BridgeError('TRANSPORT', 'SDK resume did not confirm restoration');
301
+ }
302
+ resumeAccepted = true;
303
+ resumeUnconfirmed = false;
304
+ }
305
+ // This creates the fresh Session and its permission facts without an LLM turn.
306
+ let working = await runtime.request('session/working-directory/get', { sessionId });
307
+ if (request.workspaceCwd !== undefined && working?.cwd !== request.cwd) {
308
+ working = await runtime.request('session/working-directory/set', { sessionId, path: request.cwd });
309
+ }
310
+ if (working?.cwd !== request.cwd) throw new BridgeError('INVALID_REQUEST', 'DSH effective cwd differs from the requested workspace');
311
+ fold.policy = await abortable(readPolicyReceipt(admission.receiptPath,
312
+ { requestId: admission.requestId, sessionId, nonce: admission.nonce }), controller.signal);
313
+ if (request.workspaceCwd !== undefined && fold.policy.workspaceCwd !== request.workspaceCwd) {
314
+ throw new BridgeError('INVALID_REQUEST', 'DSH session project workspace differs from the requested project');
315
+ }
316
+ fold.verifiedPolicy(request);
317
+ frame({ type: 'ready', sessionId, cwd: request.cwd, ...(request.workspaceCwd ? { workspaceCwd: request.workspaceCwd } : {}), provider: request.provider, model: request.model,
318
+ ...(request.reasoningEffort === undefined ? {} : { reasoningEffort: request.reasoningEffort }),
319
+ permissionMode: request.permissionMode, approvalPolicy: 'never',
320
+ policy: { protocol: fold.policy.protocol, cwd: fold.policy.cwd, permissionMode: fold.policy.permissionMode,
321
+ approvalPolicy: fold.policy.approvalPolicy, confinedShell: fold.policy.confinedShell,
322
+ ...(fold.policy.enforcement === undefined ? {} : { enforcement: fold.policy.enforcement }) },
323
+ ...(request.protocol === 2 ? { persistent: true, resumed: resumeAccepted } : {}) });
324
+ readySent = true;
325
+ // v2 publishes only this invocation's events. Restoration/configuration
326
+ // records are reflected in the checked ready receipt, never as new output.
327
+ if (request.protocol === 1) for (const eventFrame of beforeReady) frame(eventFrame);
328
+ beforeReady.length = 0;
329
+ if (doctor) {
330
+ outcome = { text: 'SDK route, cwd and permission handshake verified; no model prompt sent.', usage: null };
331
+ } else {
332
+ // The next prompt is the only invocation owned by this worker. Reset any
333
+ // restoration facts before dispatch; historical usage must never be added.
334
+ fold.beginCall();
335
+ dispatch = 'unknown';
336
+ const receipt = await runtime.request('session/prompt', {
337
+ sessionId, contentBlocks: [{ type: 'text', text: request.prompt }],
338
+ });
339
+ if (typeof receipt?.messageId !== 'string' || receipt.messageId === '') {
340
+ throw new BridgeError('TRANSPORT', 'SDK prompt returned no messageId');
341
+ }
342
+ promptId = receipt.messageId;
343
+ dispatch = 'dispatched';
344
+ const completed = await runtime.request('session/wait', { sessionId });
345
+ if (!record(completed) || Object.keys(completed).length !== 0) {
346
+ throw new BridgeError('TRANSPORT', 'SDK wait did not confirm a quiescent session');
347
+ }
348
+ waited = true;
349
+ outcome = fold.finish(receipt.messageId);
350
+ }
351
+ } catch (error) {
352
+ if (!warmClosed) failure = normalizedError(error);
353
+ } finally {
354
+ if (runtime) {
355
+ try {
356
+ const exit = await runtime.close();
357
+ if (exit.spawned && (exit.forced || exit.code !== 0 || exit.signal || !exit.graceful)) {
358
+ settled = false;
359
+ failure ??= { code: 'TRANSPORT', message: 'DSH runtime did not prove graceful shutdown of its managed processes' };
360
+ }
361
+ if (runtime.protocolFailure) {
362
+ // A provider result cannot attest a damaged SDK stream. Keep its
363
+ // diagnosis as context, while protocol and cleanup facts stay independent.
364
+ const protocolFailure = normalizedError(runtime.protocolFailure);
365
+ failure = failure && failure.message !== protocolFailure.message
366
+ ? { ...protocolFailure, message: `${protocolFailure.message}; preceding failure: ${failure.message}` }
367
+ : protocolFailure;
368
+ }
369
+ } catch (error) {
370
+ settled = false;
371
+ failure = normalizedError(error);
372
+ }
373
+ }
374
+ clearTimeout(deadline);
375
+ if (controller.signal.aborted) failure ??= normalizedError(controller.signal.reason);
376
+ if (request && Date.now() - started > request.timeoutMs && !cancelled) {
377
+ failure = { code: 'TIMEOUT', message: 'DSH worker lifecycle deadline exceeded (including cleanup)' };
378
+ }
379
+ if (temp) await rm(temp, { recursive: true, force: true }).catch(error => {
380
+ failure ??= { code: 'TRANSPORT', message: `could not remove worker overlay: ${error.message}` };
381
+ });
382
+ signal?.removeEventListener('abort', cancel);
383
+ controller.signal.removeEventListener('abort', abort);
384
+ if (prepared && !formalStarted) {
385
+ const confirmed = settled && !runtime?.protocolFailure;
386
+ try { await writeWarmReceipt(prepared, confirmed, failure); }
387
+ catch (error) { settled = false; failure = normalizedError(error); }
388
+ emit({ protocol: 1, type: 'warm-result', warmId: prepared.warmId, binding: prepared.binding,
389
+ dispatched: false, cleanupConfirmed: confirmed && settled, status: warmClosed && settled && !failure ? 'closed' : cancelled ? 'cancelled' : 'error',
390
+ ...(failure ? { error: failure } : {}) });
391
+ }
392
+ }
393
+ if (prepared && !formalStarted) return { status: warmClosed && settled && !failure ? 'closed' : cancelled ? 'cancelled' : 'error', settled };
394
+ const result = {
395
+ type: 'result', status: cancelled ? 'cancelled' : failure ? 'error' : 'ok', settled,
396
+ ...(sessionId ? { sessionId } : {}),
397
+ text: outcome?.text ?? fold?.text ?? '',
398
+ usage: outcome?.usage ?? null, costUsd: null,
399
+ ...(failure ? { error: failure } : {}),
400
+ };
401
+ if ((formalStarted ? request.protocol : input?.protocol) === 2) {
402
+ // Cleanup, result certainty and output validity are independent. A complete
403
+ // native turn with empty/model-invalid output can still be repaired in its
404
+ // own context. Disconnects, damaged frames and ambiguous restore failures
405
+ // never become a confirmed failure merely because the process has exited.
406
+ const terminalKnown = waited && fold?.receipts.has(promptId) && fold?.terminal && !runtime?.protocolFailure;
407
+ const terminal = terminalKnown ? fold.terminal.kind === 'completed' ? 'completed'
408
+ : fold.terminal.kind === 'aborted' ? 'cancelled' : 'failed'
409
+ : dispatch === 'not-dispatched' && !resumeUnconfirmed ? 'failed' : 'unknown';
410
+ result.lifecycle = {
411
+ protocol: SESSION_PROTOCOL, requestId: request?.requestId ?? input.requestId,
412
+ sessionId: sessionId ?? '', dispatch, outcome: terminal,
413
+ cleanupConfirmed: settled,
414
+ resumeCandidate: Boolean(terminal === 'completed' && settled && readySent && !cancelled && !controller.signal.aborted),
415
+ resumeAccepted, turnId: terminalKnown ? fold.turnId : '',
416
+ usageStatus: result.usage === null ? 'unknown' : 'complete',
417
+ };
418
+ }
419
+ frame(result);
420
+ return result;
421
+ }