dsh-plugin-wsl-env 0.7.5 → 0.8.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/CONTRIBUTING.md +129 -39
  3. package/README.md +20 -20
  4. package/docs/ARCHITECTURE.md +6 -3
  5. package/docs/LIMITATIONS.md +13 -0
  6. package/docs/README.zh.md +20 -20
  7. package/lib/agent-confined.d.ts +42 -0
  8. package/lib/agent-confined.js +25 -41
  9. package/lib/agent-errors.d.ts +31 -0
  10. package/lib/agent-errors.js +19 -16
  11. package/lib/agent-exec.d.ts +159 -0
  12. package/lib/agent-exec.js +151 -165
  13. package/lib/agent-protocol.d.ts +192 -0
  14. package/lib/agent-protocol.js +76 -100
  15. package/lib/agent-shared.d.ts +27 -0
  16. package/lib/agent-shared.js +12 -12
  17. package/lib/agent.d.ts +353 -0
  18. package/lib/agent.js +536 -552
  19. package/lib/auto-preset.d.ts +110 -0
  20. package/lib/auto-preset.js +157 -141
  21. package/lib/bwrap.d.ts +90 -0
  22. package/lib/bwrap.js +18 -18
  23. package/lib/file-reference-wsl.d.ts +160 -0
  24. package/lib/file-reference-wsl.js +192 -173
  25. package/lib/fs-decisions.d.ts +107 -0
  26. package/lib/fs-decisions.js +64 -60
  27. package/lib/fs-routing.d.ts +182 -0
  28. package/lib/fs-routing.js +173 -116
  29. package/lib/fs-substrate.d.ts +258 -0
  30. package/lib/fs-substrate.js +248 -248
  31. package/lib/fs.d.ts +10 -0
  32. package/lib/fs.js +5 -0
  33. package/lib/fsio-agent.d.ts +307 -0
  34. package/lib/fsio-agent.js +414 -410
  35. package/lib/fsio-text.d.ts +115 -0
  36. package/lib/fsio-text.js +104 -84
  37. package/lib/git-route.d.ts +100 -0
  38. package/lib/git-route.js +46 -47
  39. package/lib/index.d.ts +1003 -0
  40. package/lib/index.js +1101 -1003
  41. package/lib/listing.d.ts +114 -0
  42. package/lib/listing.js +68 -55
  43. package/lib/paths.d.ts +182 -0
  44. package/lib/paths.js +134 -109
  45. package/lib/picker.d.ts +266 -0
  46. package/lib/picker.js +371 -351
  47. package/lib/ports.d.ts +44 -0
  48. package/lib/ports.js +39 -29
  49. package/lib/preset-choice.d.ts +33 -0
  50. package/lib/preset-choice.js +15 -7
  51. package/lib/probe-cache.d.ts +40 -0
  52. package/lib/probe-cache.js +35 -29
  53. package/lib/sandbox-core.d.ts +79 -0
  54. package/lib/sandbox-core.js +71 -88
  55. package/lib/sandbox.d.ts +48 -0
  56. package/lib/sandbox.js +4 -4
  57. package/lib/search-exec.d.ts +96 -0
  58. package/lib/search-exec.js +52 -79
  59. package/lib/search-route.d.ts +75 -0
  60. package/lib/search-route.js +30 -31
  61. package/lib/shell-env.d.ts +88 -0
  62. package/lib/shell-env.js +78 -74
  63. package/lib/shell.d.ts +10 -0
  64. package/lib/shell.js +5 -0
  65. package/lib/subprocess.d.ts +313 -0
  66. package/lib/subprocess.js +267 -262
  67. package/lib/terminal-activity.d.ts +98 -0
  68. package/lib/terminal-activity.js +29 -39
  69. package/lib/terminal-route.d.ts +27 -0
  70. package/lib/terminal-route.js +16 -8
  71. package/lib/watcher.d.ts +77 -0
  72. package/lib/watcher.js +120 -126
  73. package/lib/workspace-files-route.d.ts +132 -0
  74. package/lib/workspace-files-route.js +73 -81
  75. package/lib/workspace-files-wsl.d.ts +257 -0
  76. package/lib/workspace-files-wsl.js +311 -251
  77. package/lib/wsl.d.ts +266 -0
  78. package/lib/wsl.js +144 -155
  79. package/package.json +68 -20
