@deepseek-ai/dsh-tool-pwsh 0.1.6-alpha.2 → 0.1.7-alpha.2

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/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/shell/tool-pwsh/README.md
5
- README.md: a1b8755c50862bb318f72af90f3cdbae158e7746
6
- README.zh.md: efcf188b31365f6897eb14fb6482c973f23f6a7b
5
+ README.md: 26bf4e30eb03a6e1d36afa2a7c1ccd60a1673ac2
6
+ README.zh.md: fbf9fc61ba09ba2bc88daebffe46c52a64877208
package/README.md CHANGED
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
9
9
 
10
10
  ## Summary
11
11
 
12
- `dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin.
12
+ `dsh-tool-pwsh` lets the agent run PowerShell commands through a mounted shell executor. Each call uses a fresh process; with a job registry composed every command is a job from the moment it starts, so `run_in_background` returns the id at once and a foreground command that outlives its timeout returns the same id, with observable output. Commands use native Windows paths and `$env:NAME` variables without dialect translation. Calls receive the managed `DSH_*` environment, and sandboxed execution enforces Windows language-mode and named-pipe requirements. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin.
13
13
 
14
14
  ## Table of Contents
15
15
 
@@ -41,11 +41,12 @@ The common path is a PowerShell executor provider, the environment registry, and
41
41
  - name: '@deepseek-ai/dsh-tool-pwsh'
42
42
  ```
43
43
 
44
- The single config field toggles background support.
44
+ The config fields govern the background surface.
45
45
 
46
46
  | Field | Default | Meaning |
47
47
  |---|---|---|
48
- | `enableRunInBackground` | `true` | Expose `run_in_background`; when `false`, forced background calls are rejected |
48
+ | `enableRunInBackground` | `true` | Expose `run_in_background` while a job registry is composed; when `false`, forced background calls are rejected |
49
+ | `promoteOnTimeout` | `true` | Keep a foreground command that reaches its timeout running as its background job instead of killing it |
49
50
 
50
51
  The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-pwsh) is the exhaustive source for every accepted field and its JSDoc; the generated [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh) carries the full argument schema.
51
52
 
@@ -53,6 +54,10 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
53
54
 
54
55
  The tool executes `pwsh -Command <command>` and returns the combined output. Commands run in a fresh pwsh process every call, so state never persists — pass `workdir` instead of `cd`. Paths use native Windows form and environment variables are read with `$env:NAME`. A non-zero exit is reported as `[exit code: N]`; on Windows a force-killed command settles as `[exit code: 1]` without a signal marker, so the agent treats a bare exit 1 after an interruption as a termination, not a command failure. Background runs, output truncation, and the `description`/`timeoutMs`/`workdir` arguments behave exactly as in [`dsh-tool-bash`](../tool-bash/README.md#running-long-commands-in-the-background), including job-owned cancellation during asynchronous shell preparation.
55
56
 
57
+ ### Foreground commands as jobs
58
+
59
+ With a job registry composed, a foreground command is registered with `ctx.jobs` at its start and the call waits on that job: the command is listed, streams through `job.list` and `job.follow`, and can be stopped from the Web task list for as long as it runs. A command that finishes within the timeout returns the ordinary foreground result and its job record leaves the registry with it, so the model never sees an id. A command that outlives the timeout keeps running as the job it already was, and the call returns `[still running after <timeoutMs>ms; moved to background job <id>]` plus the job hand-off guidance, seeded with one consuming read of the output so far — `job_output` continues exactly after it. A kill from outside the call (the human stopping the job) settles the foreground result with `[stopped: <reason>]` ahead of the exit marker, so the model reads the reason instead of a command failure; cancelling the call itself kills the job. Registration is best-effort: `promoteOnTimeout: false`, a missing job registry, or a registry that refuses the job at its start (the owner's job limit, no controller) run the command under the executor's deadline kill instead, and the tool description advertises the hand-over only when it holds.
60
+
56
61
  ### Windows-specific sandbox behavior
57
62
 
58
63
  Under a sandboxing executor, denied commands report `[sandbox: file access denied under <mode> mode]`, and the same one-shot escalation path applies: retry the exact command once with `sandbox_permissions` plus a `justification` through user approval. The tool also teaches two Windows-restricted-token contracts in its description: read-only pwsh runs in ConstrainedLanguage (`.NET` static calls, `Add-Type`, COM, and reflection fail with "only core types" errors), and in both confined modes programs cannot open named pipes, so a command that captures another program's output through piped stdio fails with EPERM — escalate the exact command once or restructure it to avoid capturing output.
@@ -83,7 +88,7 @@ This section explains the design decisions behind the tool and points at the cod
83
88
  | File | Role |
84
89
  |---|---|
85
90
  | [`src/index.ts`](src/index.ts) | Plugin entry: tool registration, prompt section, arg validation, escalation, request assembly |
86
- | [`src/background.ts`](src/background.ts) | Map a settled background process onto generic job outcome vocabulary |
91
+ | [`src/background.ts`](src/background.ts) | Map a settled process onto generic job outcome vocabulary and render a ring read as a process read |
87
92
  | [`src/render.ts`](src/render.ts) | Model-facing result text: streams, markers, truncation notices (bash twin) |
88
93
  | — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. |
89
94
 
@@ -152,7 +157,7 @@ Prefix-stable while visibility and the tool definition are unchanged. A restrict
152
157
 
153
158
  #### What the model sees
154
159
 
155
- The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path-or-(unavailable)>]`, `[sandbox: file access denied under <mode> mode]` plus the escalation hint `[sandbox: escalation available — …]` (only when the composition advertises escalation), `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
160
+ The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path-or-(unavailable)>]`, `[sandbox: file access denied under <mode> mode]` plus the escalation hint `[sandbox: escalation available — …]` (only when the composition advertises escalation), `[timed out after <timeoutMs>ms]`, `[stopped: <reason>]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
156
161
 
157
162
  #### Token effect
158
163
 
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@ kind: "package-reference"
9
9
 
10
10
  ## 概述
11
11
 
12
- `dsh-tool-pwsh` 为 agent(智能体)提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会向模型说明并强制执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。
12
+ `dsh-tool-pwsh` 让 agent(智能体)通过已挂载的 shell 执行器运行 PowerShell 命令。每次调用使用全新进程;组合中有 job 注册表时,每条命令从启动那一刻起就是一个任务,因此 `run_in_background` 立即返回 id,超过超时仍在运行的前台命令返回同一个 id,输出可观测。命令使用原生 Windows 路径和 `$env:NAME` 变量,不做方言翻译。调用获得受管 `DSH_*` 环境,沙箱执行会落实 Windows 语言模式与命名管道要求。请与 `dsh-pwsh-local` 等 PowerShell 执行器及 `dsh-shell-env` 插件一起挂载。
13
13
 
14
14
  ## 目录
15
15
 
@@ -41,11 +41,12 @@ kind: "package-reference"
41
41
  - name: '@deepseek-ai/dsh-tool-pwsh'
