dsh-plugin-wsl-env 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +274 -0
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/cordis.patch.yml +306 -0
- package/examples/profile.cordis.patch.yml +36 -0
- package/lib/auto-preset.js +198 -0
- package/lib/fs.js +5 -0
- package/lib/index.js +871 -0
- package/lib/listing.js +112 -0
- package/lib/paths.js +265 -0
- package/lib/picker.js +350 -0
- package/lib/sandbox.js +162 -0
- package/lib/shell-env.js +74 -0
- package/lib/shell.js +5 -0
- package/lib/subprocess.js +230 -0
- package/lib/wsl.js +320 -0
- package/package.json +103 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# The bundle layer this package contributes:
|
|
2
|
+
#
|
|
3
|
+
# "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
|
|
4
|
+
#
|
|
5
|
+
# `dsh plugin --profile <name> add dsh-plugin-wsl-env` installs the package and
|
|
6
|
+
# appends this file as one layer — there is nothing to merge by hand. It is
|
|
7
|
+
# still an ordinary patch layer: a top-level YAML array of loader patch entries
|
|
8
|
+
# (id-targeted config overrides, disables, and insert lists; `!!js` expressions
|
|
9
|
+
# allowed).
|
|
10
|
+
#
|
|
11
|
+
# Layers compose in the order bundles, the profile's own `cordis.patch.yml`,
|
|
12
|
+
# the home-level `cordis.patch.yml`, then `--patch` overlays, and later layers
|
|
13
|
+
# win per row. A row's `config` is replaced whole, never deep-merged, so a user
|
|
14
|
+
# who wants a different value overrides that row by id in their own layer —
|
|
15
|
+
# examples/profile.cordis.patch.yml shows what such a layer looks like.
|
|
16
|
+
#
|
|
17
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
18
|
+
# The GUI terminal window is a COMPOSITION-level environment, not a preset one.
|
|
19
|
+
#
|
|
20
|
+
# The right-sidebar terminal is dsh-api-terminal-controller. It resolves its
|
|
21
|
+
# execution world from the Session's own context —
|
|
22
|
+
# agent.ctx.get("subprocess")
|
|
23
|
+
# — and that context is created by the agent loop under the ROOT realm
|
|
24
|
+
# (dsh-agent-loop: `createScope(loopCtx, …)`, loopCtx = the root `ctx.agents`),
|
|
25
|
+
# while a preset's isolate realms are created by dsh-agent-preset-registry under
|
|
26
|
+
# the REGISTRY's context (`createScope(this.owner, …)`). The two subtrees never
|
|
27
|
+
# meet, so a `subprocess` provider mounted inside preset-wsl — the natural-looking
|
|
28
|
+
# design, since `shell` and `fs` live there — is invisible to the terminal
|
|
29
|
+
# window. The registry ships `agentPresets.serviceFor(agent, name)` for exactly
|
|
30
|
+
# that gap; the terminal controller does not use it.
|
|
31
|
+
#
|
|
32
|
+
# So the terminal is bound here, once, for the whole composition:
|
|
33
|
+
#
|
|
34
|
+
# 1. the shipped local provider is disabled and replaced by a subclass whose
|
|
35
|
+
# ONLY override is `spawnTerminal`, which rewrites a `wsl.exe` launch into
|
|
36
|
+
# `wsl.exe -d <distro> --cd <linux dir>` for the directory the Session
|
|
37
|
+
# supplies. Ordinary `spawn()` — every command, the host ripgrep search,
|
|
38
|
+
# every other consumer — reaches the shipped implementation untouched;
|
|
39
|
+
# 2. the controller is given the one shell profile it should resolve, so the
|
|
40
|
+
# window's default is a distro shell rather than `cmd.exe` inside a UNC
|
|
41
|
+
# workspace. Its shell menu is reduced to that single entry on purpose:
|
|
42
|
+
# shell discovery also runs against the root provider and receives no
|
|
43
|
+
# directory, so offering host executables there would advertise shells this
|
|
44
|
+
# terminal never starts.
|
|
45
|
+
#
|
|
46
|
+
# Consequence: the terminal follows the composition, not the Session. A Session
|
|
47
|
+
# whose workspace is a Windows folder gets the same distro terminal, started in
|
|
48
|
+
# that folder as `/mnt/<drive>/…`. That is the honest limit of a per-session
|
|
49
|
+
# execution world that no Session identity reaches.
|
|
50
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
51
|
+
- id: subprocess
|
|
52
|
+
disabled: true
|
|
53
|
+
|
|
54
|
+
- id: terminal-controller
|
|
55
|
+
name: '@deepseek-ai/dsh-api-terminal-controller'
|
|
56
|
+
config:
|
|
57
|
+
shell:
|
|
58
|
+
path: wsl.exe
|
|
59
|
+
name: WSL
|
|
60
|
+
args: []
|
|
61
|
+
shellCandidates: []
|
|
62
|
+
|
|
63
|
+
- insert:
|
|
64
|
+
- id: subprocess-wsl
|
|
65
|
+
name: 'dsh-plugin-wsl-env/subprocess'
|
|
66
|
+
config:
|
|
67
|
+
distro: ''
|
|
68
|
+
|
|
69
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
70
|
+
# Environment is per-session, not per-process.
|
|
71
|
+
#
|
|
72
|
+
# There is no DSH_WSL switch any more. The profile keeps the SHIPPED environment
|
|
73
|
+
# for host workspaces — sandboxed PowerShell executor, ACL-confined filesystem,
|
|
74
|
+
# and therefore the Permissions selector — while the wsl agent preset (appended
|
|
75
|
+
# below) supplies the WSL environment to agents that select it, isolated to that
|
|
76
|
+
# preset's own service realm. Both can run at the same time in one process.
|
|
77
|
+
#
|
|
78
|
+
# dsh-plugin-wsl-env/auto-preset binds the WSL preset automatically when a new
|
|
79
|
+
# session's workspace directory is inside a distro, and leaves an explicit
|
|
80
|
+
# operator choice alone.
|
|
81
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
82
|
+
|
|
83
|
+
# ── The workspace picker serves BOTH worlds ──────────────────────────────────
|
|
84
|
+
#
|
|
85
|
+
# Kept from the previous design: the shipped chooser resolves to the native OS
|
|
86
|
+
# dialog on Windows, which cannot browse a distro. This one lists the Windows home
|
|
87
|
+
# directory and every installed distro in its root level, so it is the single
|
|
88
|
+
# picker for host and WSL workspaces alike — and the directory it returns is what
|
|
89
|
+
# auto-preset reads to choose the environment.
|
|
90
|
+
- id: directory-picker
|
|
91
|
+
disabled: true
|
|
92
|
+
|
|
93
|
+
- insert:
|
|
94
|
+
- id: directory-picker-wsl
|
|
95
|
+
name: 'dsh-plugin-wsl-env/picker'
|
|
96
|
+
config:
|
|
97
|
+
preferredDistro: ''
|
|
98
|
+
includeHostHome: true
|
|
99
|
+
maxEntries: 1000
|
|
100
|
+
|
|
101
|
+
- id: directory-picker-browse-surface
|
|
102
|
+
name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'
|
|
103
|
+
# Contributes the WSL environment's facts (distro / real shell / home) to the
|
|
104
|
+
# managed DSH_* namespace that dsh-shell-env injects into every model shell
|
|
105
|
+
# call. That is DSH's sanctioned channel for shell-environment facts.
|
|
106
|
+
- id: wsl-shell-env
|
|
107
|
+
name: 'dsh-plugin-wsl-env/shell-env'
|
|
108
|
+
|
|
109
|
+
# Per-session environment selection from the workspace directory. It only
|
|
110
|
+
# rewrites a preset that still equals the registry default, so an operator's
|
|
111
|
+
# explicit choice is never overridden.
|
|
112
|
+
- id: auto-preset
|
|
113
|
+
name: 'dsh-plugin-wsl-env/auto-preset'
|
|
114
|
+
|
|
115
|
+
- insert:
|
|
116
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
117
|
+
# preset-wsl — the WSL environment, scoped to agents that select this preset.
|
|
118
|
+
#
|
|
119
|
+
# This is what makes environments per-session instead of per-process: the shell
|
|
120
|
+
# and fs services are isolated to this preset's realm, so an agent on the host
|
|
121
|
+
# preset keeps the profile's global sandboxed PowerShell executor and ACL-confined
|
|
122
|
+
# filesystem while an agent on this preset gets the distro's login shell and its
|
|
123
|
+
# UNC-backed filesystem — concurrently, in one process.
|
|
124
|
+
#
|
|
125
|
+
# The wrapping group is required by dsh-agent-preset-registry, which audits every
|
|
126
|
+
# preset mount and rejects a provider that is not behind an isolate realm:
|
|
127
|
+
# "Preset services require isolate realms: shell, fs"
|
|
128
|
+
# Because a preset's plugins mount under a REGISTRY-owned scope (not this row's
|
|
129
|
+
# ctx), the realm has to be declared here, inside config.plugins.
|
|
130
|
+
#
|
|
131
|
+
# Generated from the shipped standard preset by build-preset-wsl.mjs so the
|
|
132
|
+
# embedded plan-mode section and every nested group survive byte-for-byte.
|
|
133
|
+
# Re-run it after an app update that changes the preset.
|
|
134
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
135
|
+
- id: preset-wsl
|
|
136
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
137
|
+
config:
|
|
138
|
+
id: wsl
|
|
139
|
+
order: 2
|
|
140
|
+
plugins:
|
|
141
|
+
- id: wsl-env
|
|
142
|
+
name: cordis:group
|
|
143
|
+
group: true
|
|
144
|
+
isolate:
|
|
145
|
+
shell: true
|
|
146
|
+
fs: true
|
|
147
|
+
config:
|
|
148
|
+
# ── the WSL environment for this preset only ────────────────────
|
|
149
|
+
# No `cwd`: an empty default workdir means the distro user's home,
|
|
150
|
+
# asked of the distro itself. Pin a directory here to override it —
|
|
151
|
+
# a value that is right on one machine is wrong on every other.
|
|
152
|
+
#
|
|
153
|
+
# Each command is confined inside the distro by bubblewrap, in the
|
|
154
|
+
# policy's mode, and the file tools are fenced by the same policy.
|
|
155
|
+
# Install bubblewrap there (`sudo apt install bubblewrap`); without
|
|
156
|
+
# it the providers fail closed rather than run unconfined. Add
|
|
157
|
+
# `sandbox: false` to either provider for the old, unconfined
|
|
158
|
+
# posture.
|
|
159
|
+
- id: wsl-shell
|
|
160
|
+
name: 'dsh-plugin-wsl-env'
|
|
161
|
+
config:
|
|
162
|
+
distro: ''
|
|
163
|
+
loginShell: true
|
|
164
|
+
timeoutMs: 120000
|
|
165
|
+
maxTimeoutMs: 600000
|
|
166
|
+
- id: wsl-fs
|
|
167
|
+
name: 'dsh-plugin-wsl-env/fs'
|
|
168
|
+
config:
|
|
169
|
+
distro: ''
|
|
170
|
+
restrictToDistro: true
|
|
171
|
+
- id: persona
|
|
172
|
+
name: '@deepseek-ai/dsh-persona'
|
|
173
|
+
config:
|
|
174
|
+
suffix: Your working directory is {{cwd}}.
|
|
175
|
+
prefix: You are a coding agent powered by the {{model}} model.
|
|
176
|
+
- id: agent-instructions
|
|
177
|
+
name: '@deepseek-ai/dsh-agent-instructions'
|
|
178
|
+
config:
|
|
179
|
+
maxBytes: 65536
|
|
180
|
+
- id: tool-bash
|
|
181
|
+
name: '@deepseek-ai/dsh-tool-bash'
|
|
182
|
+
disabled: false
|
|
183
|
+
- id: tool-pwsh
|
|
184
|
+
name: '@deepseek-ai/dsh-tool-pwsh'
|
|
185
|
+
disabled: true
|
|
186
|
+
- id: tool-fs
|
|
187
|
+
name: '@deepseek-ai/dsh-tool-fs'
|
|
188
|
+
- id: tool-fs-search
|
|
189
|
+
name: '@deepseek-ai/dsh-tool-fs-search'
|
|
190
|
+
config:
|
|
191
|
+
sampleOverCapGlobResults: false
|
|
192
|
+
- id: tool-jobs
|
|
193
|
+
name: '@deepseek-ai/dsh-tool-jobs'
|
|
194
|
+
- id: skill-filesystem
|
|
195
|
+
name: '@deepseek-ai/dsh-skill-filesystem'
|
|
196
|
+
- id: tool-skill
|
|
197
|
+
name: '@deepseek-ai/dsh-tool-skill'
|
|
198
|
+
- id: command-goal
|
|
199
|
+
name: '@deepseek-ai/dsh-command-goal'
|
|
200
|
+
- id: tool-goal
|
|
201
|
+
name: '@deepseek-ai/dsh-tool-goal'
|
|
202
|
+
- id: planning
|
|
203
|
+
name: cordis:group
|
|
204
|
+
group: true
|
|
205
|
+
isolate:
|
|
206
|
+
planMode: true
|
|
207
|
+
config:
|
|
208
|
+
- id: plan-mode
|
|
209
|
+
name: '@deepseek-ai/dsh-plan-mode'
|
|
210
|
+
config:
|
|
211
|
+
section: |
|
|
212
|
+
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
|
213
|
+
|
|
214
|
+
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
|
215
|
+
|
|
216
|
+
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
|
|
217
|
+
|
|
218
|
+
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
|
|
219
|
+
|
|
220
|
+
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
|
|
221
|
+
|
|
222
|
+
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
|
|
223
|
+
- id: compaction
|
|
224
|
+
name: cordis:group
|
|
225
|
+
group: true
|
|
226
|
+
isolate:
|
|
227
|
+
compaction: true
|
|
228
|
+
toolResultPruner: true
|
|
229
|
+
config:
|
|
230
|
+
- id: compaction-basic
|
|
231
|
+
name: '@deepseek-ai/dsh-compaction-basic'
|
|
232
|
+
- id: command-compact
|
|
233
|
+
name: '@deepseek-ai/dsh-command-compact'
|
|
234
|
+
- id: tool-result-pruner
|
|
235
|
+
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
|
236
|
+
config:
|
|
237
|
+
thresholdChars: 8192
|
|
238
|
+
headChars: 4096
|
|
239
|
+
tailChars: 1024
|
|
240
|
+
- id: delegation
|
|
241
|
+
name: cordis:group
|
|
242
|
+
group: true
|
|
243
|
+
isolate:
|
|
244
|
+
workflowEngine: true
|
|
245
|
+
config:
|
|
246
|
+
- id: tool-subagent-control
|
|
247
|
+
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
248
|
+
- id: tool-subagent-list-agents
|
|
249
|
+
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
250
|
+
- id: tool-subagent
|
|
251
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
252
|
+
config:
|
|
253
|
+
provider: spawn
|
|
254
|
+
toolName: subagent
|
|
255
|
+
modelSelectionSettings: true
|
|
256
|
+
backgroundMode: continuable
|
|
257
|
+
- id: tool-subagent-fork
|
|
258
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
259
|
+
config:
|
|
260
|
+
provider: fork
|
|
261
|
+
toolName: subagent_fork
|
|
262
|
+
backgroundMode: continuable
|
|
263
|
+
- id: tool-subagent-codex
|
|
264
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
265
|
+
disabled: true
|
|
266
|
+
config:
|
|
267
|
+
provider: codex
|
|
268
|
+
toolName: subagent_codex
|
|
269
|
+
backgroundMode: one-shot
|
|
270
|
+
maxDepth: provider-managed
|
|
271
|
+
- id: tool-subagent-claude-code
|
|
272
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
273
|
+
disabled: true
|
|
274
|
+
config:
|
|
275
|
+
provider: claude-code
|
|
276
|
+
toolName: subagent_claude_code
|
|
277
|
+
backgroundMode: one-shot
|
|
278
|
+
maxDepth: provider-managed
|
|
279
|
+
- id: workflow-ptc
|
|
280
|
+
name: '@deepseek-ai/dsh-workflow-ptc'
|
|
281
|
+
config:
|
|
282
|
+
provider: spawn
|
|
283
|
+
- id: tool-workflow
|
|
284
|
+
name: '@deepseek-ai/dsh-tool-workflow'
|
|
285
|
+
- id: tool-ralph
|
|
286
|
+
name: '@deepseek-ai/dsh-tool-ralph'
|
|
287
|
+
disabled: true
|
|
288
|
+
config:
|
|
289
|
+
subagentProvider: spawn
|
|
290
|
+
maxRounds: 64
|
|
291
|
+
- id: tool-ask-user
|
|
292
|
+
name: '@deepseek-ai/dsh-tool-ask-user'
|
|
293
|
+
- id: tool-todo
|
|
294
|
+
name: '@deepseek-ai/dsh-tool-todo'
|
|
295
|
+
config:
|
|
296
|
+
allowParallelInProgress: true
|
|
297
|
+
- id: tool-web
|
|
298
|
+
name: '@deepseek-ai/dsh-tool-web'
|
|
299
|
+
config:
|
|
300
|
+
fetch: true
|
|
301
|
+
searchTimeoutMs: 60000
|
|
302
|
+
- id: present
|
|
303
|
+
name: '@deepseek-ai/dsh-tool-present'
|
|
304
|
+
- id: tool-plugin-manager
|
|
305
|
+
name: '@deepseek-ai/dsh-plugin-manager/tools'
|
|
306
|
+
disabled: true
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# A PROFILE patch layer — `$DSH_HOME/profiles/<name>/cordis.patch.yml` — not part
|
|
2
|
+
# of this plugin's bundle. It is applied after every bundle layer, so it is where
|
|
3
|
+
# a machine's own preferences and row overrides belong.
|
|
4
|
+
#
|
|
5
|
+
# This copy is the author's own machine: account/model defaults and the UI
|
|
6
|
+
# verbosity that suits it. Copy the rows you want, not the file, and expect the
|
|
7
|
+
# values to differ on your machine. The plugin's rows are NOT repeated here —
|
|
8
|
+
# they arrive with the bundle (see the package's cordis.patch.yml).
|
|
9
|
+
- id: agent-default-model
|
|
10
|
+
name: "@deepseek-ai/dsh-agent-default-model"
|
|
11
|
+
config:
|
|
12
|
+
provider: deepseek-account
|
|
13
|
+
model: deepseek-flash
|
|
14
|
+
reasoningEffort: high
|
|
15
|
+
|
|
16
|
+
- id: ui-settings-account
|
|
17
|
+
name: "@deepseek-ai/dsh-client-ui-settings-account"
|
|
18
|
+
config:
|
|
19
|
+
version: 1
|
|
20
|
+
step: done
|
|
21
|
+
purpose: development
|
|
22
|
+
process: detailed
|
|
23
|
+
completion: completed
|
|
24
|
+
usage: detailed
|
|
25
|
+
developerTools: true
|
|
26
|
+
|
|
27
|
+
- id: ui-chat
|
|
28
|
+
name: "@deepseek-ai/dsh-client-ui-chat"
|
|
29
|
+
config:
|
|
30
|
+
transcriptView: detailed
|
|
31
|
+
performanceUsage: detailed
|
|
32
|
+
|
|
33
|
+
- id: ui-settings
|
|
34
|
+
name: "@deepseek-ai/dsh-client-ui-settings"
|
|
35
|
+
config:
|
|
36
|
+
enabled: true
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-preset: bind an agent to the WSL preset when its workspace directory
|
|
3
|
+
* lives inside a distro.
|
|
4
|
+
*
|
|
5
|
+
* Why an event hook and not configuration: the environment is chosen in
|
|
6
|
+
* `dsh-api-session-controller`'s `composeAgent(presetId)`, whose signature has no
|
|
7
|
+
* working directory — the cwd only exists one frame up in
|
|
8
|
+
* `ensureSession(sessionId, cwd, ...)`. So the path-aware decision has to be made
|
|
9
|
+
* where the path IS available. `agent/created` carries it:
|
|
10
|
+
*
|
|
11
|
+
* ctx.serial(carrier, "agent/created", { agent, source, signal })
|
|
12
|
+
*
|
|
13
|
+
* and `agent.session.header.cwd` is the session's workspace directory.
|
|
14
|
+
*
|
|
15
|
+
* Two details are load-bearing, both established by experiment:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Respect an explicit choice.** By the time the event fires, the resolved
|
|
18
|
+
* preset is already mounted (`composeAgent` mounts request-or-default in
|
|
19
|
+
* `setup`). We cannot tell which of the two it was — so we only ever rewrite
|
|
20
|
+
* when the mounted preset equals the registry default, which means the client
|
|
21
|
+
* omitted it. An operator's pick is never overridden.
|
|
22
|
+
*
|
|
23
|
+
* 2. **Switch after creation, not during.** Switching inside the listener
|
|
24
|
+
* collides with the previous generation's tools:
|
|
25
|
+
* Error: tool "subagent" is already registered in this scope
|
|
26
|
+
* Deferring past the creating call fixes it — the session is still blank
|
|
27
|
+
* (no turn has started), so `select()` remains legal. A short retry loop
|
|
28
|
+
* absorbs the remaining ordering slack instead of relying on one timer.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-plugin-wsl/auto-preset
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { appendFileSync } from "node:fs";
|
|
34
|
+
import z from "@deepseek-ai/schemastery";
|
|
35
|
+
import { isWslUnc } from "./paths.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Set `DSH_WSL_TRACE` to a file path to record every decision this plugin makes.
|
|
39
|
+
*
|
|
40
|
+
* `ctx.logger` is not an injected service here, so a failure inside it would
|
|
41
|
+
* swallow the message — and a silently-swallowed decision is exactly how a real
|
|
42
|
+
* bug in this plugin stayed invisible once already.
|
|
43
|
+
*/
|
|
44
|
+
const TRACE = process.env.DSH_WSL_TRACE;
|
|
45
|
+
function trace(message) {
|
|
46
|
+
if (TRACE === void 0 || TRACE === "") return;
|
|
47
|
+
try {
|
|
48
|
+
appendFileSync(TRACE, `${new Date().toISOString()} ${message}\n`);
|
|
49
|
+
} catch {
|
|
50
|
+
/* a trace is never worth failing a session over */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const name = "auto-preset";
|
|
55
|
+
|
|
56
|
+
export const inject = ["agents", "agentPresets"];
|
|
57
|
+
|
|
58
|
+
export const Config = z.object({
|
|
59
|
+
/** Preset to bind for a WSL workspace. */
|
|
60
|
+
preset: z.string().default("wsl"),
|
|
61
|
+
/** Attempts before giving up; each waits `retryMs`. */
|
|
62
|
+
attempts: z.natural().min(1).default(6),
|
|
63
|
+
/** Gap between attempts, in milliseconds. */
|
|
64
|
+
retryMs: z.natural().default(150),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export async function apply(ctx, config = {}) {
|
|
68
|
+
const target = config.preset ?? "wsl";
|
|
69
|
+
const attempts = config.attempts ?? 6;
|
|
70
|
+
const retryMs = config.retryMs ?? 150;
|
|
71
|
+
const warn = (message) => {
|
|
72
|
+
trace(`WARN ${message}`);
|
|
73
|
+
// `logger` is not injectable here, so an unavailable or throwing logger must
|
|
74
|
+
// not turn this into a silent no-op: fall back to stderr.
|
|
75
|
+
let logged = false;
|
|
76
|
+
try {
|
|
77
|
+
ctx.logger?.warn?.(`auto-preset: ${message}`);
|
|
78
|
+
logged = ctx.logger !== void 0;
|
|
79
|
+
} catch {
|
|
80
|
+
logged = false;
|
|
81
|
+
}
|
|
82
|
+
if (!logged) {
|
|
83
|
+
try {
|
|
84
|
+
process.stderr.write(`auto-preset: ${message}\n`);
|
|
85
|
+
} catch {
|
|
86
|
+
/* logging is never worth failing a session over */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const consider = (agent) => {
|
|
92
|
+
if (agent === void 0) return;
|
|
93
|
+
|
|
94
|
+
let cwd;
|
|
95
|
+
let current;
|
|
96
|
+
try {
|
|
97
|
+
cwd = agent.session?.header?.cwd;
|
|
98
|
+
current = ctx.agentPresets.composedPreset(agent.ctx);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
warn(`cannot inspect the new agent: ${error?.message ?? error}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
trace(`consider agent=${agent.id} cwd=${JSON.stringify(cwd)} current=${String(current)}`);
|
|
104
|
+
|
|
105
|
+
// Only a workspace inside a distro, and only when the preset is still the
|
|
106
|
+
// registry default (i.e. the operator did not ask for one). `undefined` means
|
|
107
|
+
// the creating caller mounted nothing at all — the headless runner does this
|
|
108
|
+
// — which is likewise "no explicit choice".
|
|
109
|
+
if (!isWslUnc(cwd)) return;
|
|
110
|
+
let fallback;
|
|
111
|
+
try {
|
|
112
|
+
fallback = ctx.agentPresets.defaultId;
|
|
113
|
+
} catch {
|
|
114
|
+
fallback = void 0;
|
|
115
|
+
}
|
|
116
|
+
if (current !== fallback && current !== void 0) return;
|
|
117
|
+
trace(` -> acting: fallback=${String(fallback)} isWsl=${isWslUnc(cwd)}`);
|
|
118
|
+
|
|
119
|
+
// Deferred on purpose — see the module comment.
|
|
120
|
+
void (async () => {
|
|
121
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
122
|
+
try {
|
|
123
|
+
const chosen = await ctx.agentPresets.select(agent, target);
|
|
124
|
+
ctx.logger?.debug?.(`auto-preset: "${cwd}" -> preset "${chosen}"`);
|
|
125
|
+
return;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const code = error?.code;
|
|
128
|
+
// The session already started a turn: its preset is fixed, and that is
|
|
129
|
+
// a legitimate outcome (the operator drove it before we got here).
|
|
130
|
+
if (code === "agent-preset/locked") {
|
|
131
|
+
warn(`"${cwd}" already began its first turn; leaving the preset as "${String(current)}"`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (code === "agent-preset/not-found") {
|
|
135
|
+
warn(`preset "${target}" is not composed in this profile; skipping "${cwd}"`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (attempt === attempts) {
|
|
139
|
+
warn(`could not bind preset "${target}" for "${cwd}": ${code ?? error?.name}: ${error?.message}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
ctx.on("agent/created", (payload) => consider(payload?.agent));
|
|
149
|
+
|
|
150
|
+
// ── Make the INITIAL preset correct, instead of correcting it afterwards ────
|
|
151
|
+
//
|
|
152
|
+
// A WSL workspace mounted on the HOST preset puts the Windows ACL sandbox in
|
|
153
|
+
// front of a `\\wsl.localhost` path, which cannot carry security descriptors:
|
|
154
|
+
// the shell tool then dies in sandbox preparation (`GetNamedSecurityInfoW
|
|
155
|
+
// failed (Win32 1)`) and the workspace is unusable — not merely mis-labelled.
|
|
156
|
+
// Correcting the preset after creation loses whenever the first turn does not
|
|
157
|
+
// wait, so the choice is made where the cwd still exists: the controller
|
|
158
|
+
// resolves the preset in `composeAgent(presetId)`, which has no cwd, but it
|
|
159
|
+
// calls `agents.ensureSession(sessionId, cwd, …, presetId)` — and that frame
|
|
160
|
+
// holds both. Filling the preset there makes the very first mount already
|
|
161
|
+
// correct, so the host sandbox never meets the distro path at all.
|
|
162
|
+
ctx.inject(["sessionController"], (controllerCtx) => {
|
|
163
|
+
const facade = controllerCtx.sessionController?.agents;
|
|
164
|
+
if (facade === void 0 || typeof facade.ensureSession !== "function") {
|
|
165
|
+
warn("sessionController exposes no agents.ensureSession(); the creation-time preset fix is inactive");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const original = facade.ensureSession;
|
|
169
|
+
facade.ensureSession = async function (sessionId, cwd, checkPersistedIdentity, presetId) {
|
|
170
|
+
// An explicit choice always wins, and only a distro workspace is affected.
|
|
171
|
+
if (presetId !== void 0 || !isWslUnc(cwd)) {
|
|
172
|
+
return original.call(this, sessionId, cwd, checkPersistedIdentity, presetId);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return await original.call(this, sessionId, cwd, checkPersistedIdentity, target);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// A session already stored under another preset rejects a different one.
|
|
178
|
+
// Fall back rather than turn adoption into a new failure mode.
|
|
179
|
+
if (!String(error?.message ?? "").includes("agent preset")) throw error;
|
|
180
|
+
trace(`creation-time preset "${target}" refused for ${sessionId}; falling back to the recorded one`);
|
|
181
|
+
return original.call(this, sessionId, cwd, checkPersistedIdentity, presetId);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
trace("creation-time preset fix installed");
|
|
185
|
+
controllerCtx.effect(() => () => {
|
|
186
|
+
facade.ensureSession = original;
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
// Mount order is not a guarantee: a runner that creates its session during
|
|
190
|
+
// startup can finish before this plugin mounts and miss the event entirely
|
|
191
|
+
// (dsh-headless does exactly that). Sweep the agents already live so the
|
|
192
|
+
// decision never depends on who mounted first.
|
|
193
|
+
try {
|
|
194
|
+
for (const agent of ctx.agents.list()) consider(agent);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
warn(`could not sweep already-live agents: ${error?.message ?? error}`);
|
|
197
|
+
}
|
|
198
|
+
}
|