@@ -27,268 +27,268 @@
27
27
  * boundary, so this module is unit-testable in a bare checkout against the
28
28
  * real agent script.
29
29
  *
30
+ * This is a TypeScript source built to `lib/fs-substrate.js`; edit THIS file
31
+ * and run `pnpm run build` — the artifact under `lib/` is generated, and
32
+ * `pnpm test` fails when it drifts.
33
+ *
30
34
  * @module dsh-plugin-wsl/fs-substrate
31
35
  */
32
-
33
36
  import { applyLiteralEdit, FsCodedError, normalizeLineEndings, restoreLineEndings } from "./fsio-text.js";
34
37
  import { DistroFs } from "./fsio-agent.js";
35
38
  import { posixToUnc, uncToPosix } from "./paths.js";
36
-
37
- /** The provider's stat/lstat row, type-spelled the way the tool layer reads it. */
39
+ /**
40
+ * The provider's stat/lstat row, type-spelled the way the tool layer reads it.
41
+ *
42
+ * @param info - the distro stat row.
43
+ * @returns the provider-shaped row.
44
+ */
38
45
  function toInfo({ version, type, size }) {
39
- return {
40
- version,
41
- type: type === "f" ? "file" : type === "d" ? "directory" : type === "l" ? "symlink" : "other",
42
- size,
43
- };
46
+ return {
47
+ version,
48
+ type: type === "f" ? "file" : type === "d" ? "directory" : type === "l" ? "symlink" : "other",
49
+ size,
50
+ };
44
51
  }
45
-
46
52
  /**
47
53
  * One distro's provider-shaped filesystem face.
48
54
  */