42
42
  ```
43
43
 
44
- 唯一的配置字段用于开关后台支持。
44
+ 配置字段决定后台能力面。
45
45
 
46
46
  | 字段 | 默认值 | 含义 |
47
47
  |---|---|---|
48
- | `enableRunInBackground` | `true` | 暴露 `run_in_background`;为 `false` 时拒绝强制后台调用 |
48
+ | `enableRunInBackground` | `true` | 组合中有 job 注册表时暴露 `run_in_background`;为 `false` 时拒绝强制后台调用 |
49
+ | `promoteOnTimeout` | `true` | 到达超时的前台命令继续作为它的后台任务运行,而不是杀掉它 |
49
50
 
50
51
  生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-pwsh)是每个受支持字段及其 JSDoc 的穷尽式真源;生成的[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-pwsh)携带完整参数 schema。
51
52
 
@@ -53,6 +54,10 @@ kind: "package-reference"
53
54
 
54
55
  工具执行 `pwsh -Command <command>` 并返回合并后的输出。命令每次调用都运行在全新 pwsh 进程中,因此状态从不保留——请传 `workdir` 而不是 `cd`。路径使用原生 Windows 形式,环境变量用 `$env:NAME` 读取。非零退出以 `[exit code: N]` 报告;在 Windows 上,强制终止的命令以 `[exit code: 1]` 结算且没有信号标记,因此 agent 把中断后的裸 exit 1 当作终止而非命令失败。后台运行、输出截断以及 `description`/`timeoutMs`/`workdir` 参数的行为与 [`dsh-tool-bash`](../tool-bash/README.zh.md#running-long-commands-in-the-background) 完全一致,包括异步 shell 准备过程中由任务负责的取消。
55
56
 
57
+ ### 前台命令即任务
58
+
59
+ 组合中有 job 注册表时,前台命令一启动就登记到 `ctx.jobs`,调用等待该任务:命令在运行期间始终被列出、经 `job.list` 与 `job.follow` 流式观看,并可从 Web 任务列表停止。在超时内完成的命令返回普通前台结果,其任务记录随结果一起离开注册表,模型从不看到 id。超过超时仍在运行的命令继续作为它本来就是的那个任务运行,调用返回 `[still running after <timeoutMs>ms; moved to background job <id>]` 加任务交接指引,并以一次消费式读取带上目前为止的输出——`job_output` 恰好从此处接续。来自调用之外的杀停(人在界面上停止任务)会让前台结果在退出标记之前带上 `[stopped: <reason>]`,模型读到的是原因而不是命令失败;取消调用本身则杀掉任务。登记是尽力而为的:`promoteOnTimeout: false`、缺少 job 注册表,或注册表在启动时拒绝该任务(持有者的任务上限、没有控制器)都会改为在执行器的 deadline 杀下运行命令,工具描述也只在交接语义成立时才宣传它。
60
+
56
61
  ### Windows 特有的沙箱行为
57
62
 
58
63
  在沙箱执行器下,被拒绝的命令会报告 `[sandbox: file access denied under <mode> mode]`,并适用相同的单次升权路径:用 `sandbox_permissions` 加一句 `justification`,经用户审批后重试完全相同的命令一次。工具还会在其描述中教授两条 Windows 受限令牌约定:只读 pwsh 运行在 ConstrainedLanguage 中(`.NET` 静态调用、`Add-Type`、COM 与反射会以 "only core types" 错误失败);两种受限模式下程序都无法打开命名管道,因此通过管道 stdio 捕获另一程序输出的命令会以 EPERM 失败——请升权该确切命令一次,或重构命令以避免捕获输出。
@@ -83,7 +88,7 @@ kind: "package-reference"
83
88
  | 文件 | 职责 |
84
89
  |---|---|
85
90
  | [`src/index.ts`](src/index.ts) | 插件入口:工具注册、提示词区段、参数校验、升权、请求组装 |
86
- | [`src/background.ts`](src/background.ts) | 把已结算的后台进程映射为通用任务结果词汇 |
91
+ | [`src/background.ts`](src/background.ts) | 把已结算的进程映射为通用任务结果词汇,并把输出环读取渲染为进程读取 |
87
92
  | [`src/render.ts`](src/render.ts) | 模型侧结果文本:流、标记、截断通知(bash 孪生) |
88
93
  | — | 不发布运行时不变式伴生入口;除所属 seam 强制执行的约定外,本包不公开独立的事件序列或可变数据关系。 |
89
94
 
@@ -152,7 +157,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be
152
157
 
153
158
  #### 模型看到什么
154
159
 
155
- 渲染器输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` 和 stderr 尾部。条件行精确为 `[output truncated; full output: <path-or-(unavailable)>]`、`[sandbox: file access denied under <mode> mode]` 加升权提示 `[sandbox: escalation available — …]`(仅在组合声明升权时)、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空正文渲染为 `(no output)`。
160
+ 渲染器输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` 和 stderr 尾部。条件行精确为 `[output truncated; full output: <path-or-(unavailable)>]`、`[sandbox: file access denied under <mode> mode]` 加升权提示 `[sandbox: escalation available — …]`(仅在组合声明升权时)、`[timed out after <timeoutMs>ms]`、`[stopped: <reason>]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空正文渲染为 `(no output)`。
156
161
 
157
162
  #### Token 影响
158
163
 
package/lib/index.js CHANGED
@@ -6,36 +6,98 @@ import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDen
6
6
  import { parseExitStatus } from "@deepseek-ai/dsh-shell";
7
7
  //#region lib/types/background.js
8
8
  /**
9
- * Generic-task adaptation for background pwsh process handles — the shell-agnostic
10
- * twin of `dsh-tool-bash`'s background adaptation.
9
+ * Generic-job adaptation for pwsh process handles — the shell-agnostic twin
10
+ * of `dsh-tool-bash`'s background adaptation: the terminal
11
+ * outcome the registry records and the pull sources it pumps.
11
12
  *
12
13
  * @module @deepseek-ai/dsh-tool-pwsh/background
13
14
  */
