dsh-ptc-moejs 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Calcium-Ion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,129 @@
1
- # Temporary Holding Version
1
+ # dsh-ptc-moejs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [简体中文](README.zh_CN.md)
4
+
5
+ A PTC runtime for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh). It runs the model's programs in [moejs](https://github.com/Calcium-Ion/moejs), a JavaScript engine written in Go.
6
+
7
+ In dsh's PTC mode the model answers with a TypeScript program that calls tools, and dsh hands that program to the loaded runtime. A run of three tool calls takes 0.18 ms here; dsh's Node runtime takes 95 ms for the same program (see [Measurements](#measurements)). Each run gets a fresh moejs runtime inside one sidecar process, which stays up between runs, and a memory budget, 512 MiB by default; a deadline or an abort stops a run at once, an endless loop included. The sidecar is a static Go binary that dsh starts as a plain child process. Starting that process is all the runtime asks of the host, so it also works in containers where bubblewrap cannot run.
8
+
9
+ ## Quick start
10
+
11
+ ```sh
12
+ dsh plugin --profile web add dsh-ptc-moejs
13
+ ```
14
+
15
+ Use the name of the profile you run in place of `web`. The sidecar binary comes as an optional dependency for darwin-arm64, darwin-x64, linux-arm64, linux-x64 and win32-x64, so install with optional dependencies enabled.
16
+
17
+ Installing adds a disabled `ptc-moejs` row to the profile. To select it, add this patch to the profile's `cordis.patch.yml`, which is `~/.dsh/profiles/<name>/cordis.patch.yml` (or the same path under `$DSH_HOME`):
18
+
19
+ ```yaml
20
+ # Replace the Node PTC runtime with moejs.
21
+ - id: ptc-runtime
22
+ disabled: true
23
+ - id: ptc-moejs
24
+ disabled: false
25
+ # Turn PTC mode on.
26
+ - id: tools
27
+ config:
28
+ mode: ptc
29
+ ```
30
+
31
+ `dsh --profile web --dump-config` prints the composed rows. In the web and headless profiles, `DSH_TOOLS_MODE=ptc` also turns PTC mode on for one launch, in place of the `tools` entry. To go back to the Node runtime, delete the first two entries.
32
+
33
+ ## Configuration
34
+
35
+ Options go on the `ptc-moejs` row of the same patch:
36
+
37
+ ```yaml
38
+ - id: ptc-moejs
39
+ disabled: false
40
+ config:
41
+ memoryLimitMb: 1024
42
+ timeoutMs: 300000
43
+ ```
44
+
45
+ | Option | Default | Meaning |
46
+ |---|--:|---|
47
+ | `timeoutMs` | 120000 | Deadline of a run in milliseconds when the model asks for none. Tool calls and approvals count toward it. |
48
+ | `maxTimeoutMs` | 600000 | The longest deadline a run may ask for. |
49
+ | `memoryLimitMb` | 512 | MiB a run may allocate over its whole life, garbage included. Past it the run stops with `timeout` and a message that names the budget. |
50
+ | `maxLogBytes` | 16 MiB | Bytes of console output a run keeps, counted as the JSON array dsh stores. Output past it is dropped, a marker line ends the logs, and the run goes on. |
51
+ | `maxValueBytes` | 16 MiB | Bytes of the returned value as JSON. A larger value fails the run with `output-limit`. Error messages are cut to this size. |
52
+ | `maxPendingCalls` | 128 | Tool calls a run may have in flight at once. One more fails the run with `protocol`. |
53
+ | `maxFrameBytes` | 64 MiB | The largest message between dsh and the sidecar: a program, the arguments of a tool call or one tool result. A larger tool result reaches the program as a rejected call. |
54
+
55
+ The deadline defaults follow dsh's Node runtime. dsh spills tool results longer than 12,500 tokens to a file, so the log and value budgets are limits on memory use.
56
+
57
+ ## How it works
58
+
59
+ A program in this runtime can call the tools dsh gives it and write to `console`. To read a file or run a command it calls a tool, and each tool call goes through dsh's tool pipeline with that tool's own policy, approvals and sandbox, as it does with the Node runtime. Every effect a program has on the machine is one of those tool calls.
60
+
61
+ ## What a program sees
62
+
63
+ A program is the body of an async function, so `await` and `return` work at its top level. The function's parameters are the binding namespaces dsh passes (`tools` in PTC mode), the error class of each namespace (`ToolCallError`, which carries the tool's name in `toolName`) and `console` with `log`, `info`, `warn`, `error` and `debug`.
64
+
65
+ Programs are TypeScript with erasable syntax, the rule dsh's Node runtime applies: the types are removed, and `enum`, namespaces with values and parameter properties are syntax errors.
66
+
67
+ The rest is the JavaScript standard library, which in moejs includes `TextEncoder`, `TextDecoder`, `atob`, `btoa`, `structuredClone` and `queueMicrotask`. Node's APIs are absent: `require`, `import`, `process`, `Buffer`, `fs` and `setTimeout` are undefined in a program. A program that needs a file asks the `read` tool for it. If your programs use Node APIs, keep dsh's Node runtime for that profile.
68
+
69
+ The program has no sandbox of its own, so `run_code` takes no `sandbox_permissions` argument. When the file sandbox denies a tool call, the program retries that call with `sandbox_permissions` and `justification` among the arguments, and each such call asks you for approval. On the Node runtime, the file operations a program performs itself through Node APIs can be approved once for the whole run.
70
+
71
+ A run looks like this. The model writes the body; the runtime executes it and hands the returned value to dsh:
72
+
73
+ ```ts
74
+ const head = await tools.bash({ description: "Read the last commit", command: "git log -1 --oneline" });
75
+ return { head: head.stdout?.text };
76
+ ```
77
+
78
+ The returned value goes back to dsh as lossless JSON: plain objects, arrays, strings, finite numbers, booleans and `null`. A value that holds `undefined`, a function, a `BigInt`, a cycle, `NaN`, `Infinity`, `-0`, a `Date`, a `Map` or a class instance fails the run with `invalid-output`, and the message gives the path of the first such member, for example `a BigInt at $.items[3].size`. A program that returns nothing completes with no value. Tool arguments follow the same rule, and a call with arguments that are not lossless JSON rejects inside the program before dsh sees it.
79
+
80
+ | Failure kind | When |
81
+ |---|---|
82
+ | `exception` | The program throws, has a syntax error, or uses TypeScript that changes run-time behaviour, such as `enum`. |
83
+ | `timeout` | The deadline passes, or the program allocates more than `memoryLimitMb`. |
84
+ | `abort` | dsh cancels the run, or the profile shuts the runtime down. |
85
+ | `invalid-output` | The returned value is not lossless JSON. |
86
+ | `output-limit` | The returned value is larger than `maxValueBytes`. |
87
+ | `protocol` | More than `maxPendingCalls` tool calls are in flight, or the sidecar sent a malformed message. |
88
+ | `worker-exit` | The sidecar process died. The next run starts a new one. |
89
+
90
+ ## Measurements
91
+
92
+ Both runtimes ran the same program through dsh's `PtcRuntime` service API: three tool calls, each answered at once by its binding, and a returned object. Every round started each runtime in a fresh Node process. The 5 rounds alternated the order of the runtimes on 8 cores of an Intel Core i5-13500H, with Linux 6.18, Node 24.22.1, Go 1.26.6, dsh 0.2.1-rc.2 and moejs v0.1.0-alpha.9. The table gives the median of the round medians.
93
+
94
+ | Runtime | Per call | Load to first result |
95
+ |---|--:|--:|
96
+ | dsh-ptc-moejs | 0.18 ms | 5.8 ms |
97
+ | dsh-ptc-runtime-node, workspace-write | 94.8 ms | 131.7 ms |
98
+ | dsh-ptc-runtime-node, danger-full-access | 91.2 ms | 135.0 ms |
99
+
100
+ A call is one `resolve` and `run`, from the program text to the result. Load to first result runs from mounting the plugin to the end of its first run, and for moejs it includes starting the sidecar. Workspace-write is the default file policy of a dsh Session, under which the Node runtime starts each program inside bubblewrap. Danger-full-access starts it directly. The round medians stayed within 0.16 to 0.20 ms for moejs and within 90 to 96 ms for Node.
101
+
102
+ ## Limitations
103
+
104
+ - Every call starts with a fresh runtime. A persistent mode that keeps variables between calls, like the Node runtime's REPL mode, is planned for a later version.
105
+ - The memory budget counts what a run allocates, garbage included. A long run that builds and drops a lot of data can stop before its live data reaches the budget.
106
+ - Values nest up to 10,000 levels deep in both directions, the depth moejs's JSON reader and writer handle.
107
+ - `console` output imitates Node's `util.inspect` for strings, numbers, plain objects, arrays, `Map`, `Set`, errors and class instances, and breaks a line at 72 characters.
108
+ - moejs interrupts running JavaScript, and a compile runs to its end once it starts. A program that takes long to parse still ends at its deadline for dsh, and the sidecar drops the run once the parse returns.
109
+ - If a tool call that the program never awaits fails, the failure stays in that unread promise.
110
+ - This version has been tested on Linux x64. The other four binaries are cross-compiled from the same source.
111
+
112
+ ## Development
113
+
114
+ ```sh
115
+ pnpm install
116
+ pnpm run build:sidecar # the sidecar for this machine, into npm/<package>/bin
117
+ pnpm run build # lib/
118
+ pnpm test # unit tests and the behaviour suite against the sidecar
119
+ go test ./... # the sidecar's Go tests
120
+ DSH_REPO=/path/to/deepseek-harness pnpm run e2e # headless dsh with dsh's mock LLM
121
+ DSH_REPO=/path/to/deepseek-harness pnpm run bench # the measurements above
122
+ pnpm run build:binaries # all five platform binaries
123
+ ```
124
+
125
+ Building the sidecar takes Go 1.25 or later, with `CGO_ENABLED=0`. `DSH_REPO` is a dsh checkout after `pnpm install` and `pnpm run build`. The e2e test installs this checkout into a temporary profile with `dsh plugin add`, runs one headless task in which the mock model calls `run_code`, and compares the Session log with the same task on the Node runtime.
126
+
127
+ ## License
128
+
129
+ MIT. The sidecar binary in each platform package also contains moejs (Apache License 2.0) and the Go standard library (BSD 3-Clause License). Their license texts are in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), which ships with each platform package.
@@ -0,0 +1,129 @@
1
+ # dsh-ptc-moejs
2
+
3
+ [English](README.md)
4
+
5
+ [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的 PTC 运行时,用 Go 写的 JavaScript 引擎 [moejs](https://github.com/Calcium-Ion/moejs) 运行模型的程序。
6
+
7
+ dsh 的 PTC 模式下,模型给出一段调用工具的 TypeScript 程序,dsh 把程序交给当前加载的运行时执行。这个运行时把程序放进 [moejs](https://github.com/Calcium-Ion/moejs) 里运行。调用三次工具的程序在这里一次运行用时 0.18 ms,dsh 的 Node 运行时跑同样的程序用时 95 ms(见[测量](#测量))。每次运行都在一个 sidecar 进程里新建 moejs 运行时,sidecar 在多次运行之间一直保留;每次运行还有内存预算,默认 512 MiB,截止时间到了或者被中止时运行立即停止,死循环也一样。sidecar 是静态链接的 Go 程序,dsh 把它当作普通子进程启动。运行时对宿主机的要求就是能启动这个进程,所以在无法运行 bubblewrap 的容器里也能用。
8
+
9
+ ## 快速开始
10
+
11
+ ```sh
12
+ dsh plugin --profile web add dsh-ptc-moejs
13
+ ```
14
+
15
+ 把 `web` 换成你使用的 profile 名。sidecar 程序以可选依赖的形式提供,覆盖 darwin-arm64、darwin-x64、linux-arm64、linux-x64 和 win32-x64,安装时需要启用可选依赖。
16
+
17
+ 安装后 profile 里多出一行处于禁用状态的 `ptc-moejs`。要启用它,把下面的补丁加进 profile 的 `cordis.patch.yml`,路径是 `~/.dsh/profiles/<name>/cordis.patch.yml`(设置了 `$DSH_HOME` 时在它下面的同一路径):
18
+
19
+ ```yaml
20
+ # 用 moejs 替换 Node PTC 运行时。
21
+ - id: ptc-runtime
22
+ disabled: true
23
+ - id: ptc-moejs
24
+ disabled: false
25
+ # 打开 PTC 模式。
26
+ - id: tools
27
+ config:
28
+ mode: ptc
29
+ ```
30
+
31
+ `dsh --profile web --dump-config` 会打印组合后的配置行。在 web 和 headless profile 里,也可以用 `DSH_TOOLS_MODE=ptc` 在单次启动时打开 PTC 模式,代替 `tools` 那一项。要换回 Node 运行时,删掉前两项。
32
+
33
+ ## 配置
34
+
35
+ 选项写在同一个补丁的 `ptc-moejs` 行上:
36
+
37
+ ```yaml
38
+ - id: ptc-moejs
39
+ disabled: false
40
+ config:
41
+ memoryLimitMb: 1024
42
+ timeoutMs: 300000
43
+ ```
44
+
45
+ | 选项 | 默认值 | 含义 |
46
+ |---|--:|---|
47
+ | `timeoutMs` | 120000 | 模型没有指定时,一次运行的截止时间(毫秒)。工具调用和审批的时间都算在内。 |
48
+ | `maxTimeoutMs` | 600000 | 一次运行可以请求的最长截止时间。 |
49
+ | `memoryLimitMb` | 512 | 一次运行在整个生命周期里可以分配的 MiB 数,垃圾也算在内。超过后运行以 `timeout` 停止,消息里写明是哪个预算。 |
50
+ | `maxLogBytes` | 16 MiB | 一次运行保留的 console 输出字节数,按 dsh 存储的 JSON 数组计算。超出部分被丢弃,日志以一行标记结尾,运行继续。 |
51
+ | `maxValueBytes` | 16 MiB | 返回值序列化成 JSON 后的字节数。更大的值让运行以 `output-limit` 失败。错误消息也截断到这个长度。 |
52
+ | `maxPendingCalls` | 128 | 一次运行同时进行中的工具调用数。再多一个,运行以 `protocol` 失败。 |
53
+ | `maxFrameBytes` | 64 MiB | dsh 和 sidecar 之间单条消息的上限:一段程序、一次工具调用的参数或一个工具结果。更大的工具结果在程序里表现为被拒绝的调用。 |
54
+
55
+ 截止时间的默认值和 dsh 的 Node 运行时一致。dsh 会把超过 12,500 token 的工具结果转存到文件,所以日志和返回值的预算用来限制内存占用。
56
+
57
+ ## 工作方式
58
+
59
+ 程序能调用 dsh 提供的工具,能向 `console` 输出。读文件、执行命令都要调用工具,每次工具调用都走 dsh 的工具流水线,由该工具自己的策略、审批和沙箱处理,和 Node 运行时一样。程序对机器的每一次影响都是这样一次工具调用。
60
+
61
+ ## 程序能看到什么
62
+
63
+ 程序是一个 async 函数的函数体,顶层可以用 `await` 和 `return`。函数的参数有 dsh 传入的绑定命名空间(PTC 模式下是 `tools`)、每个命名空间的错误类(`ToolCallError`,`toolName` 属性里是工具名),以及带 `log`、`info`、`warn`、`error`、`debug` 的 `console`。
64
+
65
+ 程序是只用可擦除语法的 TypeScript,规则和 dsh 的 Node 运行时相同:类型标注被删掉,`enum`、带值的 namespace 和参数属性都是语法错误。
66
+
67
+ 其余是 JavaScript 标准库,moejs 的标准库里有 `TextEncoder`、`TextDecoder`、`atob`、`btoa`、`structuredClone` 和 `queueMicrotask`。Node 的 API 在程序里都是 undefined,包括 `require`、`import`、`process`、`Buffer`、`fs` 和 `setTimeout`。程序要读文件时调用 `read` 工具。如果你的程序要用 Node API,这个 profile 继续用 dsh 的 Node 运行时。
68
+
69
+ 程序自己没有沙箱,所以 `run_code` 不接受 `sandbox_permissions`。文件沙箱拒绝某次工具调用时,程序在这次调用的参数里带上 `sandbox_permissions` 和 `justification` 重试,每次这样的调用都要你批准。用 Node 运行时的话,程序通过 Node API 自己做的文件操作还能用一次批准全部放行。
70
+
71
+ 一次运行是这样的。函数体由模型写,运行时执行它,并把返回值交给 dsh:
72
+
73
+ ```ts
74
+ const head = await tools.bash({ description: "Read the last commit", command: "git log -1 --oneline" });
75
+ return { head: head.stdout?.text };
76
+ ```
77
+
78
+ 返回值以无损 JSON 的形式交给 dsh:普通对象、数组、字符串、有限数字、布尔值和 `null`。值里含有 `undefined`、函数、`BigInt`、循环引用、`NaN`、`Infinity`、`-0`、`Date`、`Map` 或类的实例时,运行以 `invalid-output` 失败,消息给出第一个这样的成员的路径,例如 `a BigInt at $.items[3].size`。程序没有返回值时,运行正常结束,结果里没有值。工具参数遵守同样的规则,参数不是无损 JSON 的调用在发给 dsh 之前就在程序内部被拒绝。
79
+
80
+ | 失败类型 | 什么时候出现 |
81
+ |---|---|
82
+ | `exception` | 程序抛出异常、有语法错误,或者用了会改变运行行为的 TypeScript,例如 `enum`。 |
83
+ | `timeout` | 超过截止时间,或者分配的内存超过 `memoryLimitMb`。 |
84
+ | `abort` | dsh 取消了这次运行,或者 profile 关闭了运行时。 |
85
+ | `invalid-output` | 返回值不是无损 JSON。 |
86
+ | `output-limit` | 返回值大于 `maxValueBytes`。 |
87
+ | `protocol` | 同时进行中的工具调用超过 `maxPendingCalls`,或者 sidecar 发来了格式错误的消息。 |
88
+ | `worker-exit` | sidecar 进程退出了。下一次运行会启动新的进程。 |
89
+
90
+ ## 测量
91
+
92
+ 两个运行时通过 dsh 的 `PtcRuntime` 服务接口运行同一段程序:三次工具调用,每次由绑定函数立即返回,最后返回一个对象。每一轮里,每个运行时都在新的 Node 进程中启动。5 轮交替顺序运行,绑定在 Intel Core i5-13500H 的 8 个核上,环境是 Linux 6.18、Node 24.22.1、Go 1.26.6、dsh 0.2.1-rc.2 和 moejs v0.1.0-alpha.9。表中是各轮中位数的中位数。
93
+
94
+ | 运行时 | 每次调用 | 从加载到第一个结果 |
95
+ |---|--:|--:|
96
+ | dsh-ptc-moejs | 0.18 ms | 5.8 ms |
97
+ | dsh-ptc-runtime-node,workspace-write | 94.8 ms | 131.7 ms |
98
+ | dsh-ptc-runtime-node,danger-full-access | 91.2 ms | 135.0 ms |
99
+
100
+ 一次调用是一次 `resolve` 加一次 `run`,从程序文本到结果。从加载到第一个结果是从挂载插件到第一次运行结束,moejs 的这段时间包含启动 sidecar。workspace-write 是 dsh 会话的默认文件策略,这时 Node 运行时在 bubblewrap 里启动每段程序。danger-full-access 下直接启动。各轮中位数里,moejs 在 0.16 到 0.20 ms 之间,Node 在 90 到 96 ms 之间。
101
+
102
+ ## 限制
103
+
104
+ - 每次调用都从新的运行时开始。在调用之间保留变量的持久模式(类似 Node 运行时的 REPL 模式)计划在以后的版本中加入。
105
+ - 内存预算统计的是一次运行分配的内存,垃圾也算在内。一次运行如果反复创建又丢弃大量数据,可能在存活数据达到预算之前就被停止。
106
+ - 两个方向的值最多嵌套 10,000 层,这是 moejs 的 JSON 读写能处理的深度。
107
+ - `console` 的输出仿照 Node 的 `util.inspect`,覆盖字符串、数字、普通对象、数组、`Map`、`Set`、错误和类的实例,单行超过 72 个字符时换行。
108
+ - moejs 的中断作用于正在运行的 JavaScript,编译一旦开始会一直进行到结束。解析很慢的程序到截止时间时,dsh 一侧照常结束这次运行,sidecar 等解析返回后丢弃它。
109
+ - 程序没有 await 的工具调用如果失败,这个失败只留在那个没人读取的 promise 里。
110
+ - 这个版本在 Linux x64 上测试过。其余四个平台的程序由同一份源码交叉编译。
111
+
112
+ ## 开发
113
+
114
+ ```sh
115
+ pnpm install
116
+ pnpm run build:sidecar # 为本机构建 sidecar,放到 npm/<package>/bin
117
+ pnpm run build # lib/
118
+ pnpm test # 单元测试,以及针对 sidecar 的行为测试
119
+ go test ./... # sidecar 的 Go 测试
120
+ DSH_REPO=/path/to/deepseek-harness pnpm run e2e # 用 dsh 的 mock LLM 跑 headless dsh
121
+ DSH_REPO=/path/to/deepseek-harness pnpm run bench # 上面的测量
122
+ pnpm run build:binaries # 五个平台的程序
123
+ ```
124
+
125
+ 构建 sidecar 需要 Go 1.25 或更新版本,使用 `CGO_ENABLED=0`。`DSH_REPO` 是执行过 `pnpm install` 和 `pnpm run build` 的 dsh 源码目录。端到端测试用 `dsh plugin add` 把这个目录装进临时 profile,跑一个 headless 任务,让 mock 模型调用 `run_code`,再和 Node 运行时跑同一任务的会话日志做比较。
126
+
127
+ ## 许可证
128
+
129
+ MIT。各平台包里的常驻进程二进制还包含 moejs(Apache License 2.0)和 Go 标准库(BSD 3-Clause License),它们的许可证原文在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 里,随每个平台包一起发布。
@@ -0,0 +1,9 @@
1
+ # The dsh-ptc-moejs bundle layer: it mounts the moejs PTC runtime as a
2
+ # disabled row, so installing the package changes nothing on its own. A
3
+ # profile selects it with a patch that disables the ptc-runtime row (the Node
4
+ # runtime) and enables this one; see the README.
5
+
6
+ - insert:
7
+ - id: ptc-moejs
8
+ name: dsh-ptc-moejs
9
+ disabled: true
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Finds the sidecar binary. Each supported platform has its own package,
3
+ * installed as an optional dependency, as esbuild ships its binary.
4
+ * @module dsh-ptc-moejs/binary
5
+ */
6
+ /** The platforms that have a binary package. */
7
+ export declare const PLATFORMS: readonly ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64"];
8
+ /**
9
+ * Resolve the sidecar binary for this platform.
10
+ * @param platform - the Node platform name.
11
+ * @param arch - the Node architecture name.
12
+ * @returns the absolute path of the binary.
13
+ * @throws When no binary package for the platform is installed.
14
+ */
15
+ export declare function resolveBinary(platform?: string, arch?: string): string;
package/lib/binary.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Finds the sidecar binary. Each supported platform has its own package,
3
+ * installed as an optional dependency, as esbuild ships its binary.
4
+ * @module dsh-ptc-moejs/binary
5
+ */
6
+ import { createRequire } from 'node:module';
7
+ /** The platforms that have a binary package. */
8
+ export const PLATFORMS = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64'];
9
+ /**
10
+ * Resolve the sidecar binary for this platform.
11
+ * @param platform - the Node platform name.
12
+ * @param arch - the Node architecture name.
13
+ * @returns the absolute path of the binary.
14
+ * @throws When no binary package for the platform is installed.
15
+ */
16
+ export function resolveBinary(platform = process.platform, arch = process.arch) {
17
+ const target = `${platform}-${arch}`;
18
+ const pkg = `dsh-ptc-moejs-${target}`;
19
+ try {
20
+ return createRequire(import.meta.url).resolve(`${pkg}/bin/moejs-ptc${platform === 'win32' ? '.exe' : ''}`);
21
+ }
22
+ catch {
23
+ const supported = PLATFORMS.includes(target);
24
+ throw new Error(supported
25
+ ? `dsh-ptc-moejs: the sidecar package ${pkg} is not installed; reinstall with optional dependencies enabled`
26
+ : `dsh-ptc-moejs: no sidecar binary for ${target} (supported: ${PLATFORMS.join(', ')})`);
27
+ }
28
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A PTC runtime for the DeepSeek Harness that runs each program in a fresh
3
+ * moejs runtime inside one long-lived sidecar process. A program's effects on
4
+ * the machine are its tool calls, which dsh's tool pipeline confines.
5
+ * @module dsh-ptc-moejs
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime';
9
+ import type { PtcBindingNamespace, PtcRunRequest, PtcRunResult, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime';
10
+ import z from '@deepseek-ai/schemastery';
11
+ import type { Namespace } from './sidecar.ts';
12
+ /** Plugin configuration. */
13
+ export interface Config {
14
+ /** Default elapsed deadline of a run in milliseconds, tool calls included. */
15
+ timeoutMs?: number;
16
+ /** Largest deadline a run may request. */
17
+ maxTimeoutMs?: number;
18
+ /** MiB one run may allocate in total, garbage included, before it is stopped. */
19
+ memoryLimitMb?: number;
20
+ /** Bytes of serialized logs a run keeps; capture stops at the budget. */
21
+ maxLogBytes?: number;
22
+ /** Bytes of serialized completion value; a larger value fails as output-limit. */
23
+ maxValueBytes?: number;
24
+ /** Tool calls one run may have in flight at once. */
25
+ maxPendingCalls?: number;
26
+ /** Bytes of one frame between host and sidecar: a program, a tool result or tool arguments. */
27
+ maxFrameBytes?: number;
28
+ }
29
+ /** Configuration with every default filled in. */
30
+ export type ResolvedConfig = Required<Config>;
31
+ /** The defaults, with the Node runtime's 120 s and 600 s deadlines. */
32
+ export declare const MoejsConfig: z<Config>;
33
+ /**
34
+ * Fill in defaults and validate the configuration.
35
+ * @param config - configuration as given.
36
+ * @returns the complete configuration.
37
+ * @throws When a value is out of range.
38
+ */
39
+ export declare function resolveConfig(config?: Config): ResolvedConfig;
40
+ /**
41
+ * Validate the binding namespaces of a request and read each function once.
42
+ * Names follow the rules dsh shares across PTC runtimes, so a namespace list
43
+ * valid here is valid on every runtime.
44
+ * @param bindings - the request's namespaces.
45
+ * @returns the namespaces keyed by their global.
46
+ * @throws When a name is unusable, reserved or used twice.
47
+ */
48
+ export declare function validateBindings(bindings: readonly PtcBindingNamespace[]): Map<string, Namespace>;
49
+ /** The moejs PTC runtime: `ctx.ptcRuntime` backed by one moejs-ptc sidecar. */
50
+ export declare class MoejsPtcRuntime extends PtcRuntime {
51
+ static Config: z<Config>;
52
+ readonly language = "typescript";
53
+ readonly isolation = "process";
54
+ private readonly config;
55
+ private readonly sidecar;
56
+ private disposed;
57
+ constructor(ctx: Context, config: Config);
58
+ get executionInstructions(): string;
59
+ get timeout(): {
60
+ defaultMs: number;
61
+ maxMs: number;
62
+ };
63
+ /**
64
+ * Fill in the working directory and deadline of a request.
65
+ * @param request - program, bindings and optional choices.
66
+ * @returns the request with a cwd and a capped deadline, or null for none.
67
+ * @throws When the request names a sandbox policy, a relative cwd or an invalid timeout.
68
+ */
69
+ resolve(request: PtcRunRequest): PtcRunSpec;
70
+ /**
71
+ * Run a resolved program. Its outcome is a field of the result.
72
+ * @param spec - what resolve returned.
73
+ * @returns the program's logs and its value or failure.
74
+ * @throws When the spec was not resolved or its bindings break the seam's rules.
75
+ */
76
+ run(spec: PtcRunSpec): Promise<PtcRunResult>;
77
+ }
78
+ export default MoejsPtcRuntime;
package/lib/index.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * A PTC runtime for the DeepSeek Harness that runs each program in a fresh
3
+ * moejs runtime inside one long-lived sidecar process. A program's effects on
4
+ * the machine are its tool calls, which dsh's tool pipeline confines.
5
+ * @module dsh-ptc-moejs
6
+ */
7
+ import { isAbsolute } from 'node:path';
8
+ import { DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, PtcRuntime, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-ptc-runtime';
9
+ import z from '@deepseek-ai/schemastery';
10
+ import { resolveBinary } from "./binary.js";
11
+ import { FRAME_ENVELOPE, messageOf } from "./protocol.js";
12
+ import { Sidecar } from "./sidecar.js";
13
+ /** The defaults, with the Node runtime's 120 s and 600 s deadlines. */
14
+ export const MoejsConfig = z.object({
15
+ timeoutMs: z.number().default(120_000).description('Default elapsed deadline of a run in milliseconds, tool calls included.'),
16
+ maxTimeoutMs: z.number().default(600_000).description('Largest deadline a run may request, in milliseconds.'),
17
+ memoryLimitMb: z.number().default(512).description('MiB one run may allocate in total, garbage included, before it is stopped.'),
18
+ maxLogBytes: z.number().default(16 * 1024 * 1024).description('Bytes of serialized logs a run keeps; capture stops at the budget.'),
19
+ maxValueBytes: z.number().default(16 * 1024 * 1024).description('Bytes of serialized completion value; a larger value fails as output-limit.'),
20
+ maxPendingCalls: z.number().default(128).description('Tool calls one run may have in flight at once.'),
21
+ maxFrameBytes: z.number().default(64 * 1024 * 1024).description('Bytes of one frame between host and sidecar: a program, a tool result or tool arguments.'),
22
+ });
23
+ /** setTimeout's largest delay. */
24
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
25
+ /** A frame becomes one JavaScript string, so it stays well below V8's string limit. */
26
+ const MAX_FRAME_BYTES = 512 * 1024 * 1024;
27
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
28
+ /**
29
+ * Fill in defaults and validate the configuration.
30
+ * @param config - configuration as given.
31
+ * @returns the complete configuration.
32
+ * @throws When a value is out of range.
33
+ */
34
+ export function resolveConfig(config = {}) {
35
+ const resolved = MoejsConfig(config);
36
+ for (const [key, value] of Object.entries(resolved)) {
37
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
38
+ throw new Error(`dsh-ptc-moejs: ${key} must be a positive finite number`);
39
+ }
40
+ }
41
+ for (const key of ['timeoutMs', 'maxTimeoutMs']) {
42
+ if (resolved[key] > MAX_TIMER_DELAY_MS)
43
+ throw new Error(`dsh-ptc-moejs: ${key} must be at most ${MAX_TIMER_DELAY_MS}`);
44
+ }
45
+ for (const key of ['memoryLimitMb', 'maxLogBytes', 'maxValueBytes', 'maxPendingCalls', 'maxFrameBytes']) {
46
+ if (!Number.isSafeInteger(resolved[key]))
47
+ throw new Error(`dsh-ptc-moejs: ${key} must be an integer`);
48
+ }
49
+ if (!Number.isSafeInteger(resolved.memoryLimitMb * 1024 * 1024))
50
+ throw new Error('dsh-ptc-moejs: memoryLimitMb is too large');
51
+ if (resolved.maxFrameBytes > MAX_FRAME_BYTES)
52
+ throw new Error(`dsh-ptc-moejs: maxFrameBytes must be at most ${MAX_FRAME_BYTES}`);
53
+ for (const key of ['maxLogBytes', 'maxValueBytes']) {
54
+ if (resolved[key] > resolved.maxFrameBytes - FRAME_ENVELOPE) {
55
+ throw new Error(`dsh-ptc-moejs: ${key} must fit in a frame: at most maxFrameBytes - ${FRAME_ENVELOPE}`);
56
+ }
57
+ }
58
+ return resolved;
59
+ }
60
+ /**
61
+ * Validate the binding namespaces of a request and read each function once.
62
+ * Names follow the rules dsh shares across PTC runtimes, so a namespace list
63
+ * valid here is valid on every runtime.
64
+ * @param bindings - the request's namespaces.
65
+ * @returns the namespaces keyed by their global.
66
+ * @throws When a name is unusable, reserved or used twice.
67
+ */
68
+ export function validateBindings(bindings) {
69
+ const namespaces = new Map();
70
+ const injected = new Set();
71
+ const claim = (name, role) => {
72
+ if (typeof name !== 'string' || !IDENTIFIER.test(name) || PORTABLE_RESERVED_WORDS.has(name)) {
73
+ throw new Error(`dsh-ptc-moejs: ${role} ${JSON.stringify(name)} is not a usable identifier`);
74
+ }
75
+ if (RESERVED_BINDING_GLOBALS.has(name))
76
+ throw new Error(`dsh-ptc-moejs: reserved binding global ${JSON.stringify(name)}`);
77
+ if (injected.has(name))
78
+ throw new Error(`dsh-ptc-moejs: duplicate injected global ${JSON.stringify(name)}`);
79
+ injected.add(name);
80
+ return name;
81
+ };
82
+ for (const namespace of bindings) {
83
+ const global = claim(namespace.global, 'binding global');
84
+ let errorClass;
85
+ if (namespace.errorClass !== undefined) {
86
+ const name = claim(namespace.errorClass.name, 'binding error class');
87
+ const member = namespace.errorClass.memberNameProperty;
88
+ if (typeof member !== 'string' || member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) {
89
+ throw new Error(`dsh-ptc-moejs: binding error member property ${JSON.stringify(member)} is not usable`);
90
+ }
91
+ errorClass = { name, memberNameProperty: member };
92
+ }
93
+ // Null prototype: `__proto__` and `constructor` are ordinary names.
94
+ const functions = Object.create(null);
95
+ for (const name of Object.keys(namespace.functions)) {
96
+ const fn = namespace.functions[name];
97
+ if (typeof fn === 'function')
98
+ Object.defineProperty(functions, name, { value: fn, enumerable: true });
99
+ }
100
+ namespaces.set(global, { global, functions, ...errorClass === undefined ? {} : { errorClass } });
101
+ }
102
+ return namespaces;
103
+ }
104
+ /** The moejs PTC runtime: `ctx.ptcRuntime` backed by one moejs-ptc sidecar. */
105
+ export class MoejsPtcRuntime extends PtcRuntime {
106
+ static Config = MoejsConfig;
107
+ language = 'typescript';
108
+ isolation = 'process';
109
+ config;
110
+ sidecar;
111
+ disposed = false;
112
+ constructor(ctx, config) {
113
+ super(ctx);
114
+ this.config = resolveConfig(config);
115
+ const budgets = {
116
+ memoryLimitBytes: this.config.memoryLimitMb * 1024 * 1024,
117
+ maxLogBytes: this.config.maxLogBytes,
118
+ maxValueBytes: this.config.maxValueBytes,
119
+ maxPendingCalls: this.config.maxPendingCalls,
120
+ maxFrameBytes: this.config.maxFrameBytes,
121
+ };
122
+ this.sidecar = new Sidecar(resolveBinary(), budgets);
123
+ ctx.effect(() => {
124
+ this.sidecar.start();
125
+ return async () => {
126
+ this.disposed = true;
127
+ await this.sidecar.close('runtime disposed');
128
+ };
129
+ }, 'moejs ptc-runtime sidecar');
130
+ }
131
+ get executionInstructions() {
132
+ return 'The program is the body of an async TypeScript function with erasable syntax: enum and namespaces with values are syntax errors, and type annotations are not checked. '
133
+ + 'Top-level await and return are supported. '
134
+ + 'The program runs in moejs with the JavaScript standard library, the tool bindings and console.log, info, warn, error and debug. '
135
+ + 'Node APIs, require, import, process, timers and direct file or network access are absent: use the tools for files, commands and the network. '
136
+ + 'run_code takes no sandbox_permissions or justification with this runtime. '
137
+ + 'When the sandbox denies a tool call, retry that call with sandbox_permissions and justification in its own arguments. The user approves each such call. '
138
+ + 'Emit output with console.log(...) or return a lossless JSON value: plain objects, arrays, strings, finite numbers, booleans and null. '
139
+ + 'Only printed values and the returned value become program output. '
140
+ + `Each call starts a fresh runtime that may allocate ${this.config.memoryLimitMb} MiB in total, and variables from earlier calls are gone.`;
141
+ }
142
+ get timeout() {
143
+ return { defaultMs: Math.min(this.config.timeoutMs, this.config.maxTimeoutMs), maxMs: this.config.maxTimeoutMs };
144
+ }
145
+ /**
146
+ * Fill in the working directory and deadline of a request.
147
+ * @param request - program, bindings and optional choices.
148
+ * @returns the request with a cwd and a capped deadline, or null for none.
149
+ * @throws When the request names a sandbox policy, a relative cwd or an invalid timeout.
150
+ */
151
+ resolve(request) {
152
+ if (this.disposed)
153
+ throw new Error('dsh-ptc-moejs: resolve after disposal');
154
+ if (request.sandboxPolicy !== undefined) {
155
+ throw new Error('dsh-ptc-moejs rejects an explicit sandboxPolicy: its programs reach files through tools, which apply their own policy');
156
+ }
157
+ const cwd = request.cwd ?? process.cwd();
158
+ if (!isAbsolute(cwd))
159
+ throw new Error('dsh-ptc-moejs: cwd must be absolute');
160
+ const requested = request.timeoutMs;
161
+ if (requested !== undefined && requested !== null && (!Number.isFinite(requested) || requested <= 0)) {
162
+ throw new Error('dsh-ptc-moejs: timeoutMs must be a positive finite number');
163
+ }
164
+ const timeoutMs = requested === null ? null : Math.min(requested ?? this.config.timeoutMs, this.config.maxTimeoutMs);
165
+ return { ...request, cwd, timeoutMs };
166
+ }
167
+ /**
168
+ * Run a resolved program. Its outcome is a field of the result.
169
+ * @param spec - what resolve returned.
170
+ * @returns the program's logs and its value or failure.
171
+ * @throws When the spec was not resolved or its bindings break the seam's rules.
172
+ */
173
+ async run(spec) {
174
+ if (this.disposed)
175
+ throw new Error('dsh-ptc-moejs: run after disposal');
176
+ if (spec.sandboxPolicy !== undefined)
177
+ throw new Error('dsh-ptc-moejs: this runtime rejects an explicit sandboxPolicy');
178
+ const timeout = spec.timeoutMs;
179
+ if (typeof spec.program !== 'string' || !isAbsolute(spec.cwd)
180
+ || (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0 || timeout > this.config.maxTimeoutMs))) {
181
+ throw new Error('dsh-ptc-moejs: run requires a resolved program, cwd and timeout');
182
+ }
183
+ const namespaces = validateBindings(spec.bindings);
184
+ if (spec.signal?.aborted === true)
185
+ return { logs: [], error: { kind: 'abort', message: messageOf(spec.signal.reason) } };
186
+ return await this.sidecar.execute(spec.program, namespaces, { timeoutMs: timeout, signal: spec.signal });
187
+ }
188
+ }
189
+ export default MoejsPtcRuntime;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The host side of the sidecar protocol: validation of every frame the
3
+ * sidecar sends, the log ledger, and the result of a run. Frame shapes are
4
+ * described in the sidecar's package documentation (internal/sidecar).
5
+ * @module dsh-ptc-moejs/protocol
6
+ */
7
+ import type { PtcJsonValue, PtcRunFailure, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime';
8
+ /** The wire version this host speaks. */
9
+ export declare const PROTOCOL = 1;
10
+ /** Bytes around a value in one frame, reserved out of the frame budget. */
11
+ export declare const FRAME_ENVELOPE = 256;
12
+ /** One frame from the sidecar, rebuilt field by field. */
13
+ export type SidecarFrame = {
14
+ type: 'boot-ack';
15
+ protocol: number;
16
+ } | {
17
+ type: 'call';
18
+ run: number;
19
+ id: number;
20
+ global: string;
21
+ name: string;
22
+ args: PtcJsonValue;
23
+ } | {
24
+ type: 'log';
25
+ run: number;
26
+ text: string;
27
+ truncated: boolean;
28
+ } | {
29
+ type: 'done';
30
+ run: number;
31
+ value?: PtcJsonValue;
32
+ error?: PtcRunFailure;
33
+ };
34
+ /**
35
+ * Parse and validate one line from the sidecar.
36
+ * @param line - one JSON line, without its newline.
37
+ * @returns the rebuilt frame, or a string that says what is wrong with it.
38
+ */
39
+ export declare function parseFrame(line: string): SidecarFrame | string;
40
+ /** UTF-8 bytes of the JSON text of a value, which the log and value budgets count. */
41
+ export declare function jsonBytes(value: PtcJsonValue): number;
42
+ /** The marker ending a diagnostic cut to its budget. */
43
+ export declare const TRUNCATION_MARKER = "\u2026 [truncated]";
44
+ /**
45
+ * Cap a diagnostic at maxBytes of serialized JSON, marker included.
46
+ * @param message - the failure message.
47
+ * @param maxBytes - the value budget, which diagnostics share.
48
+ * @returns the message, or a prefix of it that ends with the marker.
49
+ */
50
+ export declare function capMessage(message: string, maxBytes: number): string;
51
+ /**
52
+ * The logs of one run under the log budget. The budget counts the logs as
53
+ * dsh serializes them, a JSON array of strings. The marker line that ends a
54
+ * cut run is outside the budget.
55
+ */
56
+ export declare class LogLedger {
57
+ private readonly maxBytes;
58
+ readonly logs: string[];
59
+ private left;
60
+ private cut;
61
+ constructor(maxBytes: number);
62
+ /** The line marking where capture stopped. */
63
+ static marker(maxBytes: number): string;
64
+ /**
65
+ * Admit one entry, or stop capture when it does not fit.
66
+ * @param text - one console line.
67
+ */
68
+ admit(text: string): void;
69
+ /** Stop capture and append the marker once. */
70
+ truncate(): void;
71
+ }
72
+ /**
73
+ * The result of a run that completed.
74
+ * @param ledger - the run's logs.
75
+ * @param value - the completion value, if any.
76
+ * @param maxValueBytes - the value budget.
77
+ * @returns the seam result, with output-limit for a value over the budget.
78
+ */
79
+ export declare function successResult(ledger: LogLedger, value: PtcJsonValue | undefined, maxValueBytes: number): PtcRunResult;
80
+ /**
81
+ * The result of a run that failed.
82
+ * @param ledger - the run's logs.
83
+ * @param failure - why it failed.
84
+ * @param maxValueBytes - the budget the message is capped at.
85
+ * @returns the seam result.
86
+ */
87
+ export declare function failureResult(ledger: LogLedger, failure: PtcRunFailure, maxValueBytes: number): PtcRunResult;
88
+ /** The text of an error or any thrown value. */
89
+ export declare function messageOf(error: unknown): string;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The host side of the sidecar protocol: validation of every frame the
3
+ * sidecar sends, the log ledger, and the result of a run. Frame shapes are
4
+ * described in the sidecar's package documentation (internal/sidecar).
5
+ * @module dsh-ptc-moejs/protocol
6
+ */
7
+ import { isJsonValue } from '@deepseek-ai/dsh-util-values';
8
+ /** The wire version this host speaks. */
9
+ export const PROTOCOL = 1;
10
+ /** Bytes around a value in one frame, reserved out of the frame budget. */
11
+ export const FRAME_ENVELOPE = 256;
12
+ /** The failure kinds a done frame may carry. A timeout here is the memory budget, which the sidecar enforces. */
13
+ const SIDECAR_KINDS = new Set(['exception', 'invalid-output', 'output-limit', 'timeout', 'protocol', 'worker-exit']);
14
+ function record(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ function positiveInteger(value) {
18
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
19
+ }
20
+ /**
21
+ * Parse and validate one line from the sidecar.
22
+ * @param line - one JSON line, without its newline.
23
+ * @returns the rebuilt frame, or a string that says what is wrong with it.
24
+ */
25
+ export function parseFrame(line) {
26
+ let raw;
27
+ try {
28
+ raw = JSON.parse(line);
29
+ }
30
+ catch {
31
+ return 'a frame is not JSON';
32
+ }
33
+ if (!record(raw))
34
+ return 'a frame is not an object';
35
+ if (raw.type === 'boot-ack') {
36
+ return typeof raw.protocol === 'number' ? { type: 'boot-ack', protocol: raw.protocol } : 'invalid boot-ack frame';
37
+ }
38
+ if (!positiveInteger(raw.run))
39
+ return 'a frame has no valid run id';
40
+ const run = raw.run;
41
+ switch (raw.type) {
42
+ case 'call':
43
+ if (!positiveInteger(raw.id) || typeof raw.global !== 'string' || typeof raw.name !== 'string')
44
+ return 'invalid call frame';
45
+ // JSON.parse turns "-0" into -0 and "1e400" into Infinity, and isJsonValue rejects both.
46
+ if (!Object.hasOwn(raw, 'args') || !isJsonValue(raw.args))
47
+ return 'call arguments are not lossless JSON';
48
+ return { type: 'call', run, id: raw.id, global: raw.global, name: raw.name, args: raw.args };
49
+ case 'log':
50
+ if (typeof raw.text !== 'string')
51
+ return 'invalid log frame';
52
+ return { type: 'log', run, text: raw.text, truncated: raw.truncated === true };
53
+ case 'done': {
54
+ const hasValue = Object.hasOwn(raw, 'value');
55
+ if (hasValue && !isJsonValue(raw.value))
56
+ return 'a completion value is not lossless JSON';
57
+ if (raw.error === undefined) {
58
+ return hasValue ? { type: 'done', run, value: raw.value } : { type: 'done', run };
59
+ }
60
+ const error = raw.error;
61
+ if (hasValue || !record(error) || typeof error.message !== 'string' || typeof error.kind !== 'string' || !SIDECAR_KINDS.has(error.kind)) {
62
+ return 'invalid done frame';
63
+ }
64
+ return { type: 'done', run, error: { kind: error.kind, message: error.message } };
65
+ }
66
+ default:
67
+ return 'a frame has an unknown type';
68
+ }
69
+ }
70
+ /** UTF-8 bytes of the JSON text of a value, which the log and value budgets count. */
71
+ export function jsonBytes(value) {
72
+ return Buffer.byteLength(JSON.stringify(value), 'utf8');
73
+ }
74
+ /** The marker ending a diagnostic cut to its budget. */
75
+ export const TRUNCATION_MARKER = '… [truncated]';
76
+ /**
77
+ * Cap a diagnostic at maxBytes of serialized JSON, marker included.
78
+ * @param message - the failure message.
79
+ * @param maxBytes - the value budget, which diagnostics share.
80
+ * @returns the message, or a prefix of it that ends with the marker.
81
+ */
82
+ export function capMessage(message, maxBytes) {
83
+ if (message.length * 6 + 2 <= maxBytes || jsonBytes(message) <= maxBytes)
84
+ return message;
85
+ let low = 0;
86
+ let high = Math.min(message.length, maxBytes);
87
+ // The longest prefix whose JSON, with the marker, fits.
88
+ while (low < high) {
89
+ const middle = Math.ceil((low + high) / 2);
90
+ if (jsonBytes(message.slice(0, middle) + TRUNCATION_MARKER) <= maxBytes)
91
+ low = middle;
92
+ else
93
+ high = middle - 1;
94
+ }
95
+ let end = low;
96
+ // Never split a surrogate pair.
97
+ const code = message.charCodeAt(end - 1);
98
+ if (end > 0 && code >= 0xd800 && code <= 0xdbff)
99
+ end -= 1;
100
+ return message.slice(0, end) + TRUNCATION_MARKER;
101
+ }
102
+ /**
103
+ * The logs of one run under the log budget. The budget counts the logs as
104
+ * dsh serializes them, a JSON array of strings. The marker line that ends a
105
+ * cut run is outside the budget.
106
+ */
107
+ export class LogLedger {
108
+ maxBytes;
109
+ logs = [];
110
+ left;
111
+ cut = false;
112
+ constructor(maxBytes) {
113
+ this.maxBytes = maxBytes;
114
+ // Each entry pays for its JSON string and the "[" or "," before it, so
115
+ // one byte stays for the closing "]".
116
+ this.left = maxBytes - 1;
117
+ }
118
+ /** The line marking where capture stopped. */
119
+ static marker(maxBytes) {
120
+ return `[dsh-ptc-moejs] log capture truncated at ${maxBytes} bytes`;
121
+ }
122
+ /**
123
+ * Admit one entry, or stop capture when it does not fit.
124
+ * @param text - one console line.
125
+ */
126
+ admit(text) {
127
+ if (this.cut)
128
+ return;
129
+ const cost = text.length + 3 > this.left ? undefined : jsonBytes(text) + 1;
130
+ if (cost === undefined || cost > this.left) {
131
+ this.truncate();
132
+ return;
133
+ }
134
+ this.left -= cost;
135
+ this.logs.push(text);
136
+ }
137
+ /** Stop capture and append the marker once. */
138
+ truncate() {
139
+ if (this.cut)
140
+ return;
141
+ this.cut = true;
142
+ this.logs.push(LogLedger.marker(this.maxBytes));
143
+ }
144
+ }
145
+ /**
146
+ * The result of a run that completed.
147
+ * @param ledger - the run's logs.
148
+ * @param value - the completion value, if any.
149
+ * @param maxValueBytes - the value budget.
150
+ * @returns the seam result, with output-limit for a value over the budget.
151
+ */
152
+ export function successResult(ledger, value, maxValueBytes) {
153
+ if (value === undefined)
154
+ return { logs: ledger.logs };
155
+ if (jsonBytes(value) > maxValueBytes) {
156
+ return { logs: ledger.logs, error: { kind: 'output-limit', message: `completion value exceeded ${maxValueBytes} bytes` } };
157
+ }
158
+ return { logs: ledger.logs, value };
159
+ }
160
+ /**
161
+ * The result of a run that failed.
162
+ * @param ledger - the run's logs.
163
+ * @param failure - why it failed.
164
+ * @param maxValueBytes - the budget the message is capped at.
165
+ * @returns the seam result.
166
+ */
167
+ export function failureResult(ledger, failure, maxValueBytes) {
168
+ return { logs: ledger.logs, error: { kind: failure.kind, message: capMessage(failure.message, maxValueBytes) } };
169
+ }
170
+ /** The text of an error or any thrown value. */
171
+ export function messageOf(error) {
172
+ if (error instanceof Error)
173
+ return error.message;
174
+ try {
175
+ return String(error);
176
+ }
177
+ catch {
178
+ return 'unrenderable error';
179
+ }
180
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * One long-lived moejs-ptc process and the runs in flight on it. Each run is
3
+ * a fresh moejs runtime inside the process. The host keeps the deadlines,
4
+ * aborts and binding calls, and validates every frame the process sends.
5
+ * @module dsh-ptc-moejs/sidecar
6
+ */
7
+ import type { PtcBindingErrorClass, PtcBindingFunction, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime';
8
+ /** The budgets the sidecar enforces, sent in its boot frame. */
9
+ export interface Budgets {
10
+ memoryLimitBytes: number;
11
+ maxLogBytes: number;
12
+ maxValueBytes: number;
13
+ maxPendingCalls: number;
14
+ maxFrameBytes: number;
15
+ }
16
+ /** A binding namespace after validation: its functions read once. */
17
+ export interface Namespace {
18
+ global: string;
19
+ functions: Record<string, PtcBindingFunction>;
20
+ errorClass?: PtcBindingErrorClass;
21
+ }
22
+ /** Options of one execution. */
23
+ export interface ExecuteOptions {
24
+ timeoutMs: number | null;
25
+ signal?: AbortSignal | undefined;
26
+ }
27
+ /** The moejs-ptc process of one provider instance. */
28
+ export declare class Sidecar {
29
+ private readonly binary;
30
+ private readonly budgets;
31
+ private child;
32
+ private exited;
33
+ private readonly runs;
34
+ private nextRun;
35
+ private booted;
36
+ private partial;
37
+ private partialBytes;
38
+ private stderrTail;
39
+ private closed;
40
+ constructor(binary: string, budgets: Budgets);
41
+ /** Start the process now rather than on the first run. */
42
+ start(): void;
43
+ /**
44
+ * Run one program and settle with its result.
45
+ * @param program - the program text.
46
+ * @param namespaces - the validated bindings, keyed by global.
47
+ * @param options - deadline and cancellation.
48
+ * @returns the program's logs and outcome.
49
+ */
50
+ execute(program: string, namespaces: Map<string, Namespace>, options: ExecuteOptions): Promise<PtcRunResult>;
51
+ /** Abort every run, end the process and wait until it has exited. */
52
+ close(reason: string): Promise<void>;
53
+ private send;
54
+ /** The process ended or broke: fail its runs; the next run starts a new one. */
55
+ private lost;
56
+ private onData;
57
+ private onLine;
58
+ /** An idle sidecar does not keep the host process alive. */
59
+ private updateRef;
60
+ }
package/lib/sidecar.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * One long-lived moejs-ptc process and the runs in flight on it. Each run is
3
+ * a fresh moejs runtime inside the process. The host keeps the deadlines,
4
+ * aborts and binding calls, and validates every frame the process sends.
5
+ * @module dsh-ptc-moejs/sidecar
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values';
9
+ import { FRAME_ENVELOPE, LogLedger, PROTOCOL, capMessage, failureResult, messageOf, parseFrame, successResult } from "./protocol.js";
10
+ /** How long a closing process may take before it is killed. */
11
+ const CLOSE_GRACE_MS = 2_000;
12
+ /** How much of the process's stderr a worker-exit message quotes. */
13
+ const STDERR_TAIL = 4_096;
14
+ const fatalUtf8 = new TextDecoder('utf-8', { fatal: true });
15
+ /** One run on the sidecar: its logs, its calls and how it ends. */
16
+ class Execution {
17
+ id;
18
+ namespaces;
19
+ budgets;
20
+ send;
21
+ ledger;
22
+ result;
23
+ settle;
24
+ settled = false;
25
+ nextCall = 1;
26
+ pending = 0;
27
+ constructor(id, namespaces, budgets, send) {
28
+ this.id = id;
29
+ this.namespaces = namespaces;
30
+ this.budgets = budgets;
31
+ this.send = send;
32
+ this.ledger = new LogLedger(budgets.maxLogBytes);
33
+ this.result = new Promise((resolve) => { this.settle = resolve; });
34
+ }
35
+ get done() { return this.settled; }
36
+ finish(result) {
37
+ if (this.settled)
38
+ return;
39
+ this.settled = true;
40
+ this.settle(result);
41
+ }
42
+ fail(failure) {
43
+ this.finish(failureResult(this.ledger, failure, this.budgets.maxValueBytes));
44
+ }
45
+ /** Stop the program on the sidecar and settle with the failure. */
46
+ stop(failure) {
47
+ if (this.settled)
48
+ return;
49
+ this.fail(failure);
50
+ this.send(JSON.stringify({ type: 'cancel', run: this.id }));
51
+ }
52
+ handle(frame) {
53
+ switch (frame.type) {
54
+ case 'log':
55
+ if (frame.truncated)
56
+ this.ledger.truncate();
57
+ else
58
+ this.ledger.admit(frame.text);
59
+ return;
60
+ case 'done':
61
+ if (frame.error !== undefined)
62
+ this.fail(frame.error);
63
+ else
64
+ this.finish(successResult(this.ledger, frame.value, this.budgets.maxValueBytes));
65
+ return;
66
+ case 'call':
67
+ this.call(frame);
68
+ }
69
+ }
70
+ call(frame) {
71
+ const functions = this.namespaces.get(frame.global)?.functions;
72
+ const fn = functions !== undefined && Object.hasOwn(functions, frame.name) ? functions[frame.name] : undefined;
73
+ const problem = frame.id !== this.nextCall ? `call ${frame.id} arrived out of sequence`
74
+ : fn === undefined ? `a call named the undeclared binding ${frame.global}.${frame.name}`
75
+ : this.pending >= this.budgets.maxPendingCalls ? `more than ${this.budgets.maxPendingCalls} calls were pending` : undefined;
76
+ if (problem !== undefined || fn === undefined) {
77
+ this.stop({ kind: 'protocol', message: `invalid call from the moejs sidecar: ${problem}` });
78
+ return;
79
+ }
80
+ this.nextCall += 1;
81
+ this.pending += 1;
82
+ void this.reply(frame.id, fn, frame.args).then((line) => {
83
+ this.pending -= 1;
84
+ if (!this.settled)
85
+ this.send(line);
86
+ });
87
+ }
88
+ async reply(id, fn, args) {
89
+ try {
90
+ const value = snapshotJsonValue(await fn(args));
91
+ if (value === undefined)
92
+ throw new Error('binding resolution must be lossless JSON');
93
+ const text = JSON.stringify(value);
94
+ const limit = this.budgets.maxFrameBytes - FRAME_ENVELOPE;
95
+ if (Buffer.byteLength(text, 'utf8') > limit)
96
+ throw new Error(`binding resolution exceeds ${limit} bytes of JSON`);
97
+ return `{"type":"reply","run":${this.id},"id":${id},"ok":true,"value":${text}}`;
98
+ }
99
+ catch (error) {
100
+ // The cap keeps the reply inside one frame.
101
+ return JSON.stringify({ type: 'reply', run: this.id, id, ok: false, message: capMessage(messageOf(error), this.budgets.maxValueBytes) });
102
+ }
103
+ }
104
+ }
105
+ /** The moejs-ptc process of one provider instance. */
106
+ export class Sidecar {
107
+ binary;
108
+ budgets;
109
+ child;
110
+ exited = Promise.resolve();
111
+ runs = new Map();
112
+ nextRun = 1;
113
+ booted = false;
114
+ partial = [];
115
+ partialBytes = 0;
116
+ stderrTail = '';
117
+ closed = false;
118
+ constructor(binary, budgets) {
119
+ this.binary = binary;
120
+ this.budgets = budgets;
121
+ }
122
+ /** Start the process now rather than on the first run. */
123
+ start() {
124
+ if (this.child !== undefined || this.closed)
125
+ return;
126
+ const child = spawn(this.binary, [], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
127
+ this.child = child;
128
+ this.booted = false;
129
+ this.partial = [];
130
+ this.partialBytes = 0;
131
+ this.stderrTail = '';
132
+ let markExited;
133
+ this.exited = new Promise((resolve) => { markExited = resolve; });
134
+ child.stdout.on('data', (chunk) => { this.onData(child, chunk); });
135
+ child.stderr.on('data', (chunk) => {
136
+ this.stderrTail = (this.stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL);
137
+ });
138
+ // A write to a process that died fails here. Its exit settles the runs.
139
+ child.stdin.on('error', () => { });
140
+ child.on('error', (error) => {
141
+ this.lost(child, { kind: 'worker-exit', message: `the moejs sidecar could not run: ${error.message}` });
142
+ markExited();
143
+ });
144
+ child.on('exit', (code, signal) => {
145
+ const stderr = this.stderrTail.trim();
146
+ const how = signal === null ? `with code ${code}` : `on signal ${signal}`;
147
+ this.lost(child, { kind: 'worker-exit', message: `the moejs sidecar exited ${how}${stderr === '' ? '' : `: ${stderr}`}` });
148
+ markExited();
149
+ });
150
+ child.stdin.write(JSON.stringify({ type: 'boot', protocol: PROTOCOL, ...this.budgets }) + '\n');
151
+ this.updateRef();
152
+ }
153
+ /**
154
+ * Run one program and settle with its result.
155
+ * @param program - the program text.
156
+ * @param namespaces - the validated bindings, keyed by global.
157
+ * @param options - deadline and cancellation.
158
+ * @returns the program's logs and outcome.
159
+ */
160
+ execute(program, namespaces, options) {
161
+ const run = new Execution(this.nextRun++, namespaces, this.budgets, line => this.send(line));
162
+ if (this.closed) {
163
+ run.fail({ kind: 'abort', message: 'runtime disposed' });
164
+ return run.result;
165
+ }
166
+ this.start();
167
+ const frame = JSON.stringify({
168
+ type: 'run',
169
+ run: run.id,
170
+ program,
171
+ namespaces: [...namespaces.values()].map(ns => ({
172
+ global: ns.global,
173
+ names: Object.keys(ns.functions),
174
+ ...ns.errorClass === undefined ? {} : { errorClass: ns.errorClass },
175
+ })),
176
+ });
177
+ if (Buffer.byteLength(frame, 'utf8') > this.budgets.maxFrameBytes) {
178
+ run.fail({ kind: 'exception', message: `the program and its bindings exceed ${this.budgets.maxFrameBytes} bytes (maxFrameBytes)` });
179
+ return run.result;
180
+ }
181
+ this.runs.set(run.id, run);
182
+ const timer = options.timeoutMs === null ? undefined : setTimeout(() => {
183
+ run.stop({ kind: 'timeout', message: `execution deadline reached (${options.timeoutMs}ms)` });
184
+ }, options.timeoutMs);
185
+ const signal = options.signal;
186
+ const onAbort = () => { run.stop({ kind: 'abort', message: messageOf(signal?.reason) }); };
187
+ signal?.addEventListener('abort', onAbort, { once: true });
188
+ this.send(frame);
189
+ this.updateRef();
190
+ return run.result.finally(() => {
191
+ clearTimeout(timer);
192
+ signal?.removeEventListener('abort', onAbort);
193
+ this.runs.delete(run.id);
194
+ this.updateRef();
195
+ });
196
+ }
197
+ /** Abort every run, end the process and wait until it has exited. */
198
+ async close(reason) {
199
+ this.closed = true;
200
+ for (const run of this.runs.values())
201
+ run.stop({ kind: 'abort', message: reason });
202
+ const child = this.child;
203
+ const killer = child === undefined ? undefined : setTimeout(() => { child.kill('SIGKILL'); }, CLOSE_GRACE_MS);
204
+ child?.stdin.end();
205
+ await this.exited;
206
+ clearTimeout(killer);
207
+ }
208
+ send(line) {
209
+ this.child?.stdin.write(line + '\n');
210
+ }
211
+ /** The process ended or broke: fail its runs; the next run starts a new one. */
212
+ lost(child, failure) {
213
+ if (this.child !== child)
214
+ return;
215
+ this.child = undefined;
216
+ for (const run of this.runs.values())
217
+ run.fail(failure);
218
+ if (child.exitCode === null && child.signalCode === null)
219
+ child.kill('SIGKILL');
220
+ }
221
+ onData(child, chunk) {
222
+ let start = 0;
223
+ for (let newline = chunk.indexOf(10); newline !== -1; newline = chunk.indexOf(10, start)) {
224
+ const piece = chunk.subarray(start, newline);
225
+ const line = this.partial.length === 0 ? piece : Buffer.concat([...this.partial, piece]);
226
+ this.partial = [];
227
+ this.partialBytes = 0;
228
+ start = newline + 1;
229
+ this.onLine(child, line);
230
+ if (this.child !== child)
231
+ return;
232
+ }
233
+ if (start < chunk.length) {
234
+ this.partialBytes += chunk.length - start;
235
+ if (this.partialBytes > this.budgets.maxFrameBytes) {
236
+ this.lost(child, { kind: 'protocol', message: `the moejs sidecar sent a frame longer than ${this.budgets.maxFrameBytes} bytes` });
237
+ return;
238
+ }
239
+ this.partial.push(Buffer.from(chunk.subarray(start)));
240
+ }
241
+ }
242
+ onLine(child, bytes) {
243
+ let frame;
244
+ try {
245
+ frame = parseFrame(fatalUtf8.decode(bytes));
246
+ }
247
+ catch {
248
+ frame = 'a frame is not valid UTF-8';
249
+ }
250
+ if (typeof frame !== 'string' && frame.type === 'boot-ack') {
251
+ if (frame.protocol === PROTOCOL && !this.booted) {
252
+ this.booted = true;
253
+ return;
254
+ }
255
+ frame = this.booted ? 'a second boot-ack' : `the sidecar speaks protocol ${frame.protocol}, this host ${PROTOCOL}`;
256
+ }
257
+ else if (!this.booted) {
258
+ frame = 'a frame came before the boot-ack';
259
+ }
260
+ if (typeof frame === 'string') {
261
+ this.lost(child, { kind: 'protocol', message: `invalid frame from the moejs sidecar: ${frame}` });
262
+ return;
263
+ }
264
+ // A cancel can cross a run's frames on the wire, so frames of a run that has ended are dropped.
265
+ const run = this.runs.get(frame.run);
266
+ if (run !== undefined && !run.done)
267
+ run.handle(frame);
268
+ }
269
+ /** An idle sidecar does not keep the host process alive. */
270
+ updateRef() {
271
+ const child = this.child;
272
+ if (child === undefined)
273
+ return;
274
+ const busy = this.runs.size > 0;
275
+ for (const handle of [child, child.stdout, child.stderr, child.stdin]) {
276
+ if (busy)
277
+ handle.ref();
278
+ else
279
+ handle.unref();
280
+ }
281
+ }
282
+ }
package/package.json CHANGED
@@ -1,6 +1,76 @@
1
1
  {
2
2
  "name": "dsh-ptc-moejs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "PTC runtime for the DeepSeek Harness that runs model-written programs in moejs, a pure-Go JavaScript engine, inside one long-lived sidecar process",
5
+ "keywords": [
6
+ "dsh-plugin",
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "ptc",
10
+ "moejs",
11
+ "javascript",
12
+ "typescript"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Calcium-Ion/dsh-ptc-moejs.git"
18
+ },
19
+ "type": "module",
20
+ "main": "lib/index.js",
21
+ "types": "lib/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./lib/index.d.ts",
25
+ "default": "./lib/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "lib/**/*.js",
31
+ "lib/**/*.d.ts",
32
+ "cordis.patch.yml",
33
+ "README.zh_CN.md"
34
+ ],
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ }
39
+ },
40
+ "engines": {
41
+ "node": "^22.19.0 || >=24.0.0",
42
+ "dsh": ">=0.2.1-rc.2 <0.3.0"
43
+ },
44
+ "peerDependencies": {
45
+ "@deepseek-ai/cordis": "~4.0.5",
46
+ "@deepseek-ai/dsh-ptc-runtime": ">=0.2.1-rc.2 <0.3.0"
47
+ },
48
+ "dependencies": {
49
+ "@deepseek-ai/dsh-util-values": ">=0.2.1-rc.2 <0.3.0",
50
+ "@deepseek-ai/schemastery": "~3.18.5"
51
+ },
52
+ "optionalDependencies": {
53
+ "dsh-ptc-moejs-darwin-arm64": "0.1.0",
54
+ "dsh-ptc-moejs-darwin-x64": "0.1.0",
55
+ "dsh-ptc-moejs-linux-arm64": "0.1.0",
56
+ "dsh-ptc-moejs-linux-x64": "0.1.0",
57
+ "dsh-ptc-moejs-win32-x64": "0.1.0"
58
+ },
59
+ "devDependencies": {
60
+ "@deepseek-ai/cordis": "~4.0.5",
61
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.2",
62
+ "@types/node": "^22.20.1",
63
+ "typescript": "^6.0.3",
64
+ "vitest": "^4.1.11"
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.build.json",
68
+ "build:sidecar": "node scripts/build-binaries.mjs --host",
69
+ "build:binaries": "node scripts/build-binaries.mjs",
70
+ "typecheck": "tsc -p tsconfig.json",
71
+ "test": "vitest run",
72
+ "test:go": "go test ./...",
73
+ "e2e": "node e2e/run.mjs",
74
+ "bench": "node bench/bench.mjs"
75
+ }
6
76
  }