diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 561b59e10e..6b69cae609 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -108,6 +108,9 @@ flowchart LR
pkg_subagent_acp["subagent-acp"]
pkg_bash["bash"]
svc_bash["ctx.bash
Bash executor seam"]
+ pkg_pwsh_local["pwsh-local"]
+ pkg_tool_pwsh["tool-pwsh"]
+ pkg_bash_env["bash-env"]
svc_bashEnv["ctx.bashEnv
Managed bash environment registry"]
pkg_pty["pty"]
svc_pty["ctx.pty
Persistent PTY session registry"]
@@ -170,6 +173,7 @@ flowchart LR
pkg_agent_loop --> svc_agentLoop
pkg_approval --> svc_approval
pkg_bash --> svc_bash
+ pkg_bash_env --> svc_bashEnv
pkg_bash_local --> svc_bash
pkg_bash_sandbox --> svc_bash
pkg_code_runtime --> svc_codeRuntime
@@ -197,6 +201,7 @@ flowchart LR
pkg_plan_mode --> svc_planMode
pkg_pty --> svc_pty
pkg_pty_local --> svc_pty
+ pkg_pwsh_local --> svc_bash
pkg_sandbox --> svc_sandbox
pkg_sandbox_local --> svc_sandbox
pkg_sandbox_policy --> svc_sandboxPolicy
@@ -234,7 +239,6 @@ flowchart LR
pkg_tasks --> svc_tasks
pkg_tasks_local --> svc_tasks
pkg_token_meter --> svc_tokenMeter
- pkg_tool_bash --> svc_bashEnv
pkg_tools --> svc_tools
pkg_tui --> svc_tui
pkg_tui --> svc_userInteraction
@@ -260,6 +264,9 @@ flowchart LR
svc_bash --> pkg_hooks_claude
svc_bash --> pkg_hooks_codex
svc_bash --> pkg_tool_bash
+ svc_bash --> pkg_tool_pwsh
+ svc_bashEnv --> pkg_tool_bash
+ svc_bashEnv --> pkg_tool_pwsh
svc_clientModuleHost --> pkg_hmr
svc_codeRuntime --> pkg_tools
svc_commands --> pkg_tui
@@ -381,8 +388,8 @@ flowchart LR
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. |
| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. |
-| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
-| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
+| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`pwsh-local`](../packages/bash/pwsh-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. |
+| `ctx.bashEnv` | `core` | [`bash-env`](../packages/bash/bash-env) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. |
| `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. |
| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox), [`pty-local`](../packages/pty/pty-local) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
| `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`pty-local`](../packages/pty/pty-local) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. |
diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts
index 824780ae0f..c107a1e2b0 100644
--- a/examples/acp-agent/tests/acp.snapshot.ts
+++ b/examples/acp-agent/tests/acp.snapshot.ts
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import { expect, it } from 'vitest'
import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot'
+import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
/**
@@ -170,7 +171,9 @@ const SCENARIOS: Scenario[] = [
headerClass: 'pwsh',
configPath: PWSH_CONFIG,
// The composition boots the real pwsh executor; hosts without a `pwsh`
- // binary skip the run (fixtures stay guarded).
+ // binary skip the run (fixtures stay guarded). The recorded turn writes
+ // PWSH_OK via [Console]::Out.Write so the fixture carries no platform
+ // newline and one recording replays on every host.
pwshOnly: true,
},
{ name: 'todo-write', hasModelTurn: true, recorded: true },
@@ -435,9 +438,10 @@ const SCENARIOS: Scenario[] = [
},
]
-// Hosts without a `pwsh` binary skip the pwsh-tool-turn run (its fixtures
-// stay guarded); a bare `pwsh` probe keeps this suite dependency-light.
-const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
+// Hosts without a usable PowerShell skip the pwsh-tool-turn run (its fixtures
+// stay guarded); the probe follows the executor's own resolution so a Windows
+// host with only an install-location pwsh still runs the scenario.
+const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
defineAcpSnapshotSuite({
agent: AGENT,
diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json
index 4101a2c1f3..653e9a346c 100644
--- a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json
+++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/input.json
@@ -2,6 +2,6 @@
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
- { "op": "prompt", "text": "Use the pwsh tool to run exactly: Write-Output PWSH_OK. Then reply with the single word DONE and stop." }
+ { "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." }
]
}
diff --git a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl
index 333948541c..9dc17ef799 100644
--- a/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/pwsh-tool-turn/session.jsonl
@@ -1,32 +1,32 @@
-{"type":"session","version":0,"id":"1ec0d099-552b-44e1-8fb8-fd9742fbdc1c","createdAt":1785655505943,"cwd":"{{cwd}}","delegationDepth":0}
-{"type":"turn/start","seq":0,"time":1785655505948,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":1,"time":1785655505948,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: Write-Output PWSH_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"e3bca81a-d4e4-46fb-aa30-916f785c27a0"},"surfaceOp":"append"}
-{"type":"session/title","seq":2,"time":1785655505949,"data":{"title":"Use the pwsh tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
-{"type":"step/start","seq":3,"time":1785655505971,"data":{"turn":1,"step":1}}
-{"type":"request/header","seq":4,"time":1785655505972,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":5,"time":1785655505972,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
-{"type":"assistant/chunk","seq":6,"time":1785655506674,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"reasoning-chunks","seq0":7,"time0":1785655506674,"data":{"turn":1,"step":1,"index":0,"dt":[157,27,1,0,0,0,38,0,0,43,0,0,0,50,0,1,0,0,0,36,0,1,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," that"," outputs"," \"","P","WS","H","_OK","\""," and"," then"," reply"," with"," \"","D","ONE","\"."]}}
-{"type":"assistant/chunk","seq":32,"time":1785655507160,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
-{"type":"tool-call-chunks","seq0":33,"time0":1785655507161,"data":{"turn":1,"step":1,"index":1,"dt":[51,0,0,0,0,32,0,0,0,55,1,0,0,0,74,0,0,0,1,45,0,42,0,0,0,0,54,0],"id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","args":["","{","\"","command","\"",": ","\"","Write","-","Output"," P","WS","H","_OK","\"",", ","\"","description","\"",": ","\"","Output"," P","WS","H","_OK"," string","\"","}"]}}
-{"type":"assistant/chunk","seq":62,"time":1785655507601,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command that outputs \"PWSH_OK\" and then reply with \"DONE\"."}}}}
-{"type":"assistant/chunk","seq":63,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}}}}
-{"type":"assistant/chunk","seq":64,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":816,"outputTokens":96,"cacheReadTokens":0,"reasoningTokens":25}}}}
-{"type":"assistant/chunk","seq":65,"time":1785655507602,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":66,"time":1785655507604,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command that outputs \"PWSH_OK\" and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"4d81cc10-507e-49a8-96f4-aa3ae5ad2eb5"},"usage":{"inputTokens":816,"outputTokens":96,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65],"surfaceOp":"append"}
-{"type":"tool/call","seq":67,"time":1785655507605,"data":{"turn":1,"step":1,"callId":"call_00_oEhmLGLNsvlumiE0WkXD0511","name":"pwsh","arguments":"{\"command\": \"Write-Output PWSH_OK\", \"description\": \"Output PWSH_OK string\"}"}}
-{"type":"tool/result","seq":68,"time":1785655507994,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oEhmLGLNsvlumiE0WkXD0511"},"content":[{"type":"tool-result","toolCallId":"call_00_oEhmLGLNsvlumiE0WkXD0511","content":[{"type":"text","text":"PWSH_OK\n"}],"isError":false}],"role":"user","id":"84a96d55-5bd5-46fc-ab0d-918882c504e2"}},"sourceEventSeqs":[67],"surfaceOp":"append"}
-{"type":"step/end","seq":69,"time":1785655507994,"data":{"turn":1,"step":1}}
-{"type":"step/start","seq":70,"time":1785655508000,"data":{"turn":1,"step":2}}
-{"type":"assistant/chunk","seq":71,"time":1785655508608,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"reasoning-chunks","seq0":72,"time0":1785655508608,"data":{"turn":1,"step":2,"index":0,"dt":[168,38,0,60,1,0,0,0,0,27,1,0,0,0,46,0,0,0,0,0,41],"texts":["The"," command"," successfully"," output"," \"","P","WS","H","_OK","\"."," Now"," I"," should"," reply"," with"," \"","D","ONE","\""," as"," instructed","."]}}
-{"type":"assistant/chunk","seq":94,"time":1785655508990,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
-{"type":"assistant/chunk","seq":95,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
-{"type":"assistant/chunk","seq":96,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
-{"type":"assistant/chunk","seq":97,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command successfully output \"PWSH_OK\". Now I should reply with \"DONE\" as instructed."}}}}
-{"type":"assistant/chunk","seq":98,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
-{"type":"assistant/chunk","seq":99,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":34,"outputTokens":25,"cacheReadTokens":896,"reasoningTokens":22}}}}
-{"type":"assistant/chunk","seq":100,"time":1785655508991,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":101,"time":1785655508991,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command successfully output \"PWSH_OK\". Now I should reply with \"DONE\" as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"d8905016-347c-477a-a8f7-2b31839a8357"},"usage":{"inputTokens":34,"outputTokens":25,"cacheReadTokens":896,"reasoningTokens":22}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"surfaceOp":"append"}
-{"type":"step/end","seq":102,"time":1785655508992,"data":{"turn":1,"step":2}}
-{"type":"turn/end","seq":103,"time":1785655508992,"data":{"turn":1,"reason":{"kind":"completed"}}}
+{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"turn/start","seq":0,"time":1785678162244,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
+{"type":"user/message","seq":1,"time":1785678162245,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"6efbdc24-7abe-4f34-ac6d-15f93d49ad9a"},"surfaceOp":"append"}
+{"type":"session/title","seq":2,"time":1785678162246,"data":{"title":"Use the pwsh tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
+{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":4,"time":1785678162261,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":5,"time":1785678162262,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
+{"type":"assistant/chunk","seq":6,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":7,"time0":1785678162968,"data":{"turn":1,"step":1,"index":0,"dt":[393,0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}}
+{"type":"assistant/chunk","seq":29,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":30,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,0,0,0,0,275,0,1,0,0,0,0,0,0,0],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"",", ","\"","description","\"",": ","\"","Write"," P","WS","H","_OK"," to"," console","\"","}"]}}
+{"type":"assistant/chunk","seq":65,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}}
+{"type":"assistant/chunk","seq":66,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}}}}
+{"type":"assistant/chunk","seq":67,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}}
+{"type":"assistant/chunk","seq":68,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":69,"time":1785678164126,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"6e968eea-46b8-4489-8005-5e898d53c1a9"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"}
+{"type":"tool/call","seq":70,"time":1785678164127,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"}}
+{"type":"tool/result","seq":71,"time":1785678164405,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"964dfad5-651e-47f0-90a5-fe5bc711a3ff"}},"sourceEventSeqs":[70],"surfaceOp":"append"}
+{"type":"step/end","seq":72,"time":1785678164405,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":73,"time":1785678164410,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":74,"time":1785678165135,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":75,"time0":1785678165136,"data":{"turn":1,"step":2,"index":0,"dt":[176,44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}}
+{"type":"assistant/chunk","seq":100,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":101,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
+{"type":"assistant/chunk","seq":102,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":103,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}}
+{"type":"assistant/chunk","seq":104,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","seq":105,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}}
+{"type":"assistant/chunk","seq":106,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":107,"time":1785678165693,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"91f35706-53e9-4fcd-891e-2c9eafccde98"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106],"surfaceOp":"append"}
+{"type":"step/end","seq":108,"time":1785678165694,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":109,"time":1785678165694,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml
index 49d38f2b25..0d11b89d6b 100644
--- a/packages/bash/pwsh-local/README.i18n.yaml
+++ b/packages/bash/pwsh-local/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
-README.md: c5e0cc524e32d2218e734539c74adcddb663d4f3
-README.zh.md: 7906d8a511ee72ecdffaddaf88ddebe195a20857
+README.md: e061d1bd614fa4aa13d3ae9418c2e4af47d72518
+README.zh.md: 3a8d2b7ea41c02dbaf72273b0e9ef0f2c023068f
diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md
index c5e0cc524e..e061d1bd61 100644
--- a/packages/bash/pwsh-local/README.md
+++ b/packages/bash/pwsh-local/README.md
@@ -50,6 +50,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.
-- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble; prefix such scripts with a no-op statement (or `& { … }`) first.
+- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead.
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.
diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md
index 7906d8a511..3a8d2b7ea4 100644
--- a/packages/bash/pwsh-local/README.zh.md
+++ b/packages/bash/pwsh-local/README.zh.md
@@ -50,6 +50,6 @@
- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。
-- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行;请先用无操作语句(或 `& { … }`)开头。
+- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。
清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。
diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts
index e6228ceca8..4f913d78ee 100644
--- a/packages/bash/tool-bash/tests/tools.spec.ts
+++ b/packages/bash/tool-bash/tests/tools.spec.ts
@@ -1017,9 +1017,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
// not renderResult output, so a generic fenced card, no terminal output/exit.
const out = ctx.tools.get('bash')!.presentResult!(
{ command: 'x', description: 'x' },
- { content: [{ type: 'text', text: 'command aborted' }], isError: true },
+ { content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
)
- expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] })
+ expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
})
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts
index 5d0bbe2c76..d484faa24d 100644
--- a/packages/subprocess/subprocess/src/index.ts
+++ b/packages/subprocess/subprocess/src/index.ts
@@ -45,7 +45,10 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
* `HOME`, locale, and proxy variables survive, so child CLIs run normally;
* harness identity never leaks implicitly (a deliberately forwarded
* credential or current `DSH_*` fact goes through the spec's explicit `env`,
- * which merges after this scrub). Exported as a plain function so spawners
+ * which merges after this scrub). Both scrubs match case-insensitively:
+ * Windows environment names are case-insensitive, so a parent `dsh_*` entry
+ * would otherwise survive and read back as `$env:DSH_*` in the child;
+ * deliberate lowercase `dsh_*` names on POSIX are implausible. Exported as a plain function so spawners
* that cannot route through the service (node-pty backends, SDK-managed
* transports) share the one scrub definition.
* @returns a fresh environment object safe to hand to a child spawn.
@@ -53,7 +56,7 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
export function scrubbedParentEnv(): Record {
const env: Record = {}
for (const [key, value] of Object.entries(process.env)) {
- if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value
+ if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value
}
return env
}
diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts
index 28a1dce299..d0ef5c9fd6 100644
--- a/packages/subprocess/subprocess/tests/service.spec.ts
+++ b/packages/subprocess/subprocess/tests/service.spec.ts
@@ -52,20 +52,23 @@ describe('SubprocessService seam', () => {
await expect(ctx.plugin(SecondService)).rejects.toThrow(/service "subprocess" has been registered/)
})
- it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => {
+ it('scrubbedParentEnv drops credential-shaped and DSH_ names (case-insensitively) but keeps PATH', () => {
process.env.DSH_SCRUB_PROBE = 'stale'
+ process.env.dsh_scrub_probe_lower = 'stale'
process.env.SCRUB_PROBE_TOKEN = 'secret'
process.env.SCRUB_PROBE_PASSWORD = 'secret'
process.env.SCRUB_PROBE_PLAIN = 'visible'
try {
const env = scrubbedParentEnv()
expect(env.DSH_SCRUB_PROBE).toBeUndefined()
+ expect(env.dsh_scrub_probe_lower).toBeUndefined()
expect(env.SCRUB_PROBE_TOKEN).toBeUndefined()
expect(env.SCRUB_PROBE_PASSWORD).toBeUndefined()
expect(env.SCRUB_PROBE_PLAIN).toBe('visible')
expect(env.PATH).toBeDefined()
} finally {
delete process.env.DSH_SCRUB_PROBE
+ delete process.env.dsh_scrub_probe_lower
delete process.env.SCRUB_PROBE_TOKEN
delete process.env.SCRUB_PROBE_PASSWORD
delete process.env.SCRUB_PROBE_PLAIN
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 2bbfe56b2c..842467c764 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -338,16 +338,17 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'bash',
title: 'Bash executor seam',
mode: 'seam',
- implementations: ['bash-local', 'bash-sandbox'],
- consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
- note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
+ implementations: ['bash-local', 'bash-sandbox', 'pwsh-local'],
+ consumers: ['tool-bash', 'tool-pwsh', 'hooks-claude', 'hooks-codex'],
+ note: 'The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them.',
},
{
key: 'bashEnv',
- pkg: 'tool-bash',
+ pkg: 'bash-env',
title: 'Managed bash environment registry',
mode: 'core',
- note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.',
+ consumers: ['tool-bash', 'tool-pwsh'],
+ note: 'Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace.',
},
{
key: 'pty',