49
55
  export class AgentSubstrate {
50
- /**
51
- * @param {object} deps - the substrate's collaborators.
52
- * @param {import("./agent.js").WslAgent} deps.agent - the resident in-distro agent reads ride.
53
- * @param {string} deps.distro - the pinned distro name, for UNC identity.
54
- * @param {number} [deps.diffBasisMaxBytes] - the overwrite-diff bound, mirroring the provider config.
55
- * @param {(policy: {mode: string, workspaceRoot: string}|undefined) => Promise<import("./agent.js").WslAgent>} [deps.agentFor]
56
- * stage-two routing: the agent a MUTATION must run on for this resolved
57
- * policy — the confined resident whose mount table matches the granted
58
- * rights. Omitted (or resolving to `deps.agent`) keeps every op on the
59
- * plain resident, which is stage one's posture.
60
- */
61
- constructor({ agent, agentFor, distro, diffBasisMaxBytes = 10 * 1024 * 1024 }) {
62
- this.distro = distro;
63
- this.diffBasisMaxBytes = diffBasisMaxBytes;
64
- this.agent = agent;
65
- this.agentFor = agentFor;
66
- this.fs = new DistroFs({ agent, distro });
67
- /** Per-targetKey tail promise: the host backend's mutation serialization. */
68
- this.locks = new Map();
69
- }
70
-
71
- /**
72
- * The agent a mutation runs on under this resolved policy.
73
- * @param {{mode: string, workspaceRoot: string}|undefined} policy - the
74
- * resolved file-effect policy, or undefined when the caller pre-checked.
75
- * @returns {Promise<object>} the agent to publish through.
76
- */
77
- async mutationAgent(policy) {
78
- if (this.agentFor === undefined) return this.agent;
79
- return this.agentFor(policy);
80
- }
81
-
82
- /** Run `op` with exclusive access to `targetKey` (FIFO per key). */
83
- async lock(targetKey, op) {
84
- const run = (this.locks.get(targetKey) ?? Promise.resolve()).then(op, op);
85
- const tail = run.then(() => undefined, () => undefined);
86
- this.locks.set(targetKey, tail);
87
- try {
88
- return await run;
89
- } finally {
90
- if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey);
56
+ /**
57
+ * @param deps - the substrate's collaborators.
58
+ */
59
+ constructor({ agent, agentFor, distro, diffBasisMaxBytes = 10 * 1024 * 1024 }) {
60
+ this.distro = distro;
61
+ this.diffBasisMaxBytes = diffBasisMaxBytes;
62
+ this.agent = agent;
63
+ this.agentFor = agentFor;
64
+ this.fs = new DistroFs({ agent, distro });
65
+ this.locks = new Map();
91
66
  }
92
- }
93
-
94
- /** The Linux path a resolved target names, falling back to its display. */
95
- linuxOf(target) {
96
- return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
97
- }
98
-
99
- /**
100
- * Resolve one absolute Linux path to the target shape the provider returns:
101
- * the canonical Linux spelling the model sees and the UNC identity the
102
- * harness keys on. Symlinks resolve here, where they live.
103
- * @param {string} linuxPath - the absolute Linux path to resolve.
104
- * @param {AbortSignal} [signal] - cancels the round trip.
105
- * @returns {Promise<{displayPath: string, targetKey: string}>}
106
- */
107
- async resolve(linuxPath, signal) {
108
- const canonical = await this.fs.canonicalPath(linuxPath, signal);
109
- return { displayPath: canonical, targetKey: posixToUnc(this.distro, canonical) };
110
- }
111
-
112
- /**
113
- * Follow-stat one target; `undefined` when absent.
114
- * @param {object} target - the resolved target.
115
- * @param {AbortSignal} [signal] - cancels the round trip.
116
- * @returns {Promise<{version: string, type: string, size: number}|undefined>}
117
- */
118
- async stat(target, signal) {
119
- const info = await this.fs.stat(this.linuxOf(target), { signal });
120
- return info === null ? undefined : toInfo(info);
121
- }
122
-
123
- /**
124
- * No-follow stat one path; `undefined` when absent, `symlink` for a link.
125
- * @param {string} linuxPath - the absolute Linux path to inspect.
126
- * @param {AbortSignal} [signal] - cancels the round trip.
127
- * @returns {Promise<{version: string, type: string, size: number}|undefined>}
128
- */
129
- async lstat(linuxPath, signal) {
130
- if (String(linuxPath).trim().length === 0) {
131
- throw new FsCodedError("file_path must be a non-empty string", "FS_NOT_FOUND");
67
+ /**
68
+ * The agent a mutation runs on under this resolved policy.
69
+ *
70
+ * @param policy - the resolved file-effect policy, or undefined when the
71
+ * caller pre-checked.
72
+ * @returns the agent to publish through.
73
+ */
74
+ async mutationAgent(policy) {
75
+ if (this.agentFor === undefined)
76
+ return this.agent;
77
+ return this.agentFor(policy);
132
78
  }
133
- const info = await this.fs.stat(linuxPath, { follow: false, signal });
134
- return info === null ? undefined : toInfo(info);
135
- }
136
-
137
- /**
138
- * Read a whole regular UTF-8 file.
139
- * @param {object} target - the resolved target.
140
- * @param {AbortSignal} [signal] - aborts the read.
141
- * @returns {Promise<string>} the full decoded text.
142
- */
143
- async readText(target, signal) {
144
- return this.fs.readWholeText(target, signal);
145
- }
146
-
147
- /**
148
- * Stream a whole regular UTF-8 file as decoded chunks.
149
- * @param {object} target - the resolved target.
150
- * @param {AbortSignal} [signal] - aborts the stream.
151
- * @returns {Promise<AsyncGenerator<string>>} the chunk stream.
152
- */
153
- async streamText(target, signal) {
154
- return this.fs.streamWholeText(target, signal);
155
- }
156
-
157
- /**
158
- * Read a whole regular file as raw bytes, bounded.
159
- * @param {object} target - the resolved target.
160
- * @param {AbortSignal|undefined} signal - aborts the read.
161
- * @param {number} maxBytes - inclusive byte cap.
162
- * @returns {Promise<Uint8Array>} the bytes.
163
- */
164
- async readBytes(target, signal, maxBytes) {
165
- return this.fs.readWholeBytes(target, signal, maxBytes);
166
- }
167
-
168
- /**
169
- * Read one byte window of a regular file.
170
- * @param {object} target - the resolved target.
171
- * @param {{offset: number, length: number}} range - the window.
172
- * @param {AbortSignal} [signal] - aborts the read.
173
- * @returns {Promise<Uint8Array>} the window's bytes.
174
- */
175
- async readByteRange(target, range, signal) {
176
- return this.fs.readByteWindow(target, range, signal);
177
- }
178
-
179
- /**
180
- * List one directory's direct children.
181
- * @param {object} target - the resolved directory.
182
- * @param {AbortSignal} [signal] - aborts between children.
183
- * @returns {Promise<Array<{name: string, type: string, target: {displayPath: string, targetKey: string}, version?: string, size?: number}>>}
184
- */
185
- async listDir(target, signal) {
186
- return this.fs.listChildren(target, signal);
187
- }
188
-
189
- /**
190
- * Write a whole text file with the host backend's orchestration: probe,
191
- * guard, best-effort diff basis, atomic publication, outcome shape. The
192
- * guard is FORWARDED — a guarded create publishes with the distro-side
193
- * no-replace link (atomic), and a `replaceIfVersion` write carries its
194
- * version into the op, where the agent re-verifies it one syscall before
195
- * the rename and refuses with `FS_STALE_VERSION` on a mismatch.
196
- *
197
- * @param {object} target - the resolved target.
198
- * @param {string} content - the full new file content.
199
- * @param {{kind: string, version?: string}|undefined} expected - the write intent.
200
- * @param {AbortSignal} [signal] - cancels the write.
201
- * @param {{mode: string, workspaceRoot: string}|undefined} [policy] - the
202
- * resolved policy this write was fenced with; selects the confined
203
- * resident when stage-two routing is wired.
204
- * @returns {Promise<{operation: string, version: string, before: string|null, after: string}>}
205
- */
206
- async writeText(target, content, expected, signal, policy) {
207
- const mutationAgent = await this.mutationAgent(policy);
208
- return this.lock(target.targetKey, async () => {
209
- const linuxPath = this.linuxOf(target);
210
- const existing = await this.fs.stat(linuxPath, { signal });
211
- if (existing && existing.type !== "f") {
212
- throw new FsCodedError(`cannot write "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
213
- }
214
- if (expected?.kind === "replaceIfVersion") {
215
- if (!existing) throw new FsCodedError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
216
- if (existing.version !== expected.version) {
217
- throw new FsCodedError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
79
+ /**
80
+ * Run `op` with exclusive access to `targetKey` (FIFO per key).
81
+ *
82
+ * @template T
83
+ * @param targetKey - the mutation's serialization key.
84
+ * @param op - the exclusive section.
85
+ * @returns the section's outcome.
86
+ */
87
+ async lock(targetKey, op) {
88
+ const run = (this.locks.get(targetKey) ?? Promise.resolve()).then(op, op);
89
+ const tail = run.then(() => undefined, () => undefined);
90
+ this.locks.set(targetKey, tail);
91
+ try {
92
+ return await run;
93
+ }
94
+ finally {
95
+ if (this.locks.get(targetKey) === tail)
96
+ this.locks.delete(targetKey);
218
97
  }
219
- } else if (expected?.kind === "createIfAbsent" && existing) {
220
- throw new FsCodedError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
221
- }
222
- const before =
223
- existing !== null && Buffer.byteLength(content, "utf8") < this.diffBasisMaxBytes
224
- ? await this.fs.readTextForDiff(target, this.diffBasisMaxBytes, signal)
225
- : null;
226
- await this.fs.writeFileAtomic(linuxPath, content, {
227
- mode: existing?.mode,
228
- createIfAbsent: expected?.kind === "createIfAbsent" ? { displayPath: target.displayPath } : undefined,
229
- expectedVersion: expected?.kind === "replaceIfVersion" ? expected.version : undefined,
230
- signal,
231
- agent: mutationAgent,
232
- });
233
- const after = await this.fs.stat(linuxPath, { signal });
234
- return {
235
- operation: existing ? "update" : "create",
236
- version: after ? after.version : `missing:${target.targetKey}`,
237
- before,
238
- after: normalizeLineEndings(content),
239
- };
240
- });
241
- }
242
-
243
- /**
244
- * Edit a text file with the host backend's orchestration: probe, version
245
- * guard, LF-normalized read, literal replacement, line-ending restore,
246
- * atomic publication, outcome shape. The version the edit was based on
247
- * rides the write op and is re-verified distro-side one syscall before the
248
- * rename, so a concurrent writer wins instead of being clobbered.
249
- *
250
- * @param {object} target - the resolved target.
251
- * @param {{oldString: string, newString: string, replaceAll?: boolean}} edit - the literal request.
252
- * @param {{version: string}|undefined} expected - the version guard.
253
- * @param {AbortSignal} [signal] - cancels the edit.
254
- * @param {{mode: string, workspaceRoot: string}|undefined} [policy] - the
255
- * resolved policy this edit was fenced with; selects the confined
256
- * resident when stage-two routing is wired.
257
- * @returns {Promise<{version: string, before: string, after: string}>}
258
- */
259
- async editText(target, edit, expected, signal, policy) {
260
- const mutationAgent = await this.mutationAgent(policy);
261
- return this.lock(target.targetKey, async () => {
262
- const linuxPath = this.linuxOf(target);
263
- const existing = await this.fs.stat(linuxPath, { signal });
264
- if (!existing) {
265
- // A file that is not there NOW splits by evidence: an edit grounded
266
- // in a read (it carries the expected version) lost its file, so the
267
- // guard is stale; an edit with no read behind it names a file that
268
- // never answered the stat - the read path's missing-file dialect.
269
- if (expected) {
270
- throw new FsCodedError(`cannot edit "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
98
+ }
99
+ /**
100
+ * The Linux path a resolved target names, falling back to its display.
101
+ *
102
+ * @param target - the resolved target.
103
+ * @returns the Linux path to operate on.
104
+ */
105
+ linuxOf(target) {
106
+ return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
107
+ }
108
+ /**
109
+ * Resolve one absolute Linux path to the target shape the provider returns:
110
+ * the canonical Linux spelling the model sees and the UNC identity the
111
+ * harness keys on. Symlinks resolve here, where they live.
112
+ *
113
+ * @param linuxPath - the absolute Linux path to resolve.
114
+ * @param signal - cancels the round trip.
115
+ */
116
+ async resolve(linuxPath, signal) {
117
+ const canonical = await this.fs.canonicalPath(linuxPath, signal);
118
+ return { displayPath: canonical, targetKey: posixToUnc(this.distro, canonical) };
119
+ }
120
+ /**
121
+ * Follow-stat one target; `undefined` when absent.
122
+ *
123
+ * @param target - the resolved target.
124
+ * @param signal - cancels the round trip.
125
+ */
126
+ async stat(target, signal) {
127
+ const info = await this.fs.stat(this.linuxOf(target), { signal });
128
+ return info === null ? undefined : toInfo(info);
129
+ }
130
+ /**
131
+ * No-follow stat one path; `undefined` when absent, `symlink` for a link.
132
+ *
133
+ * @param linuxPath - the absolute Linux path to inspect.
134
+ * @param signal - cancels the round trip.
135
+ */
136
+ async lstat(linuxPath, signal) {
137
+ if (String(linuxPath).trim().length === 0) {
138
+ throw new FsCodedError("file_path must be a non-empty string", "FS_NOT_FOUND");
271
139
  }
272
- throw new FsCodedError(`cannot edit "${target.displayPath}": file does not exist`, "FS_NOT_FOUND");
273
- }
274
- if (existing.type !== "f") {
275
- throw new FsCodedError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
276
- }
277
- if (expected && existing.version !== expected.version) {
278
- throw new FsCodedError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
279
- }
280
- const original = await this.fs.readForEdit(target, signal);
281
- const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath);
282
- const content = restoreLineEndings(edited.content, original.lineEndings);
283
- await this.fs.writeFileAtomic(linuxPath, content, { mode: existing.mode, expectedVersion: existing.version, signal, agent: mutationAgent });
284
- const after = await this.fs.stat(linuxPath, { signal });
285
- return {
286
- version: after ? after.version : `missing:${target.targetKey}`,
287
- before: original.content,
288
- after: edited.content,
289
- };
290
- });
291
- }
140
+ const info = await this.fs.stat(linuxPath, { follow: false, signal });
141
+ return info === null ? undefined : toInfo(info);
142
+ }
143
+ /**
144
+ * Read a whole regular UTF-8 file.
145
+ *
146
+ * @param target - the resolved target.
147
+ * @param signal - aborts the read.
148
+ * @returns the full decoded text.
149
+ */
150
+ async readText(target, signal) {
151
+ return this.fs.readWholeText(target, signal);
152
+ }
153
+ /**
154
+ * Stream a whole regular UTF-8 file as decoded chunks.
155
+ *
156
+ * @param target - the resolved target.
157
+ * @param signal - aborts the stream.
158
+ * @returns the chunk stream.
159
+ */
160
+ async streamText(target, signal) {
161
+ return this.fs.streamWholeText(target, signal);
162
+ }
163
+ /**
164
+ * Read a whole regular file as raw bytes, bounded.
165
+ *
166
+ * @param target - the resolved target.
167
+ * @param signal - aborts the read.
168
+ * @param maxBytes - inclusive byte cap.
169
+ * @returns the bytes.
170
+ */
171
+ async readBytes(target, signal, maxBytes) {
172
+ return this.fs.readWholeBytes(target, signal, maxBytes);
173
+ }
174
+ /**
175
+ * Read one byte window of a regular file.
176
+ *
177
+ * @param target - the resolved target.
178
+ * @param range - the window.
179
+ * @param signal - aborts the read.
180
+ * @returns the window's bytes.
181
+ */
182
+ async readByteRange(target, range, signal) {
183
+ return this.fs.readByteWindow(target, range, signal);
184
+ }
185
+ /**
186
+ * List one directory's direct children.
187
+ *
188
+ * @param target - the resolved directory.
189
+ * @param signal - aborts between children.
190
+ */
191
+ async listDir(target, signal) {
192
+ return this.fs.listChildren(target, signal);
193
+ }
194
+ /**
195
+ * Write a whole text file with the host backend's orchestration: probe,
196
+ * guard, best-effort diff basis, atomic publication, outcome shape. The
197
+ * guard is FORWARDED — a guarded create publishes with the distro-side
198
+ * no-replace link (atomic), and a `replaceIfVersion` write carries its
199
+ * version into the op, where the agent re-verifies it one syscall before
200
+ * the rename and refuses with `FS_STALE_VERSION` on a mismatch.
201
+ *
202
+ * @param target - the resolved target.
203
+ * @param content - the full new file content.
204
+ * @param expected - the write intent.
205
+ * @param signal - cancels the write.
206
+ * @param policy - the resolved policy this write was fenced with; selects
207
+ * the confined resident when stage-two routing is wired.
208
+ */
209
+ async writeText(target, content, expected, signal, policy) {
210
+ const mutationAgent = await this.mutationAgent(policy);
211
+ return this.lock(target.targetKey, async () => {
212
+ const linuxPath = this.linuxOf(target);
213
+ const existing = await this.fs.stat(linuxPath, { signal });
214
+ if (existing && existing.type !== "f") {
215
+ throw new FsCodedError(`cannot write "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
216
+ }
217
+ if (expected?.kind === "replaceIfVersion") {
218
+ if (!existing)
219
+ throw new FsCodedError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
220
+ if (existing.version !== expected.version) {
221
+ throw new FsCodedError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
222
+ }
223
+ }
224
+ else if (expected?.kind === "createIfAbsent" && existing) {
225
+ throw new FsCodedError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
226
+ }
227
+ const before = existing !== null && Buffer.byteLength(content, "utf8") < this.diffBasisMaxBytes
228
+ ? await this.fs.readTextForDiff(target, this.diffBasisMaxBytes, signal)
229
+ : null;
230
+ await this.fs.writeFileAtomic(linuxPath, content, {
231
+ mode: existing?.mode,
232
+ createIfAbsent: expected?.kind === "createIfAbsent" ? { displayPath: target.displayPath } : undefined,
233
+ expectedVersion: expected?.kind === "replaceIfVersion" ? expected.version : undefined,
234
+ signal,
235
+ agent: mutationAgent,
236
+ });
237
+ const after = await this.fs.stat(linuxPath, { signal });
238
+ return {
239
+ operation: existing ? "update" : "create",
240
+ version: after ? after.version : `missing:${target.targetKey}`,
241
+ before,
242
+ after: normalizeLineEndings(content),
243
+ };
244
+ });
245
+ }
246
+ /**
247
+ * Edit a text file with the host backend's orchestration: probe, version
248
+ * guard, LF-normalized read, literal replacement, line-ending restore,
249
+ * atomic publication, outcome shape. The version the edit was based on
250
+ * rides the write op and is re-verified distro-side one syscall before the
251
+ * rename, so a concurrent writer wins instead of being clobbered.
252
+ *
253
+ * @param target - the resolved target.
254
+ * @param edit - the literal request.
255
+ * @param expected - the version guard.
256
+ * @param signal - cancels the edit.
257
+ * @param policy - the resolved policy this edit was fenced with; selects
258
+ * the confined resident when stage-two routing is wired.
259
+ */
260
+ async editText(target, edit, expected, signal, policy) {
261
+ const mutationAgent = await this.mutationAgent(policy);
262
+ return this.lock(target.targetKey, async () => {
263
+ const linuxPath = this.linuxOf(target);
264
+ const existing = await this.fs.stat(linuxPath, { signal });
265
+ if (!existing) {
266
+ // A file that is not there NOW splits by evidence: an edit grounded
267
+ // in a read (it carries the expected version) lost its file, so the
268
+ // guard is stale; an edit with no read behind it names a file that
269
+ // never answered the stat - the read path's missing-file dialect.
270
+ if (expected) {
271
+ throw new FsCodedError(`cannot edit "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
272
+ }
273
+ throw new FsCodedError(`cannot edit "${target.displayPath}": file does not exist`, "FS_NOT_FOUND");
274
+ }
275
+ if (existing.type !== "f") {
276
+ throw new FsCodedError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
277
+ }
278
+ if (expected && existing.version !== expected.version) {
279
+ throw new FsCodedError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
280
+ }
281
+ const original = await this.fs.readForEdit(target, signal);
282
+ const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath);
283
+ const content = restoreLineEndings(edited.content, original.lineEndings);
284
+ await this.fs.writeFileAtomic(linuxPath, content, { mode: existing.mode, expectedVersion: existing.version, signal, agent: mutationAgent });
285
+ const after = await this.fs.stat(linuxPath, { signal });
286
+ return {
287
+ version: after ? after.version : `missing:${target.targetKey}`,
288
+ before: original.content,
289
+ after: edited.content,
290
+ };
291
+ });
292
+ }
292
293
  }
293
-
294
294
  export default AgentSubstrate;
package/lib/fs.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `ctx.fs` entrypoint — load as `dsh-plugin-wsl/fs` in a profile patch.
3
+ *
4
+ * This is a TypeScript source built to `lib/fs.js`; edit THIS file and run
5
+ * `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
6
+ * fails when it drifts.
7
+ *
8
+ * @module dsh-plugin-wsl/fs
9
+ */
10
+ export { WslFileSystem as default, WslFileSystem } from "./index.js";
package/lib/fs.js CHANGED
@@ -1,5 +1,10 @@
1
1
  /**
2
2
  * `ctx.fs` entrypoint — load as `dsh-plugin-wsl/fs` in a profile patch.
3
+ *
4
+ * This is a TypeScript source built to `lib/fs.js`; edit THIS file and run
5
+ * `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
6
+ * fails when it drifts.
7
+ *
3
8
  * @module dsh-plugin-wsl/fs
4
9
  */
5
10
  export { WslFileSystem as default, WslFileSystem } from "./index.js";