14
15
  /**
15
- * Map a settled background process onto the generic task-outcome vocabulary:
16
+ * Sandbox facts worth the terminal detail: a runner that never ran the
17
+ * command, or a denial (with the escalation hint this composition offers).
18
+ * @param sandbox - settled sandbox facts, when this was a confined process.
19
+ * @param escalationModes - escalation targets advertised by this composition.
20
+ * @returns the markers to append, oldest first.
21
+ */
22
+ function sandboxNotes(sandbox, escalationModes) {
23
+ if (sandbox?.runnerFailed) return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`];
24
+ if (sandbox?.denied) {
25
+ const notes = [sandboxDenialMarker(sandbox.mode)];
26
+ if (escalationModes.length > 0) notes.push(escalationHintMarker("command"));
27
+ return notes;
28
+ }
29
+ return [];
30
+ }
31
+ /**
32
+ * Map a settled background process onto the generic job-outcome vocabulary:
16
33
  * `killed` stays `killed` (detail: the signal when one is known), everything
17
34
  * else is `completed` with the exit code as detail. A nonzero command exit is
18
- * reported, not failed, exactly like the foreground rendering.
35
+ * reported, not failed, exactly like the foreground rendering. Sandbox facts
36
+ * join the detail, since a job's terminal reason is the one line every
37
+ * reader — the model's status line, the roster row — shows.
19
38
  * @param proc - the settled process handle.
39
+ * @param escalationModes - escalation targets advertised by this composition.
20
40
  * @returns the outcome for the `ctx.jobs` registration.
21
41
  */
22
- function processOutcome(proc) {
23
- if (proc.status === "killed") return {
42
+ function processOutcome(proc, escalationModes = []) {
43
+ const base = proc.status === "killed" ? {
24
44
  status: "killed",
25
45
  detail: proc.signal !== null ? `signal: ${proc.signal}` : "killed before exit"
26
- };
27
- return {
46
+ } : {
28
47
  status: "completed",
29
48
  detail: `exit code: ${proc.exitCode ?? 0}`
30
49
  };
50
+ const notes = sandboxNotes(proc.sandbox, escalationModes);
51
+ return notes.length === 0 ? base : {
52
+ ...base,
53
+ detail: `${base.detail}; ${notes.join(" ")}`
54
+ };
55
+ }
56
+ /**
57
+ * The process's non-consuming stream readers as registry pull sources. They
58
+ * bind lazily because the process is spawned inside the starter, after the
59
+ * registry admitted the job; a read before the spawn yields nothing, and the
60
+ * pump keeps the model's consuming cursor untouched. A rejected spawn's
61
+ * stderr reader carries the provider's `subprocess failed before reporting an
62
+ * outcome: …` note.
63
+ * @param proc - the started process's observed streams, once the starter has spawned it.
64
+ * @returns one source per stream, stdout first.
65
+ */
66
+ function processSources(proc) {
67
+ const source = (channel) => ({
68
+ channel,
69
+ read: (fromByte) => {
70
+ const live = proc();
71
+ return live === void 0 ? {
72
+ text: "",
73
+ nextOffset: fromByte,
74
+ lossy: false
75
+ } : live.observed[channel].readFrom(fromByte);
76
+ }
77
+ });
78
+ return [source("stdout"), source("stderr")];
79
+ }
80
+ /**
81
+ * The ring chunks of one consuming registry read as the shell tools render a
82
+ * process read: stdout chunks in order, then every stderr chunk in one
83
+ * `[stderr]` section, so the output a foreground call hands over when it
84
+ * stops waiting reads exactly like the `job_output` reads that follow it.
85
+ * @param chunks - the chunks since the model cursor, in offset order.
86
+ * @returns the delta text, possibly empty.
87
+ */
88
+ function ringDelta(chunks) {
89
+ const out = chunks.filter((chunk) => chunk.channel !== "stderr").map((chunk) => chunk.text).join("");
90
+ const err = chunks.filter((chunk) => chunk.channel === "stderr").map((chunk) => chunk.text).join("");
91
+ const separator = out.length > 0 && !out.endsWith("\n") ? "\n" : "";
92
+ return out + (err.length > 0 ? `${separator}[stderr]\n${err}` : "");
31
93
  }
32
94
  /**
33
95
  * Adapt asynchronous shell preparation after job admission without exposing a partial process.
34
96
  * @param start - starts the process with job-owned cancellation.
35
- * @param renderOutput - consumes output from a published process.
97
+ * @param outcome - projects the settled process into the job outcome.
36
98
  * @returns synchronous job hooks whose completion includes preparation and process settlement.
37
99
  */
38
- function processJob(start, renderOutput) {
100
+ function processJob(start, outcome) {
39
101
  const controller = new AbortController();
40
102
  let process;
41
103
  return {
@@ -52,15 +114,14 @@ function processJob(start, renderOutput) {
52
114
  } finally {
53
115
  await process.done;
54
116
  }
55
- return processOutcome(process);
117
+ return outcome(process);
56
118
  } catch (error) {
57
119
  return {
58
120
  status: controller.signal.aborted && process === void 0 ? "killed" : "failed",
59
121
  detail: error instanceof Error ? error.message : String(error)
60
122
  };
61
123
  }
62
- })(),
63
- readOutput: () => process === void 0 ? "" : renderOutput(process)
124
+ })()
64
125
  };
65
126
  }
66
127
  //#endregion
@@ -106,6 +167,7 @@ function renderPwshResult(result, escalationModes = []) {
106
167
  if (escalationModes.length > 0) markers.push(escalationHintMarker("command"));
107
168
  }
108
169
  if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`);
170
+ if (result.stopped !== void 0) markers.push(`[stopped: ${result.stopped}]`);
109
171
  if (result.signal !== null) markers.push(`[killed by signal: ${result.signal}]`);
110
172
  else if (result.exitCode !== 0) markers.push(`[exit code: ${result.exitCode}]`);
111
173
  if (markers.length === 0) return body;
@@ -113,27 +175,40 @@ function renderPwshResult(result, escalationModes = []) {
113
175
  return body + markers.join("\n");
114
176
  }
115
177
  /**
116
- * Shape one background-process read into the `job_output` delta the model
117
- * sees: the incremental delta, plus the lossy-read notice (with full-stream
118
- * spill paths) when in-memory truncation dropped unread bytes.
119
- * @param read - one incremental read from the process handle.
178
+ * Shape a foreground call that stopped waiting into the text the model sees:
179
+ * the output captured so far (one consuming registry read taken at that
180
+ * point, so `job_output` continues exactly after it), then the still-running
181
+ * marker and the job hand-off guidance.
182
+ * @param promoted - the promoted result value: the job id, the wait that
183
+ * expired, and the output so far.
184
+ * @returns the model-facing text for a promoted call.
185
+ */
186
+ function renderPwshPromoted(promoted) {
187
+ return `${promoted.output.length > 0 ? promoted.output.endsWith("\n") ? promoted.output : `${promoted.output}\n` : ""}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]\nThe command keeps running in the background. You will be notified when it finishes; read newer output with job_output, stop it with job_kill.`;
188
+ }
189
+ /**
190
+ * Shape the one consuming registry read a foreground call embeds in its
191
+ * result when it stops waiting: the output produced so far, plus the
192
+ * dropped-output notice (naming the job's spill files) when the model cursor
193
+ * fell behind the ring, and the sandbox notices. Later `job_output` reads
194
+ * render the same ring through the job tools.
195
+ * @param delta - the read's chunks as rendered text.
196
+ * @param lossy - whether bytes before the delta were evicted unread.
197
+ * @param spillPaths - the complete-stream files the job currently advertises.
120
198
  * @param sandbox - settled sandbox facts, when this was a confined process.
121
199
  * @param escalationModes - escalation targets advertised by this composition.
122
200
  * @returns the delta text with any loss or sandbox notice appended.
123
201
  */
124
- function renderPwshProcessRead(read, sandbox, escalationModes = []) {
202
+ function renderPwshJobRead(delta, lossy, spillPaths, sandbox, escalationModes = []) {
125
203
  const notices = [];
126
- if (read.lossy) {
127
- const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path) => path !== void 0);
128
- notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(", ") : "(unavailable)"}]`);
129
- }
204
+ if (lossy) notices.push(`[some output was dropped from memory; full output: ${spillPaths.length > 0 ? spillPaths.join(", ") : "(unavailable)"}]`);
130
205
  if (sandbox?.runnerFailed) notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`);
131
206
  else if (sandbox?.denied) {
132
207
  notices.push(sandboxDenialMarker(sandbox.mode));
133
208
  if (escalationModes.length > 0) notices.push(escalationHintMarker("command"));
134
209
  }
