better-dsh 0.3.2 → 0.3.3-c

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.
@@ -1,6 +1,9 @@
1
1
  # ADR 0015 — Independent bundles and Agent-owned remote tools
2
2
 
3
- Status: accepted design (2026-10-10); consumer acceptance tracked separately.
3
+ Status: accepted design (2026-10-10); consumer acceptance tracked separately. The
4
+ companion-install and Super remote-tool-row legs are superseded by
5
+ [ADR 0016](0016-two-independent-packages-no-companion.md) (2026-11-02); the
6
+ Better-owned remote tools and the hub-target integration are retained.
4
7
 
5
8
  ## Context
6
9
 
@@ -4,31 +4,47 @@ Better Dash and Super Dash each provide the native `remote` tool and REPL route.
4
4
 
5
5
  ## Choose a call
6
6
 
7
+ `remote` declares its action; `remote_transfer` declares its mode. Nothing dispatches on which fields happen to be present.
8
+
7
9
  | Goal | Input |
8
10
  | --- | --- |
9
- | List locally known targets without dialing | `{}` |
10
- | Probe one target | `{target:"ssh:alias"}` |
11
- | Register an Agent-local SSH identity (no dialing) | `{target:"ssh:build",ssh:{host:"host",user:"user",key:"/origin/key"}}`; omit `target` for an auto id (`ssh-xxxx`) |
12
- | Stateless command and stdin over an identity | `{target:"ssh:build",cmd:"cat",stdin:"hello"}` |
13
- | Stream a local script to fixed `bash -s` | `{target:"ssh:alias",scriptPath:"/absolute/script.sh"}` |
14
- | Create a persistent shell (tab semantics) | `{target:"ssh:alias:pty",cmd:"cd /tmp; export CHECK=ready"}`; append `:label` (`ssh:alias:pty:build`) to name it |
15
- | Reuse a persistent shell | `{target:"pty:<id\|label>",cmd:"…"}` |
16
- | Session card (state + ledger, no command) | `{target:"pty:<id\|label>"}` |
17
- | Create a BYO PTY from a spawn command | `{spawn:"docker exec -it img bash",target:"pty:build",cmd:"…"}`; later `{target:"pty:build",cmd:"…"}` |
18
- | Background job (oneshot or PTY) | `{target:"ssh:alias",cmd:"long-command",run_in_background:true}` |
19
- | Close a PTY session | `{action:"close",target:"pty:<id\|label>",force:true}` |
20
- | Native CLI copy | `{action:"copy",src:"/absolute/source",dst:"ssh:alias:/absolute/destination",argv:["-p"]}` |
21
- | Native rsync | `{action:"rsync",src:"/absolute/source/",dst:"ssh:alias:/existing/destination/",argv:["-a","--partial","--dry-run"]}` |
22
-
23
- `mode` no longer exists: append `:pty` to a transport target to create, address a live shell as `pty:<id|label>`. `scriptPath` cannot combine with cmd/stdin/spawn/PTY. Direct local command execution is not provided; use native local shell tools. Background jobs require the official jobs service/controller and are read/killed with `job_output`/`job_kill`; on PTY lanes a background job's jobId is the commandId (a timeout handoff keeps them as separate ledger fields). SSH keys/config stay on the origin; BYO identities are not persisted across new/cold Agents. PTY backlog recovery and live probes are not part of this release.
24
-
25
- PTY state is durable and readable: every dispatch returns exit, cwd and `envDelta` (variable names and change markers only — values never leave the endpoint; fetch them yourself if needed). `close` is idempotent (`closed:false, reason:"not-found"|"already-closed-or-reaped"`); a session with a running job refuses close unless `force:true`, which kills the group and reports `killed`. A busy session rejects new commands instead of queueing; open another PTY or use a oneshot. Idle PTYs are reaped after 15 minutes; an Agent holds at most 10 live sessions across connections, and dead sessions do not count. `hub:<m4>:pty` selects the Hub lane's PTY but exposes no session id, label, parallelism or background works there.
26
-
27
- Native/PTY output retains at most 4,000,000 UTF-16 units per stream; Hub oneshot retains 4 MiB bytes. Normal returned tails are smaller. Read `outputStats` units and dropped/returned counters rather than treating character counts as bytes. Discarded output is not recoverable after restart.
11
+ | List locally known targets without dialing | `remote {action:"status"}` |
12
+ | Probe one target / show a session card | `remote {action:"status",target:"ssh:alias"}`, `remote {action:"status",target:"pty:<id\|label>"}` |
13
+ | Register an Agent-local SSH identity (no dialing) | `remote {action:"add",target:"ssh:build",ssh:{host:"host",user:"user",key:"/origin/key"}}`; omit `target` for an auto id (`ssh-xxxx`) |
14
+ | Register a BYO PTY from a spawn command | `remote {action:"add",spawn:"docker exec -it img bash",target:"pty:build"}`; then address it like any session |
15
+ | Stateless command and stdin over an identity | `remote {action:"exec",target:"ssh:build",cmd:"cat",stdin:"hello"}` |
16
+ | Run a local script as one `bash -c` command (stdin = the script's own input) | `remote {action:"exec",target:"ssh:alias",scriptPath:"/absolute/script.sh",stdin:"payload\n"}` |
17
+ | Use a machine's persistent shell (default PTY) | `remote {action:"exec",target:"ssh:alias",cmd:"cd /tmp; export CHECK=ready"}` — consecutive execs reuse one shell (cwd/env persist) |
18
+ | Reuse a persistent shell | `remote {action:"exec",target:"pty:<id\|label>",cmd:"…"}` |
19
+ | Answer a prompt / feed a running command (password, OTP, token, choice, REPL line, `\u0003`/`\u0004`) | `remote {action:"exec",target:"pty:<id\|label>",stdin:"the-answer\n"}` (stdin-only raw write) — optional `commandId` guards against stale prompts |
20
+ | Close a PTY session | `remote {action:"close",target:"pty:<id\|label>",force:true}` |
21
+ | Native CLI copy | `remote_transfer {mode:"copy",src:"/absolute/source",dst:"ssh:alias:/absolute/destination",argv:["-p"]}` |
22
+ | Native rsync | `remote_transfer {mode:"rsync",src:"/absolute/source/",dst:"ssh:alias:/existing/destination/",argv:["-a","--partial","--dry-run"]}` |
23
+
24
+ ## PTY is a persistent, interactive terminal session
25
+
26
+ Prefer the PTY lane. A PTY dispatch returns synchronously with the same receipt a oneshot returns — output text, exit code, duration — and only adds state: `session`, `cwd`, `envDelta`, `commandId`, `ttlSec`. Nothing is withheld at read time; the terminal merges stderr into the output stream, which is the only receipt difference. What the session buys is durability: cwd, exported environment, shell variables, activated virtualenvs, aliases and an open REPL survive between calls, so a multi-step task becomes N cheap calls instead of N stateless logins, and a command that outlives its timeout hands off to a pollable job instead of being killed. It is also the only interactive lane: a running command can be observed and then fed more stdin (`input`), which a oneshot never offers.
27
+
28
+ Interactive flow — see a prompt, answer it, read the response:
29
+
30
+ 1. Start the program: `exec` with `cmd` (foreground, `run_in_background`, or after a timeout handoff — identity is preserved either way). The running receipt carries the output so far plus `session` / `commandId` / `jobId`.
31
+ 2. Watch for the prompt in that output; use `job_output <jobId>` when you need more.
32
+ 3. Send the answer: `{action:"exec", target:"pty:<id|label>", stdin:"the-answer\n"}` (stdin alone = raw write; the `input` action is gone). `stdin` passes through verbatim — include the trailing `\n` to submit a line; `\u0003` is Ctrl-C and `\u0004` is Ctrl-D as raw characters; a literal backslash-x-0-4 stays four characters; `""` writes zero bytes (not EOF). The optional `commandId` must equal the running command's id; a stale id refuses the write instead of feeding the next command.
33
+ 4. Read the response with `job_output` on the SAME job. The input receipt only confirms N bytes were written — it never means the password was accepted or the login succeeded; judge from the program's output and the job's exit.
34
+
35
+ Honest limits of the input lane: Ctrl-D is not a universal EOF (on a line without a trailing newline it first delivers that text — send it again on the emptied line to end a reader like `cat`); Ctrl-C only interrupts where the terminal mode allows; `job_kill` remains the explicit cancel; `input` never creates, dials, reconnects or replays — address a live session as `pty:<id|label>`, any transport. A command waiting for input IS the busy state — a new `cmd` gets `[E_SESSION_BUSY]`; feed the running command instead of queueing a second one. On a oneshot, `stdin` is startup-only: known data may ride the initial `exec`, but a oneshot cannot be fed after it starts and its timeout still cancels the whole group — a prompt there means cancel and switch to PTY. Heredocs and input redirection remain valid bulk-input alternatives; there is no global stdin ban. Cancelling is reliable (0.3.2-d): `job_kill` writes its status marker after the terminal's ^C input flush, so even a cancel that follows a bare Ctrl-C settles `killed · exit 130` with the session alive — and hub-lane control bytes cross the local raw half as bytes, interpreted by the peer pty, so ^C/^D behave like every other transport.
36
+
37
+ Use the stateless oneshot when a step is genuinely one-shot (a probe, a pipeline stage, a supervised build you will not revisit) or when the `runner` supervision guarantee matters. On dumb-pipe transports the supervisor is the pinned `superd-run` helper (or its POSIX wrapper) — receipts say which ran. On the Hub lane the peer IS our own server: supervision is native there (`bash -lc` in a detached group, TERM→2s→KILL, output rings, lease lifecycle) and receipts say `hub-lease`; the helper is a dumb-pipe artifact and is never uploaded over the Hub. Note that `exec` always executes something: a warm session is created by running a command (`true` is enough), never by omitting `cmd`.
38
+
39
+ A machine target defaults to its reusable PTY; `mode:"oneshot"` selects the isolated stateless lane. The old `:pty` creation suffix and the `input` action are gone — multiple addressable shells come from `handoff {target:<machine>}` (detaches the default as `pty:<id>`), and prompt input is `exec` with `stdin` alone. `add` only registers (BYO identity or BYO PTY) and never runs a command. `scriptPath` is an alternative to `cmd` and may carry `stdin` (startup input rides after the script bytes) in both lanes. Direct local command execution is not provided; use native local shell tools. Background jobs require the official jobs service/controller and are read/killed with `job_output`/`job_kill`; on PTY lanes a background job's jobId is the commandId (a timeout handoff keeps them distinct until adoption backfills the job). SSH keys/config stay on the origin; BYO identities are not persisted across new/cold Agents. PTY backlog recovery and live probes are not part of this release.
40
+
41
+ PTY state is durable and readable: every dispatch returns exit, cwd and `envDelta` (variable names and change markers only — values never leave the endpoint; fetch them yourself if needed). `close` is idempotent (`closed:false, reason:"not-found"|"already-closed-or-reaped"`); a session with a running job refuses close unless `force:true`, which first interrupts the running command — its job settles in the killed family (`interrupted: exit 130`, or `session closed` when the frame cannot arrive) — and then kills the group. A busy session rejects new commands instead of queueing (`[E_SESSION_BUSY]` names the running id and the ways out: exec stdin-only, `job_output`, `job_kill`, handoff, or another session/oneshot); open another PTY or use a oneshot for parallel work. Idle PTYs are reaped after 15 minutes; an Agent holds at most 30 live sessions across connections (defaults and handed-off shells included; dead sessions do not count; the limit is disclosed only when reached). `hub:<m4>` as a machine target carries the same default-PTY semantics over the Hub WebSocket lane — identity, labels, stdin-only writes, parallelism and background work exactly like ssh/docker/incus/BYO.
42
+
43
+ Output returns IN FULL up to retention (0.3.2-e: the self-made 30K returned-tail window is removed — model-context sizing is the native agent runtime's job, e.g. the DSH spill policy or a foreign runtime's own guard). Native/PTY retains at most 4,000,000 UTF-16 units per stream; Hub oneshot 4 MiB bytes. Beyond retention the returned text is the retained tail, headed by a one-line disclosure banner, with `outputStats` giving total/retained/dropped/returned and `truncated` set to the original total. Read `outputStats` units and dropped/returned counters rather than treating character counts as bytes. Discarded output is not recoverable after restart.
28
44
 
29
45
  ## File operations: native CLI semantics
30
46
 
31
- `remote` addresses targets once (`ssh:<name>`, `ssh:<label>` for a BYO identity, `docker:<name>`, `incus:<name>`, `hub:<machine>`, local paths) and hands the actual transfer to the native CLI. `argv` is an ordered array of native option tokens — one element = one argv token, appended verbatim before the parsed `src`/`dst`. There is no option whitelist, no shell parsing, and no forced defaults: no hidden `--dry-run`, no forced archive/timeout, no automatic verification. A dry run means passing `--dry-run` yourself; unknown or incompatible options fail in the native CLI and its exit code and stderr come back as-is. Success of a native command is never reported as independently verified — `verified:false` on native copy receipts means exactly that.
47
+ `remote_transfer` addresses targets once (`ssh:<name>`, `ssh:<label>` for a BYO identity, `docker:<name>`, `incus:<name>`, `hub:<machine>`, local paths) and hands the actual transfer to the native CLI. `mode:"copy"|"rsync"` is required and spans only transfer fields. `argv` is an ordered array of native option tokens — one element = one argv token, appended verbatim before the parsed `src`/`dst`. There is no option whitelist, no shell parsing, and no forced defaults: no hidden `--dry-run`, no forced archive/timeout, no automatic verification. A dry run means passing `--dry-run` yourself; unknown or incompatible options fail in the native CLI and its exit code and stderr come back as-is. Success of a native command is never reported as independently verified — `verified:false` on native copy receipts means exactly that.
32
48
 
33
49
  ### copy routes
34
50
 
@@ -1,7 +1,9 @@
1
1
  # Remote 工具 enhancement — 交付、生命周期与传输规格
2
2
 
3
3
  - 日期:2026-10-10(Asia/Hong_Kong)
4
+ - **2026-11-02 修订(ADR 0016,user 决策)**:Super 不再注册模型侧 remote 工具——`superd-remote` 组合行与公开 `super-dsh/remote` 导出已删除,`remote`/`remote_transfer` 只由 Better Dash 交付;companion 自动/手动安装一并移除。Super 的生成闭包**保留为 hub 内部私有依赖**(hub exec/PTY/task/file lanes 引用其进程原语;无导出、无组合行,provenance 管线不变)。下文涉及 Super 组合行/公开导出与 companion 的条款仅作历史。Better 侧源码与「Super hub 在场即作 target」的检测逻辑不变。
4
5
  - 状态:2026-10-10 user 已审阅并明确授权执行;Task0–7 已独立审阅通过;Task8 完整双包消费者验收进行中,L 实装暴露的 worker/SSH cleanup 缺陷已进入修复验证。用户亲验与发布尚未放行。
6
+ - 2026-10-25 surface 修订(user 指令,本轮实现):`remote` 的判别字段改为**必填** `action: exec|add|close|status`;文件传输字段(`src`/`dst`/`argv`/`pullArgv`/`pushArgv`/`verify`/`mkdir`)迁到独立工具 `remote_transfer`,判别字段为**必填** `mode: copy|rsync`。`add` 只注册 BYO 身份/终端,不执行命令;`exec` 必填 `target` 且必须执行东西(warm 会话用 `cmd:"true"`)。工具简介把 PTY 重新定位为「stateful oneshot」并鼓励优先使用。下文 §5 的旧 `action:"copy"|"rsync"` 调用形与「无 action」形仅作历史。
5
7
  - 0.3.2-b 对齐(2026-10-19):`script` 已定名 `scriptPath`;`mode` 字段已移除(用 `<transport>:pty[:label]` 创建、`pty:<id|label>` 复用);`byossh:`/`byop:` 前缀已移除(`ssh`/`spawn` 参数在场即 BYO)。下文旧调用形仅作历史。
6
8
  - 输入:[2026-10-09 方向稿](../../10_plans/2026-10-09-remote-tool-optimization-directions.md)及 2026-10-10 会话裁决。
7
9
  - 执行计划:[2026-10-10-remote-tool-enhancement.md](../../superpowers/plans/2026-10-10-remote-tool-enhancement.md)。本规格优先于方向稿的冲突条款。
@@ -87,9 +87,9 @@ async function isPathUnder(path$1, root, caseSensitive = process.platform !== "w
87
87
  *
88
88
  * Per-call policy: `read-only` denies every mutation; `workspace-write` allows
89
89
  * a mutation only when the target canonicalizes under the policy's workspace
90
- * root or a platform temp area from the shared `writableRoots` policy;
91
- * `danger-full-access` delegates unfenced. A denial throws the structured
92
- * `FS_SANDBOX_DENIED`.
90
+ * root, an explicit additional writable root, or a platform temp area from the
91
+ * shared `writableRoots` policy; `danger-full-access` delegates unfenced. A
92
+ * denial throws the structured `FS_SANDBOX_DENIED`.
93
93
  *
94
94
  * @module @deepseek-ai/dsh-fs-sandbox
95
95
  */
package/lib/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { t as Schema } from "./index2.js";
2
2
  import { Context, Service } from "@deepseek-ai/cordis";
3
3
  import { ToolDefinition, ToolExecutionInput, ToolRunContext, ToolRuntime } from "@deepseek-ai/dsh-tools";
4
+ import { ToolExecutionPipeline } from "@deepseek-ai/dsh-tools/pipeline";
4
5
  import { ContentBlock, HarnessError } from "@deepseek-ai/dsh-llm";
5
6
  import { Scoped } from "@deepseek-ai/dsh-scope";
6
7
  import { Agent } from "@deepseek-ai/dsh-agent";
@@ -622,10 +623,10 @@ declare module '@deepseek-ai/cordis' {
622
623
  }
623
624
  /**
624
625
  * Capabilities the `eval` bridge closes over, mirroring upstream's
625
- * `RunCodeBridgeOptions` (the `requireRuntime` idiom): the registry-private
626
- * staged scheduler travels through the exported `TOOL_RUNTIME_SCHEDULER`
627
- * symbol-keyed property rather than a closure, because — unlike upstream —
628
- * the registry does not mint this tool for us.
626
+ * `RunCodeBridgeOptions` (the `requireRuntime` idiom): sub-dispatch rides a
627
+ * plugin-built `ToolExecutionPipeline` (0.2.2) rather than the pre-0.2.2
628
+ * registry-private staged scheduler, because — unlike upstream's PTC
629
+ * executor — the registry does not mint this tool for us.
629
630
  */
630
631
  interface RunCellBridgeOptions {
631
632
  /** Resolves `ctx.replRuntime` or throws the loud misconfiguration error (use-time read). */
@@ -649,17 +650,20 @@ interface RunCellBridgeOptions {
649
650
  /**
650
651
  * Build the `eval` {@link ToolDefinition}: required `cell` and
651
652
  * `description` parameters, executed through the dispatch bridge described
652
- * in the module doc. Sub-calls ride the registry's exported staged scheduler
653
- * (`prepare`/`dispatch`/`finalize`/`finish`) under the native concurrency
654
- * contract; each sub-dispatch is logged for reconstruction
655
- * (`tool/ptc-dispatch-start` / `tool/ptc-dispatch`) while only the outer
656
- * curated result enters model history.
657
- * @param registry - the host tool registry (sub-calls go through its staged
658
- * scheduler, bindings cover its registered tools).
653
+ * in the module doc. Sub-calls ride the plugin-built staged pipeline
654
+ * (`prepare`/`dispatch`/`complete` — the 0.2.2 `ToolExecutionPipeline`, the
655
+ * sanctioned successor of the retired `TOOL_RUNTIME_SCHEDULER` symbol) under
656
+ * the native concurrency contract; each sub-dispatch is logged for
657
+ * reconstruction (`tool/ptc-dispatch-start` / `tool/ptc-dispatch`) while only
658
+ * the outer curated result enters model history.
659
+ * @param registry - the host tool registry (bindings cover its registered
660
+ * tools; schema projection reads it live).
661
+ * @param pipeline - the staged invocation pipeline built over the same
662
+ * registry's mounting context (sub-calls dispatch through it).
659
663
  * @param options - the bridge capabilities described above.
660
664
  * @returns the registry-ready definition.
661
665
  */
662
- declare function createRunCellTool(registry: ToolRuntime, options: RunCellBridgeOptions): ToolDefinition;
666
+ declare function createRunCellTool(registry: ToolRuntime, pipeline: ToolExecutionPipeline, options: RunCellBridgeOptions): ToolDefinition;
663
667
  declare function apply(ctx: Context, config: Config): void;
664
668
  declare const _default: {
665
669
  name: string;
package/lib/index.js CHANGED
@@ -2,7 +2,6 @@ import { t as Schema } from "./lib.js";
2
2
  import { a as RESERVED_ERROR_MEMBERS, i as RESERVED_BINDING_GLOBALS, n as DUNDER_MEMBER, o as ReplRuntime, r as PORTABLE_RESERVED_WORDS, t as isFlatBindableName } from "./py-sdk2.js";
3
3
  import { n as resolveKernelEnv } from "./kernel-env2.js";
4
4
  import { t as captureAllTools } from "./native-capture.js";
5
- import { createRequire } from "node:module";
6
5
  import { Context } from "@deepseek-ai/cordis";
7
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
8
7
  import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
@@ -11,7 +10,8 @@ import { spawn } from "node:child_process";
11
10
  import { createServer } from "node:http";
12
11
  import { createInterface } from "node:readline";
13
12
  import { fileURLToPath } from "node:url";
14
- import { TOOL_RUNTIME_SCHEDULER, defineTool } from "@deepseek-ai/dsh-tools";
13
+ import { defineTool } from "@deepseek-ai/dsh-tools";
14
+ import { ToolExecutionPipeline } from "@deepseek-ai/dsh-tools/pipeline";
15
15
  import * as dshLlm from "@deepseek-ai/dsh-llm";
16
16
  import { BlockAssembler, HarnessError, createUserMessage } from "@deepseek-ai/dsh-llm";
17
17
  import { scopeTarget } from "@deepseek-ai/dsh-scope";
@@ -2165,17 +2165,20 @@ function renderValue(value) {
2165
2165
  /**
2166
2166
  * Build the `eval` {@link ToolDefinition}: required `cell` and
2167
2167
  * `description` parameters, executed through the dispatch bridge described
2168
- * in the module doc. Sub-calls ride the registry's exported staged scheduler
2169
- * (`prepare`/`dispatch`/`finalize`/`finish`) under the native concurrency
2170
- * contract; each sub-dispatch is logged for reconstruction
2171
- * (`tool/ptc-dispatch-start` / `tool/ptc-dispatch`) while only the outer
2172
- * curated result enters model history.
2173
- * @param registry - the host tool registry (sub-calls go through its staged
2174
- * scheduler, bindings cover its registered tools).
2168
+ * in the module doc. Sub-calls ride the plugin-built staged pipeline
2169
+ * (`prepare`/`dispatch`/`complete` — the 0.2.2 `ToolExecutionPipeline`, the
2170
+ * sanctioned successor of the retired `TOOL_RUNTIME_SCHEDULER` symbol) under
2171
+ * the native concurrency contract; each sub-dispatch is logged for
2172
+ * reconstruction (`tool/ptc-dispatch-start` / `tool/ptc-dispatch`) while only
2173
+ * the outer curated result enters model history.
2174
+ * @param registry - the host tool registry (bindings cover its registered
2175
+ * tools; schema projection reads it live).
2176
+ * @param pipeline - the staged invocation pipeline built over the same
2177
+ * registry's mounting context (sub-calls dispatch through it).
2175
2178
  * @param options - the bridge capabilities described above.
2176
2179
  * @returns the registry-ready definition.
2177
2180
  */
2178
- function createRunCellTool(registry, options) {
2181
+ function createRunCellTool(registry, pipeline, options) {
2179
2182
  const { requireRuntime, maxParallel, shapeDispatchLog, warn } = options;
2180
2183
  return defineTool({
2181
2184
  name: EVAL_NAME,
@@ -2320,16 +2323,6 @@ function createRunCellTool(registry, options) {
2320
2323
  parent: exec.token,
2321
2324
  signal: runController.signal
2322
2325
  };
2323
- const scheduler = registry[TOOL_RUNTIME_SCHEDULER];
2324
- if (scheduler === void 0) {
2325
- let resolvedFrom;
2326
- try {
2327
- resolvedFrom = createRequire(import.meta.url).resolve("@deepseek-ai/dsh-tools");
2328
- } catch {
2329
- resolvedFrom = "(unresolvable from the plugin position)";
2330
- }
2331
- throw new Error(`${EVAL_NAME}: the tools service mounted by this composition lacks the TOOL_RUNTIME_SCHEDULER symbol as this plugin sees it — REPL sub-dispatch cannot run. Most likely a dual-copy dsh-tools: the symbol keying the mounted ToolRuntime instance is not the symbol this plugin imported (named methods like executionMode keep working across copies; only the symbol-keyed field goes missing). This plugin resolves @deepseek-ai/dsh-tools from: ${resolvedFrom}. Check the deployed plugin's node_modules for stray @deepseek-ai entries (only schemastery and cosmokit belong there)`);
2332
- }
2333
2326
  const outcome = await new Promise((resolve$1, reject) => {
2334
2327
  let parked;
2335
2328
  const settle = (result) => {
@@ -2375,7 +2368,7 @@ function createRunCellTool(registry, options) {
2375
2368
  pendingQueue.push({
2376
2369
  flight: Promise.resolve(),
2377
2370
  settled: false,
2378
- classify: () => registry.executionMode(input).kind,
2371
+ classify: () => pipeline.executionMode(input).kind,
2379
2372
  abandon: () => {
2380
2373
  reject(/* @__PURE__ */ new Error(`${EVAL_NAME} run is over (${String(runController.signal.reason)}); ${name$1} tool call abandoned`));
2381
2374
  },
@@ -2393,9 +2386,9 @@ function createRunCellTool(registry, options) {
2393
2386
  arguments: normalized.logged
2394
2387
  });
2395
2388
  try {
2396
- const prepared = await scheduler.prepare(input);
2389
+ const prepared = await pipeline.prepare(input);
2397
2390
  if (prepared.kind === "dispatch") {
2398
- this.flight = scheduler.dispatch(prepared.exec).then((dispatchOutcome) => {
2391
+ this.flight = pipeline.dispatch(prepared.exec).then((dispatchOutcome) => {
2399
2392
  parked = {
2400
2393
  kind: dispatchOutcome.kind,
2401
2394
  exec: prepared.exec,
@@ -2422,7 +2415,7 @@ function createRunCellTool(registry, options) {
2422
2415
  async commit() {
2423
2416
  if (parked === void 0) return;
2424
2417
  try {
2425
- const result = parked.kind === "post-result" ? await scheduler.finalize(parked.exec, parked.result) : scheduler.finish(parked.exec, parked.result);
2418
+ const result = await pipeline.complete(parked.exec, parked);
2426
2419
  for (const context of result.additionalContexts ?? []) exec.deferContext(context);
2427
2420
  if (result.concludesTurn) exec.concludeTurn();
2428
2421
  settle(result);
@@ -2552,7 +2545,8 @@ function apply(ctx, config) {
2552
2545
  }
2553
2546
  return runtimeCtx.get("workflowEngine");
2554
2547
  };
2555
- runtimeCtx.tools.register(createRunCellTool(registry, {
2548
+ const pipeline = new ToolExecutionPipeline(runtimeCtx);
2549
+ runtimeCtx.tools.register(createRunCellTool(registry, pipeline, {
2556
2550
  requireRuntime,
2557
2551
  maxParallel,
2558
2552
  shapeDispatchLog,
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // Hub PTY pump — the DUMB child of the canonical PTY session over a Hub
3
+ // machine (0.3.2-c). The session (better-dsh/src/remote/pty-session.ts)
4
+ // spawns `script -qfec 'stty -echo; … exec node <this file>' /dev/null`, so
5
+ // this process's stdin/stdout ride a host-side PTY; we forward those bytes
6
+ // over one WebSocket to the peer hub's dumb PTY executor
7
+ // (/api/superd/exec/pty: one WS = one `script -qfec bash /dev/null`, text
8
+ // frames = stream bytes, {"op":…} JSON frames = transport control, WS close
9
+ // = peer group-kill). All session intelligence (nonce framing, init
10
+ // handshake, env E-frames, two-stage interrupt, idle TTL) is in-band bytes
11
+ // produced by the SESSION — this pump never parses the stream, only the
12
+ // peer's control frames.
13
+ //
14
+ // Connection facts arrive via environment (never argv — credentials must not
15
+ // show up in `ps`):
16
+ // SUPERD_HUB_PTY_URL wss://…/api/superd/exec/pty (peer executor)
17
+ // SUPERD_HUB_PTY_COOKIE pairing cookie for the upgrade request
18
+ // SUPERD_HUB_PTY_WS ABSOLUTE path to a usable `ws` module (resolved by
19
+ // the caller before spawning; no resolution gamble
20
+ // here — a missing module is a creation-time error)
21
+ //
22
+ // Verbatim-asset discipline (helper-bin precedent): this file is copied
23
+ // byte-for-byte into lib/remote/ and the Super closure; no build step
24
+ // transforms it. Crash-proof by construction (mesh-gateway lesson): the
25
+ // 'error' listener is registered before anything can reject, and post-close
26
+ // sends are swallowed — the session's death path owns the teardown.
27
+ const PUMP_ENV_PREFIX = 'SUPERD_HUB_PTY_'
28
+ const [url, cookie, wsPath] = ['URL', 'COOKIE', 'WS'].map((k) => process.env[`SUPERD_HUB_PTY_${k}`])
29
+
30
+ // cookie may be legitimately empty (an unauthenticated test lane sends no header);
31
+ // the endpoint URL and the ws module path are the hard requirements.
32
+ if (url === undefined || url === '' || wsPath === undefined || wsPath === '') {
33
+ process.stderr.write(`hub-pty-pump: missing environment (${PUMP_ENV_PREFIX}URL/WS)\n`)
34
+ process.exit(1)
35
+ }
36
+
37
+ let WebSocket
38
+ try {
39
+ const require = (await import('node:module')).createRequire(import.meta.url)
40
+ const mod = require(wsPath)
41
+ WebSocket = typeof mod === 'function' ? mod : mod.default
42
+ } catch (err) {
43
+ process.stderr.write(`hub-pty-pump: cannot load ws module at ${wsPath} — ${err instanceof Error ? err.message : String(err)}\n`)
44
+ process.exit(1)
45
+ }
46
+ if (typeof WebSocket !== 'function') {
47
+ process.stderr.write('hub-pty-pump: the ws module did not yield a WebSocket constructor\n')
48
+ process.exit(1)
49
+ }
50
+
51
+ const socket = new WebSocket(url, { headers: cookie ? { cookie } : {} })
52
+ let opened = false
53
+ let forward = true
54
+
55
+ socket.on('error', (err) => {
56
+ // Always-on listener: an ECONNREFUSED or a rejected 401 upgrade must be a
57
+ // reported death, never an uncaught exception.
58
+ process.stderr.write(`hub-pty-pump: ${opened ? 'socket error' : 'connect failed'} — ${err instanceof Error ? err.message : String(err)}\n`)
59
+ })
60
+ socket.on('open', () => { opened = true; pumpStdin() })
61
+ socket.on('message', (data) => {
62
+ if (!forward) return
63
+ const text = typeof data === 'string' ? data : Array.isArray(data) ? Buffer.concat(data).toString('utf8') : Buffer.from(data).toString('utf8')
64
+ // Transport control frames (peer: {"op":"exit"} / {"op":"spawn-error"}) are
65
+ // addressed to THIS pump, not to the shell stream — the exit frame is
66
+ // followed by a socket close, which settles us below.
67
+ if (text.startsWith('{"op":')) {
68
+ try {
69
+ const parsed = JSON.parse(text)
70
+ if (typeof parsed.op === 'string') {
71
+ if (parsed.op === 'spawn-error' && typeof parsed.error === 'string') process.stderr.write(`hub-pty-pump: peer could not spawn the terminal — ${parsed.error}\n`)
72
+ return
73
+ }
74
+ } catch { /* not JSON after all: fall through and forward */ }
75
+ }
76
+ process.stdout.write(text)
77
+ })
78
+ socket.on('close', () => {
79
+ forward = false
80
+ // Before open this is a failed connection (diag already on stderr); after
81
+ // open it is the peer session ending — either way the child exiting is the
82
+ // session's dead signal.
83
+ process.exit(opened ? 0 : 1)
84
+ })
85
+
86
+ function pumpStdin() {
87
+ process.stdin.setEncoding('utf8')
88
+ process.stdin.on('data', (chunk) => {
89
+ if (!opened) return
90
+ try { socket.send(chunk) } catch { /* closing: the close handler exits us */ }
91
+ })
92
+ // The session's kill path tears down the process group; a plain stdin end
93
+ // (no kill) still means nobody will type more — close politely so the peer
94
+ // reaps its side.
95
+ process.stdin.on('end', () => { try { socket.close() } catch { /* already closing */ } })
96
+ process.stdin.on('error', () => { /* EPIPE on teardown: the close path owns the exit */ })
97
+ }