135
- if (notices.length === 0) return read.delta;
136
- return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith("\n") ? "\n" : ""}${notices.join("\n")}`;
210
+ if (notices.length === 0) return delta;
211
+ return `${delta}${delta.length > 0 && !delta.endsWith("\n") ? "\n" : ""}${notices.join("\n")}`;
137
212
  }
138
213
  //#endregion
139
214
  //#region lib/types/index.js
@@ -144,8 +219,9 @@ function renderPwshProcessRead(read, sandbox, escalationModes = []) {
144
219
  * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
145
220
  *
146
221
  * Behavior mirrors `dsh-tool-bash` call-for-call: foreground and
147
- * `run_in_background` execution (background handles register with the
148
- * generic `ctx.jobs` runtime), the managed `DSH_*` environment through the
222
+ * `run_in_background` execution (with a job registry composed, every call
223
+ * registers its process with `ctx.jobs` as it starts, and a foreground call
224
+ * waits on its job until the timeout passes), the managed `DSH_*` environment through the
149
225
  * shared `shell-env` registry, the per-call sandbox policy resolution (the
150
226
  * calling session's mode and cwd travel to the confining executor), the
151
227
  * sandbox-denial rendering with the same-turn escalation surface
@@ -165,15 +241,18 @@ const inject = [
165
241
  "shellEnv"
166
242
  ];
167
243
  /** Runtime configuration schema for the pwsh tool plugin. */
168
- const Config = z.object({ enableRunInBackground: z.boolean().default(true) });
244
+ const Config = z.object({
245
+ enableRunInBackground: z.boolean().default(true),
246
+ promoteOnTimeout: z.boolean().default(true)
247
+ });
169
248
  function validatePwshArgs(args) {
170
249
  if (args.command.trim().length === 0) throw new Error("invalid command: expected a non-empty string");
171
250
  if (args.description.trim().length === 0) throw new Error("invalid description: expected a non-empty string");
172
251
  if (args.timeoutMs !== void 0 && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`);
173
252
  validateEscalationArgs(args.sandbox_permissions, args.justification);
174
253
  }
175
- function pwshDescription(backgroundEnabled, escalationModes) {
176
- const base = "Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. " + (backgroundEnabled ? "Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`." : "Background execution is not available; long-running commands must finish within the timeout.");
254
+ function pwshDescription(backgroundEnabled, escalationModes, promoteOnTimeout) {
255
+ const base = "Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. " + (backgroundEnabled ? "Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`." + (promoteOnTimeout ? " A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far." : "") : "Background execution is not available; long-running commands must finish within the timeout.");
177
256
  if (escalationModes.length === 0) return base;
178
257
  return base + " Under the Windows sandbox, read-only pwsh runs in PowerShell ConstrainedLanguage mode, while workspace-write stays in FullLanguage unless host policy says otherwise. In read-only, prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); .NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail with \"only core types\" errors. `-f` formatting, property access, and core cmdlets work. In both confined modes, programs cannot open named pipes, so a command that captures another program's output through piped stdio (Node.js `child_process.spawn`/`exec` with the default `stdio: 'pipe'`) fails with EPERM, while `stdio: 'inherit'` and `stdio: 'ignore'` spawns work and PowerShell's own pipelines are unaffected. That EPERM is the documented boundary: do not retry the command another way — escalate the exact command once or restructure it to avoid capturing output. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.";
179
258
  }
@@ -211,6 +290,12 @@ function canonicalPwshResult(result) {
211
290
  } } : {}
212
291
  };
213
292
  }
293
+ /** The structured abort the foreground paths throw when the caller cancels the call. */
294
+ function toolAborted() {
295
+ const error = new HarnessError("tool call aborted", TOOL_ABORTED);
296
+ error.name = "AbortError";
297
+ return error;
298
+ }
214
299
  /** Canonical background-handle properties shared by the pwsh output union. */
215
300
  const BACKGROUND_OUTPUT_PROPERTIES = {
216
301
  kind: {
@@ -225,6 +310,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
225
310
  };
226
311
  function apply(ctx, config = {}) {
227
312
  const backgroundEnabled = config.enableRunInBackground ?? true;
313
+ const promoteOnTimeout = (config.promoteOnTimeout ?? true) && backgroundEnabled;
228
314
  const defaultMode = ctx.shell.sandboxMode;
229
315
  const escalationModes = defaultMode === void 0 ? [] : ESCALATION_TARGETS;
230
316
  const sandboxPolicy = defaultMode === void 0 ? void 0 : ctx.get("sandboxPolicy");
@@ -264,219 +350,366 @@ function apply(ctx, config = {}) {
264
350
  order: ctx.systemPrompt.getSectionOrder("TOOL_PWSH"),
265
351
  text: "Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure."
266
352
  });
267
- ctx.tools.register(defineTool({
268
- name: "pwsh",
269
- description: pwshDescription(backgroundEnabled, escalationModes),
270
- parameters: {
271
- command: {
272
- type: "string",
273
- required: true,
274
- description: "The PowerShell command to execute."
275
- },
276
- description: {
277
- type: "string",
278
- required: true,
279
- description: "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"."
280
- },
281
- timeoutMs: {
282
- type: "number",
283
- description: "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
284
- },
285
- workdir: {
286
- type: "string",
287
- description: "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
288
- },
289
- ...backgroundEnabled ? { run_in_background: {
290
- type: "boolean",
291
- description: "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."
292
- } } : {},
293
- ...escalationModes.length > 0 ? {
294
- sandbox_permissions: {
353
+ /**
354
+ * One registration of the `pwsh` tool. With a registry, every call
355
+ * registers its process as a job at its start; without one the tool is
356
+ * foreground-only and the executor's deadline kills the command.
357
+ */
358
+ const pwshTool = (jobs) => {
359
+ const background = jobs !== void 0;
360
+ const promote = background && promoteOnTimeout;
361
+ /** Register the command as a job; the process spawns inside the starter, after admission. */
362
+ const startJob = (registry, args, exec, spec) => {
363
+ let proc;
364
+ let stopped;
365
+ return {
366
+ id: registry.start({
367
+ kind: "pwsh",
368
+ label: args.command,
369
+ ...exec.agent ? { owner: exec.agent.id } : {},
370
+ output: processSources(() => proc),
371
+ run: () => {
372
+ const hooks = processJob(async (signal) => {
373
+ proc = await ctx.shell.execute({
374
+ ...spec,
375
+ signal
376
+ });
377
+ return proc;
378
+ }, (started) => processOutcome(started, escalationModes));
379
+ return {
380
+ done: hooks.done,
381
+ cancel: (reason) => {
382
+ stopped = reason;
383
+ hooks.cancel(reason);
384
+ }
385
+ };
386
+ }
387
+ }),
388
+ process: () => proc,
389
+ stopped: () => stopped
390
+ };
391
+ };
392
+ /** Wait on a registered foreground command until it settles or the timeout passes. */
393
+ const waitOnJob = async (registry, attached, exec, spec) => {
394
+ const owner = exec.agent?.id;
395
+ const timeoutMs = spec.timeoutMs;
396
+ /**
397
+ * Stop the job on this call's own account and stay on it until it
398
+ * settles, so the settlement is `awaited` and no completion notice
399
+ * follows a result this call already carries; the record then leaves
400
+ * with the call, as the model never saw the id.
401
+ */
402
+ const stop = async (reason) => {
403
+ registry.kill(attached.id, owner, reason);
404
+ const settled = await registry.wait(attached.id, timeoutMs, owner);
405
+ if (settled.status !== "running" && settled.status !== "stopping") registry.remove(attached.id, owner);
406
+ return settled;
407
+ };
408
+ let view;
409
+ try {
410
+ view = await registry.wait(attached.id, timeoutMs, owner, exec.signal);
411
+ } catch {
412
+ await stop("tool call aborted");
413
+ throw toolAborted();
414
+ }
415
+ if ((view.status === "running" || view.status === "stopping") && attached.process() === void 0) {
416
+ await stop("timed out during preparation");
417
+ return {
418
+ kind: "foreground",
419
+ exitCode: null,
420
+ signal: null,
421
+ timedOut: true,
422
+ aborted: false,
423
+ timeoutMs,
424
+ stdout: {
425
+ text: "",
426
+ truncated: false
427
+ },
428
+ stderr: {
429
+ text: "",
430
+ truncated: false
431
+ },
432
+ ...spec.sandboxPolicy !== void 0 ? { sandbox: {
433
+ mode: spec.sandboxPolicy.mode,
434
+ denied: false
435
+ } } : {}
436
+ };
437
+ }
438
+ if (view.status === "running" || view.status === "stopping") {
439
+ const read = registry.read(attached.id, owner);
440
+ return {
441
+ kind: "promoted",
442
+ jobId: attached.id,
443
+ timeoutMs,
444
+ output: renderPwshJobRead(ringDelta(read.chunks), read.lossy, read.job.output.spillPaths ?? [], attached.process()?.sandbox, escalationModes)
445
+ };
446
+ }
447
+ registry.remove(attached.id, owner);
448
+ const process = attached.process();
449
+ if (process === void 0) throw new Error(view.detail);
450
+ const result = await process.result();
451
+ const stopped = attached.stopped();
452
+ return {
453
+ ...canonicalPwshResult(result),
454
+ ...stopped !== void 0 ? { stopped } : {}
455
+ };
456
+ };
457
+ return defineTool({
458
+ name: "pwsh",
459
+ description: pwshDescription(background, escalationModes, promote),
460
+ parameters: {
461
+ command: {
295
462
  type: "string",
296
- enum: [...escalationModes],
297
- description: "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval."
463
+ required: true,
464
+ description: "The PowerShell command to execute."
298
465
  },
299
- justification: {
466
+ description: {
300
467
  type: "string",
301
- description: "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
302
- }
303
- } : {}
304
- },
305
- output: {
306
- schema: { oneOf: [{
307
- type: "object",
308
- additionalProperties: false,
309
- properties: BACKGROUND_OUTPUT_PROPERTIES
310
- }, {
311
- type: "object",
312
- additionalProperties: false,
313
- properties: {
314
- kind: {
468
+ required: true,
469
+ description: "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"."
470
+ },
471
+ timeoutMs: {
472
+ type: "number",
473
+ description: promote ? "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed." : "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
474
+ },
475
+ workdir: {
476
+ type: "string",
477
+ description: "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
478
+ },
479
+ ...background ? { run_in_background: {
480
+ type: "boolean",
481
+ description: "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."
482
+ } } : {},
483
+ ...escalationModes.length > 0 ? {
484
+ sandbox_permissions: {
315
485
  type: "string",
316
- required: true,
317
- const: "foreground"
318
- },
319
- exitCode: {
320
- required: true,
321
- oneOf: [{ type: "integer" }, { type: "null" }]
322
- },
323
- signal: {
324
- required: true,
325
- oneOf: [{ type: "string" }, { type: "null" }]
326
- },
327
- timedOut: {
328
- type: "boolean",
329
- required: true
486
+ enum: [...escalationModes],
487
+ description: "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval."
330
488
  },
331
- aborted: {
332
- type: "boolean",
333
- required: true
334
- },
335
- timeoutMs: {
336
- type: "number",
337
- required: true
489
+ justification: {
490
+ type: "string",
491
+ description: "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
492
+ }
493
+ } : {}
494
+ },
495
+ output: {
496
+ schema: { oneOf: [
497
+ {
498
+ type: "object",
499
+ additionalProperties: false,
500
+ properties: BACKGROUND_OUTPUT_PROPERTIES
338
501
  },
339
- stdout: {
502
+ {
340
503
  type: "object",
341
504
  additionalProperties: false,
342
- required: true,
343
505
  properties: {
344
- text: {
506
+ kind: {
507
+ type: "string",
508
+ required: true,
509
+ const: "promoted"
510
+ },
511
+ jobId: {
345
512
  type: "string",
346
513
  required: true
347
514
  },
348
- truncated: {
349
- type: "boolean",
515
+ timeoutMs: {
516
+ type: "number",
350
517
  required: true
351
518
  },
352
- spillPath: { type: "string" }
519
+ output: {
520
+ type: "string",
521
+ required: true
522
+ }
353
523
  }
354
524
  },
355
- stderr: {
525
+ {
356
526
  type: "object",
357
527
  additionalProperties: false,
358
- required: true,
359
528
  properties: {
360
- text: {
529
+ kind: {
361
530
  type: "string",
362
- required: true
531
+ required: true,
532
+ const: "foreground"
533
+ },
534
+ exitCode: {
535
+ required: true,
536
+ oneOf: [{ type: "integer" }, { type: "null" }]
363
537
  },
364
- truncated: {
538
+ signal: {
539
+ required: true,
540
+ oneOf: [{ type: "string" }, { type: "null" }]
541
+ },
542
+ timedOut: {
365
543
  type: "boolean",
366
544
  required: true
367
545
  },
368
- spillPath: { type: "string" }
369
- }
370
- },
371
- sandbox: {
372
- type: "object",
373
- additionalProperties: false,
374
- properties: {
375
- mode: {
376
- type: "string",
546
+ aborted: {
547
+ type: "boolean",
377
548
  required: true
378
549
  },
379
- denied: {
380
- type: "boolean",
550
+ stopped: { type: "string" },
551
+ timeoutMs: {
552
+ type: "number",
381
553
  required: true
382
554
  },
383
- enforcement: { type: "string" },
384
- runnerFailed: { type: "boolean" }
555
+ stdout: {
556
+ type: "object",
557
+ additionalProperties: false,
558
+ required: true,
559
+ properties: {
560
+ text: {
561
+ type: "string",
562
+ required: true
563
+ },
564
+ truncated: {
565
+ type: "boolean",
566
+ required: true
567
+ },
568
+ spillPath: { type: "string" }
569
+ }
570
+ },
571
+ stderr: {
572
+ type: "object",
573
+ additionalProperties: false,
574
+ required: true,
575
+ properties: {
576
+ text: {
577
+ type: "string",
578
+ required: true
579
+ },
580
+ truncated: {
581
+ type: "boolean",
582
+ required: true
583
+ },
584
+ spillPath: { type: "string" }
585
+ }
586
+ },
587
+ sandbox: {
588
+ type: "object",
589
+ additionalProperties: false,
590
+ properties: {
591
+ mode: {
592
+ type: "string",
593
+ required: true
594
+ },
595
+ denied: {
596
+ type: "boolean",
597
+ required: true
598
+ },
599
+ enforcement: { type: "string" },
600
+ runnerFailed: { type: "boolean" }
601
+ }
602
+ }
385
603
  }
386
604
  }
605
+ ] },
606
+ render: (_args, value) => [{
607
+ type: "text",
608
+ text: value.kind === "background" ? `started background job ${value.jobId}` : value.kind === "promoted" ? renderPwshPromoted(value) : renderPwshResult(value, escalationModes)
609
+ }]
610
+ },
611
+ async execute(args, exec) {
612
+ validatePwshArgs(args);
613
+ const standingPolicy = resolveSandboxPolicy(exec);
614
+ const approvedMode = args.sandbox_permissions !== void 0 && args.justification !== void 0 ? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy) : void 0;
615
+ const policy = approvedMode === void 0 ? standingPolicy : {
616
+ ...standingPolicy,
617
+ mode: approvedMode
618
+ };
619
+ const workdir = resolveWorkdir(args.workdir, exec);
620
+ const request = {
621
+ command: args.command,
622
+ ...workdir !== void 0 ? { workdir } : {},
623
+ ...args.timeoutMs !== void 0 ? { timeoutMs: args.timeoutMs } : {},
624
+ dshEnv: ctx.shellEnv.collect(exec),
625
+ ...policy !== void 0 ? { sandboxPolicy: policy } : {}
626
+ };
627
+ if (args.run_in_background === true) {
628
+ if (!backgroundEnabled) throw new Error("run_in_background is disabled for this deployment (enableRunInBackground: false)");
629
+ if (jobs === void 0) throw new Error("background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs");
630
+ if (exec.signal.aborted) throw toolAborted();
631
+ return {
632
+ kind: "background",
633
+ jobId: startJob(jobs, args, exec, ctx.shell.resolve({
634
+ ...request,
635
+ onExpiry: "none"
636
+ })).id
637
+ };
387
638
  }
388
- }] },
389
- render: (_args, value) => [{
390
- type: "text",
391
- text: value.kind === "background" ? `started background job ${value.jobId}` : renderPwshResult(value, escalationModes)
392
- }]
393
- },
394
- async execute(args, exec) {
395
- validatePwshArgs(args);
396
- const standingPolicy = resolveSandboxPolicy(exec);
397
- const approvedMode = args.sandbox_permissions !== void 0 && args.justification !== void 0 ? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy) : void 0;
398
- const policy = approvedMode === void 0 ? standingPolicy : {
399
- ...standingPolicy,
400
- mode: approvedMode
401
- };
402
- const workdir = resolveWorkdir(args.workdir, exec);
403
- const request = {
404
- command: args.command,
405
- ...workdir !== void 0 ? { workdir } : {},
406
- ...args.timeoutMs !== void 0 ? { timeoutMs: args.timeoutMs } : {},
407
- dshEnv: ctx.shellEnv.collect(exec),
408
- ...policy !== void 0 ? { sandboxPolicy: policy } : {}
409
- };
410
- if (args.run_in_background === true) {
411
- if (!backgroundEnabled) throw new Error("run_in_background is disabled for this deployment (enableRunInBackground: false)");
412
- const jobs = ctx.get("jobs");
413
- if (jobs === void 0) throw new Error("background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs");
414
- if (exec.signal.aborted) {
415
- const error = new HarnessError("tool call aborted", TOOL_ABORTED);
416
- error.name = "AbortError";
417
- throw error;
639
+ if (jobs !== void 0 && promote) {
640
+ const spec = ctx.shell.resolve({
641
+ ...request,
642
+ onExpiry: "none"
643
+ });
644
+ let attached;
645
+ try {
646
+ attached = startJob(jobs, args, exec, spec);
647
+ } catch (error) {
648
+ ctx.logger.warn(`pwsh: job registration refused, running in the foreground with the timeout kill instead: ${String(error)}`);
649
+ }
650
+ if (attached !== void 0) return waitOnJob(jobs, attached, exec, spec);
418
651
  }
652
+ const result = await (await ctx.shell.execute(ctx.shell.resolve({
653
+ ...request,
654
+ signal: exec.signal
655
+ }))).result();
656
+ if (result.aborted) throw toolAborted();
657
+ return canonicalPwshResult(result);
658
+ },
659
+ presentCall: (args) => {
660
+ if (args.run_in_background === true) return {
661
+ card: "generic",
662
+ title: args.command,
663
+ kind: "execute",
664
+ rawInput: args.command,
665
+ content: [{
666
+ type: "text",
667
+ text: args.description
668
+ }]
669
+ };
419
670
  return {
420
- kind: "background",
421
- jobId: jobs.start({
422
- kind: "pwsh",
423
- label: args.command,
424
- ...exec.agent ? { owner: exec.agent } : {},
425
- run: () => processJob((signal) => ctx.shell.start(ctx.shell.resolve({
426
- ...request,
427
- signal
428
- })), (proc) => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes))
429
- })
671
+ card: "terminal",
672
+ title: args.command,
673
+ description: args.description,
674
+ ...args.workdir !== void 0 ? { cwd: args.workdir } : {}
675
+ };
676
+ },
677
+ presentResult: (args, result) => {
678
+ const block = result.content.length === 1 ? result.content[0] : void 0;
679
+ if (block === void 0 || block.type !== "text") return void 0;
680
+ const raw = block.text;
681
+ const isBackground = typeof args === "object" && args !== null && args.run_in_background === true;
682
+ const isPromoted = result.value?.kind === "promoted";
683
+ if (isBackground || isPromoted || result.isError) return {
684
+ card: "generic",
685
+ content: [{
686
+ type: "text",
687
+ text: `\`\`\`console\n${raw.replace(/\n+$/, "")}\n\`\`\``
688
+ }]
689
+ };
690
+ const { body, ...exit } = parseExitStatus(raw);
691
+ return {
692
+ card: "terminal",
693
+ output: body,
694
+ ...exit
430
695
  };
431
696
  }
432
- const result = await ctx.shell.run(ctx.shell.resolve({
433
- ...request,
434
- signal: exec.signal
435
- }));
436
- if (result.aborted) {
437
- const error = new HarnessError("tool call aborted", TOOL_ABORTED);
438
- error.name = "AbortError";
439
- throw error;
440
- }
441
- return canonicalPwshResult(result);
442
- },
443
- presentCall: (args) => {
444
- if (args.run_in_background === true) return {
445
- card: "generic",
446
- title: args.command,
447
- kind: "execute",
448
- rawInput: args.command,
449
- content: [{
450
- type: "text",
451
- text: args.description
452
- }]
453
- };
454
- return {
455
- card: "terminal",
456
- title: args.command,
457
- description: args.description,
458
- ...args.workdir !== void 0 ? { cwd: args.workdir } : {}
459
- };
460
- },
461
- presentResult: (args, result) => {
462
- const block = result.content.length === 1 ? result.content[0] : void 0;
463
- if (block === void 0 || block.type !== "text") return void 0;
464
- const raw = block.text;
465
- if (typeof args === "object" && args !== null && args.run_in_background === true || result.isError) return {
466
- card: "generic",
467
- content: [{
468
- type: "text",
469
- text: `\`\`\`console\n${raw.replace(/\n+$/, "")}\n\`\`\``
470
- }]
471
- };
472
- const { body, ...exit } = parseExitStatus(raw);
473
- return {
474
- card: "terminal",
475
- output: body,
476
- ...exit
477
- };
478
- }
479
- }));
697
+ });
698
+ };
699
+ if (!backgroundEnabled) {
700
+ ctx.tools.register(pwshTool(void 0));
701
+ return;
702
+ }
703
+ let foregroundOnly = ctx.get("jobs") === void 0 ? ctx.tools.register(pwshTool(void 0)) : void 0;
704
+ ctx.inject(["jobs"], (jobCtx) => {
705
+ foregroundOnly?.();
706
+ foregroundOnly = void 0;
707
+ const unregister = ctx.tools.register(pwshTool(jobCtx.jobs));
708
+ jobCtx.effect(() => () => {
709
+ unregister();
710
+ if (ctx.fiber.state === 2) foregroundOnly = ctx.tools.register(pwshTool(void 0));
711
+ });
712
+ });
480
713
  }
481
714
  //#endregion
482
715
  export { Config, apply, inject, name };
@@ -1,28 +1,50 @@
1
1
  /**
2
- * Generic-task adaptation for background pwsh process handles — the shell-agnostic
3
- * twin of `dsh-tool-bash`'s background adaptation.
2
+ * Generic-job adaptation for pwsh process handles — the shell-agnostic twin
3
+ * of `dsh-tool-bash`'s background adaptation: the terminal
4
+ * outcome the registry records and the pull sources it pumps.
4
5
  *
5
6
  * @module @deepseek-ai/dsh-tool-pwsh/background
6
7
  */
8
+ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox';
7
9
  import type { ShellProcess } from '@deepseek-ai/dsh-shell';
8
- import type { JobHooks } from '@deepseek-ai/dsh-jobs';
10
+ import type { JobChunk, JobHooks, JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs';
9
11
  /**
10
- * Map a settled background process onto the generic task-outcome vocabulary:
12
+ * Map a settled background process onto the generic job-outcome vocabulary:
11
13
  * `killed` stays `killed` (detail: the signal when one is known), everything
12
14
  * else is `completed` with the exit code as detail. A nonzero command exit is
13
- * reported, not failed, exactly like the foreground rendering.
15
+ * reported, not failed, exactly like the foreground rendering. Sandbox facts
16
+ * join the detail, since a job's terminal reason is the one line every
17
+ * reader — the model's status line, the roster row — shows.
14
18
  * @param proc - the settled process handle.
19
+ * @param escalationModes - escalation targets advertised by this composition.
15
20
  * @returns the outcome for the `ctx.jobs` registration.
16
21
  */
17
- export declare function processOutcome(proc: ShellProcess): {
18
- status: 'completed' | 'killed';
19
- detail: string;
20
- };
22
+ export declare function processOutcome(proc: ShellProcess, escalationModes?: readonly SandboxMode[]): JobOutcome;
23
+ /**
24
+ * The process's non-consuming stream readers as registry pull sources. They
25
+ * bind lazily because the process is spawned inside the starter, after the
26
+ * registry admitted the job; a read before the spawn yields nothing, and the
27
+ * pump keeps the model's consuming cursor untouched. A rejected spawn's
28
+ * stderr reader carries the provider's `subprocess failed before reporting an
29
+ * outcome: …` note.
30
+ * @param proc - the started process's observed streams, once the starter has spawned it.
31
+ * @returns one source per stream, stdout first.
32
+ */
33
+ export declare function processSources(proc: () => Pick<ShellProcess, 'observed'> | undefined): JobOutputSource[];
34
+ /**
35
+ * The ring chunks of one consuming registry read as the shell tools render a
36
+ * process read: stdout chunks in order, then every stderr chunk in one
37
+ * `[stderr]` section, so the output a foreground call hands over when it
38
+ * stops waiting reads exactly like the `job_output` reads that follow it.
39
+ * @param chunks - the chunks since the model cursor, in offset order.
40
+ * @returns the delta text, possibly empty.
41
+ */
42
+ export declare function ringDelta(chunks: readonly JobChunk[]): string;
21
43
  /**
22
44
  * Adapt asynchronous shell preparation after job admission without exposing a partial process.
23
45
  * @param start - starts the process with job-owned cancellation.
24
- * @param renderOutput - consumes output from a published process.
46
+ * @param outcome - projects the settled process into the job outcome.
25
47
  * @returns synchronous job hooks whose completion includes preparation and process settlement.
26
48
  */
27
- export declare function processJob(start: (signal: AbortSignal) => Promise<ShellProcess>, renderOutput: (process: ShellProcess) => string): JobHooks;
49
+ export declare function processJob(start: (signal: AbortSignal) => Promise<ShellProcess>, outcome: (process: ShellProcess) => JobOutcome): JobHooks;
28
50
  //# sourceMappingURL=background.d.ts.map
@@ -5,8 +5,9 @@
5
5
  * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
6
6
  *
7
7
  * Behavior mirrors `dsh-tool-bash` call-for-call: foreground and
8
- * `run_in_background` execution (background handles register with the
9
- * generic `ctx.jobs` runtime), the managed `DSH_*` environment through the
8
+ * `run_in_background` execution (with a job registry composed, every call
9
+ * registers its process with `ctx.jobs` as it starts, and a foreground call
10
+ * waits on its job until the timeout passes), the managed `DSH_*` environment through the
10
11
  * shared `shell-env` registry, the per-call sandbox policy resolution (the
11
12
  * calling session's mode and cwd travel to the confining executor), the
12
13
  * sandbox-denial rendering with the same-turn escalation surface
@@ -29,8 +30,21 @@ export declare const name = "tool-pwsh";
29
30
  export declare const inject: string[];
30
31
  /** Configuration for the pwsh tool. */
31
32
  export interface Config {
32
- /** Expose `run_in_background` (default true); disabled calls are also rejected. */
33
+ /**
34
+ * Expose `run_in_background` while a job registry is composed (default
35
+ * true); disabled calls are also rejected. Without a registry the tool is
36
+ * foreground-only regardless.
37
+ */
33
38
  enableRunInBackground?: boolean;
39
+ /**
40
+ * Keep a foreground command that reaches its timeout running as a
41
+ * background job instead of killing it (default true). Applies only while
42
+ * background execution is available: with `enableRunInBackground` false or
43
+ * no job registry, the executor's deadline kills the command. A foreground
44
+ * command the registry refuses at its start (admission or a missing
45
+ * controller) also runs under the deadline kill.
46
+ */
47
+ promoteOnTimeout?: boolean;
34
48
  }
35
49
  /** Runtime configuration schema for the pwsh tool plugin. */
36
50
  export declare const Config: z<Config>;
@@ -9,13 +9,15 @@
9
9
  *
10
10
  * @module @deepseek-ai/dsh-tool-pwsh/render
11
11
  */
12
- import type { ShellProcessRead, ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell';
12
+ import type { ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell';
13
13
  import type { SandboxMode } from '@deepseek-ai/dsh-sandbox';
14
14
  /** The renderable foreground result shape (the schema-derived value, no `kind`). */
15
15
  export interface RenderablePwshResult {
16
16
  exitCode: number | null;
17
17
  signal: string | null;
18
18
  timedOut: boolean;
19
+ /** The reason the command was stopped from outside the call, when a job kill ended it. */
20
+ stopped?: string;
19
21
  timeoutMs: number;
20
22
  stdout: CollectedOutput;
21
23
  stderr: CollectedOutput;
@@ -33,13 +35,31 @@ export interface RenderablePwshResult {
33
35
  */
34
36
  export declare function renderPwshResult(result: RenderablePwshResult, escalationModes?: readonly SandboxMode[]): string;
35
37
  /**
36
- * Shape one background-process read into the `job_output` delta the model
37
- * sees: the incremental delta, plus the lossy-read notice (with full-stream
38
- * spill paths) when in-memory truncation dropped unread bytes.
39
- * @param read - one incremental read from the process handle.
38
+ * Shape a foreground call that stopped waiting into the text the model sees:
39
+ * the output captured so far (one consuming registry read taken at that
40
+ * point, so `job_output` continues exactly after it), then the still-running
41
+ * marker and the job hand-off guidance.
42
+ * @param promoted - the promoted result value: the job id, the wait that
43
+ * expired, and the output so far.
44
+ * @returns the model-facing text for a promoted call.
45
+ */
46
+ export declare function renderPwshPromoted(promoted: {
47
+ jobId: string;
48
+ timeoutMs: number;
49
+ output: string;
50
+ }): string;
51
+ /**
52
+ * Shape the one consuming registry read a foreground call embeds in its
53
+ * result when it stops waiting: the output produced so far, plus the
54
+ * dropped-output notice (naming the job's spill files) when the model cursor
55
+ * fell behind the ring, and the sandbox notices. Later `job_output` reads
56
+ * render the same ring through the job tools.
57
+ * @param delta - the read's chunks as rendered text.
58
+ * @param lossy - whether bytes before the delta were evicted unread.
59
+ * @param spillPaths - the complete-stream files the job currently advertises.
40
60
  * @param sandbox - settled sandbox facts, when this was a confined process.
41
61
  * @param escalationModes - escalation targets advertised by this composition.
42
62
  * @returns the delta text with any loss or sandbox notice appended.
43
63
  */
44
- export declare function renderPwshProcessRead(read: ShellProcessRead, sandbox?: ShellSandboxInfo, escalationModes?: readonly SandboxMode[]): string;
64
+ export declare function renderPwshJobRead(delta: string, lossy: boolean, spillPaths: readonly string[], sandbox?: ShellSandboxInfo, escalationModes?: readonly SandboxMode[]): string;
45
65
  //# sourceMappingURL=render.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-tool-pwsh",
3
3
  "description": "Model-facing pwsh tool over the bash executor seam",
4
- "version": "0.1.6-alpha.2",
4
+ "version": "0.1.7-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,40 +27,41 @@
27
27
  ],
28
28
  "license": "MIT",
29
29
  "peerDependencies": {
30
- "@deepseek-ai/cordis": "^4.0.2",
31
- "@deepseek-ai/dsh-agent": "^0.1.6-alpha.2",
32
- "@deepseek-ai/dsh-jobs": "^0.1.6-alpha.2",
33
- "@deepseek-ai/dsh-llm": "^0.1.6-alpha.2",
34
- "@deepseek-ai/dsh-sandbox": "^0.1.6-alpha.2",
35
- "@deepseek-ai/dsh-sandbox-policy": "^0.1.6-alpha.2",
36
- "@deepseek-ai/dsh-tools": "^0.1.6-alpha.2",
37
- "@deepseek-ai/dsh-user-approval": "^0.1.6-alpha.2",
38
- "@deepseek-ai/dsh-shell-env": "^0.1.6-alpha.2",
39
- "@deepseek-ai/dsh-shell": "^0.1.6-alpha.2",
40
- "@deepseek-ai/dsh-system-prompt": "^0.1.6-alpha.2"
30
+ "@deepseek-ai/cordis": "~4.0.4",
31
+ "@deepseek-ai/dsh-agent": "0.1.7-alpha.2",
32
+ "@deepseek-ai/dsh-jobs": "0.1.7-alpha.2",
33
+ "@deepseek-ai/dsh-llm": "0.1.7-alpha.2",
34
+ "@deepseek-ai/dsh-sandbox": "0.1.7-alpha.2",
35
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.7-alpha.2",
36
+ "@deepseek-ai/dsh-shell": "0.1.7-alpha.2",
37
+ "@deepseek-ai/dsh-shell-env": "0.1.7-alpha.2",
38
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-alpha.2",
39
+ "@deepseek-ai/dsh-tools": "0.1.7-alpha.2",
40
+ "@deepseek-ai/dsh-user-approval": "0.1.7-alpha.2"
41
41
  },
42
42
  "dependencies": {
43
- "@deepseek-ai/schemastery": "^3.18.2"
43
+ "@deepseek-ai/schemastery": "~3.18.4"
44
44
  },
45
45
  "devDependencies": {
46
- "@deepseek-ai/cordis": "^4.0.2",
47
- "@deepseek-ai/dsh-agent": "^0.1.6-alpha.2",
48
- "@deepseek-ai/dsh-app-boot": "^0.1.6-alpha.2",
49
- "@deepseek-ai/dsh-agent-loop": "^0.1.6-alpha.2",
50
- "@deepseek-ai/dsh-jobs": "^0.1.6-alpha.2",
51
- "@deepseek-ai/dsh-jobs-local": "^0.1.6-alpha.2",
52
- "@deepseek-ai/dsh-llm": "^0.1.6-alpha.2",
53
- "@deepseek-ai/dsh-loader-smoke": "^0.1.6-alpha.2",
54
- "@deepseek-ai/dsh-pwsh-local": "^0.1.6-alpha.2",
55
- "@deepseek-ai/dsh-sandbox": "^0.1.6-alpha.2",
56
- "@deepseek-ai/dsh-sandbox-policy": "^0.1.6-alpha.2",
57
- "@deepseek-ai/dsh-session-projection": "^0.1.6-alpha.2",
58
- "@deepseek-ai/dsh-shell": "^0.1.6-alpha.2",
59
- "@deepseek-ai/dsh-shell-env": "^0.1.6-alpha.2",
60
- "@deepseek-ai/dsh-subprocess-local": "^0.1.6-alpha.2",
61
- "@deepseek-ai/dsh-system-prompt": "^0.1.6-alpha.2",
62
- "@deepseek-ai/dsh-tool-jobs": "^0.1.6-alpha.2",
63
- "@deepseek-ai/dsh-user-approval": "^0.1.6-alpha.2",
64
- "@deepseek-ai/dsh-tools": "^0.1.6-alpha.2"
46
+ "@deepseek-ai/cordis": "~4.0.4",
47
+ "@deepseek-ai/dsh-agent": "0.1.7-alpha.2",
48
+ "@deepseek-ai/dsh-agent-loop": "0.1.7-alpha.2",
49
+ "@deepseek-ai/dsh-app-boot": "0.1.7-alpha.2",
50
+ "@deepseek-ai/dsh-agent-loop-testkit": "0.1.7-alpha.2",
51
+ "@deepseek-ai/dsh-jobs-local": "0.1.7-alpha.2",
52
+ "@deepseek-ai/dsh-loader-smoke": "0.1.7-alpha.2",
53
+ "@deepseek-ai/dsh-llm": "0.1.7-alpha.2",
54
+ "@deepseek-ai/dsh-jobs": "0.1.7-alpha.2",
55
+ "@deepseek-ai/dsh-pwsh-local": "0.1.7-alpha.2",
56
+ "@deepseek-ai/dsh-sandbox": "0.1.7-alpha.2",
57
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.7-alpha.2",
58
+ "@deepseek-ai/dsh-session-projection": "0.1.7-alpha.2",
59
+ "@deepseek-ai/dsh-shell": "0.1.7-alpha.2",
60
+ "@deepseek-ai/dsh-shell-env": "0.1.7-alpha.2",
61
+ "@deepseek-ai/dsh-subprocess-local": "0.1.7-alpha.2",
62
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-alpha.2",
63
+ "@deepseek-ai/dsh-tool-jobs": "0.1.7-alpha.2",
64
+ "@deepseek-ai/dsh-tools": "0.1.7-alpha.2",
65
+ "@deepseek-ai/dsh-user-approval": "0.1.7-alpha.2"
65
66
  }
66
67
  }