deepseek-coordination 0.1.2 → 0.1.5

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.
@@ -0,0 +1,340 @@
1
+ /**
2
+ * dsh-os settings service: the state the settings panel edits and the takeover reads.
3
+ *
4
+ * WHY THIS EXISTS AS A SERVICE RATHER THAN A CLOSURE IN takeover.js
5
+ *
6
+ * The switch has to be readable by the interception on every tool call, writable by the HTTP routes the
7
+ * browser panel talks to, and visible to both without either one owning the other. A Cordis service is
8
+ * how that is expressed in this runtime: `takeover.js` injects `dsh-os-settings` and calls `enabled()`
9
+ * on each call, and the routes mutate the same object.
10
+ *
11
+ * WHY THE STATE IS A FILE AND NOT THE PROFILE
12
+ *
13
+ * A toggle a user flips in a panel must survive a restart, and `cordis.patch.yml` is a deployment input
14
+ * rather than runtime state: writing it from a plugin would make the profile's patch layer — the layer
15
+ * a human reviews — change underneath them. The state therefore lives in its own JSON document.
16
+ *
17
+ * LOCATION. `DSH_HOME` is preferred over the package root when it is set, because the package directory
18
+ * is the installed artifact: it is replaced wholesale on upgrade, it may be read-only, and on Windows it
19
+ * commonly sits under a path that an installer owns. A setting that only survives until the next upgrade
20
+ * is worse than no setting. The package root remains the fallback so a checkout works with no
21
+ * environment at all.
22
+ *
23
+ * FAILURE POLICY. A settings file that cannot be read is treated as absent and the documented defaults
24
+ * are used; a settings file that cannot be written fails the HTTP call with a readable reason. Neither
25
+ * case may take the host down: the whole point of the feature is that it is optional.
26
+ */
27
+
28
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
29
+ import { randomBytes } from 'node:crypto'
30
+ import { dirname, join } from 'node:path'
31
+
32
+ import { PKG_ROOT } from './invoke.js'
33
+ import { TAKEOVER_TOOLS } from './takeover.js'
34
+
35
+ /** Service name other plugins inject. */
36
+ export const SERVICE_NAME = 'dsh-os-settings'
37
+
38
+ /** How many recent activity entries the panel can show. */
39
+ const ACTIVITY_LIMIT = 50
40
+
41
+ /** The shape of every boolean this service stores, so a hand-edited file cannot invert a default. */
42
+ function asBoolean(value, fallback) {
43
+ return typeof value === 'boolean' ? value : fallback
44
+ }
45
+
46
+ /** Normalise an arbitrary parsed document into the settings shape, keeping only known keys. */
47
+ export function normalizeSettings(raw) {
48
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
49
+ const tools = source.tools !== null && typeof source.tools === 'object' ? source.tools : {}
50
+ const normalizedTools = {}
51
+ for (const tool of TAKEOVER_TOOLS) {
52
+ normalizedTools[tool] = asBoolean(tools[tool], true)
53
+ }
54
+ return {
55
+ version: 1,
56
+ // The master switch.
57
+
58
+ enabled: asBoolean(source.enabled, false),
59
+ tools: normalizedTools,
60
+ // 'target' preserves the target file's BOM and line endings; 'builtin' reproduces the built-in
61
+ // UTF-8-without-BOM, LF behaviour exactly. 'target' is the default because it is the reason the
62
+ // takeover exists.
63
+ writeFormat: source.writeFormat === 'builtin' ? 'builtin' : 'target',
64
+ }
65
+ }
66
+
67
+ /** Where the settings document lives, with the reason each candidate is in the order it is in. */
68
+ export function settingsPath(env = process.env) {
69
+ const home = env.DSH_HOME
70
+ if (typeof home === 'string' && home.trim() !== '') return join(home, 'dsh-os', 'settings.json')
71
+ return join(PKG_ROOT, '.dsh-os-settings.json')
72
+ }
73
+
74
+ /** Read the document, or the defaults. Never throws: an unreadable file is an absent one. */
75
+ export function readSettings(file) {
76
+ try {
77
+ if (!existsSync(file)) return normalizeSettings(undefined)
78
+ const raw = readFileSync(file, 'utf8')
79
+ if (raw.trim() === '') return normalizeSettings(undefined)
80
+ return normalizeSettings(JSON.parse(raw))
81
+ } catch {
82
+ return normalizeSettings(undefined)
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Write the document atomically: sibling temporary file, then rename.
88
+ *
89
+ * A panel toggle and a tool call can overlap, and a half-written settings file would be read as
90
+ * "defaults" on the next start — silently disabling a feature the user turned on. Rename is atomic on
91
+ * the same filesystem, and the temporary file is a sibling by construction.
92
+ */
93
+ export function writeSettings(file, settings) {
94
+ mkdirSync(dirname(file), { recursive: true })
95
+ const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`
96
+ try {
97
+ writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
98
+ renameSync(temporary, file)
99
+ } catch (error) {
100
+ try { rmSync(temporary, { force: true }) } catch { /* the original failure is the one that matters */ }
101
+ throw error
102
+ }
103
+ }
104
+
105
+ /**
106
+ * The live settings object, registered as a Cordis service.
107
+ *
108
+ * `record`/`recordFallback` exist so the panel can show what actually happened rather than only what is
109
+ * configured: "takeover is on" and "takeover is answering calls" are different claims, and only the
110
+ * second one is evidence that the feature works.
111
+ */
112
+ export class SettingsService {
113
+ constructor(ctx, config = {}) {
114
+ this.ctx = ctx
115
+ this.file = config.settingsPath ?? settingsPath()
116
+ this.settings = readSettings(this.file)
117
+ this.activity = []
118
+ this.listeners = new Set()
119
+ }
120
+
121
+ /** The whole document, as the panel renders it. */
122
+ current() {
123
+ return {
124
+ ...this.settings,
125
+ path: this.file,
126
+ tools: { ...this.settings.tools },
127
+ activity: [...this.activity],
128
+ }
129
+ }
130
+
131
+ /** Whether takeover is answering calls at all: the master switch. */
132
+ enabled() {
133
+ return this.settings.enabled === true
134
+ }
135
+
136
+ /** Whether one tool is taken over. Meaningless while the master switch is off, and reported as such. */
137
+ toolEnabled(tool) {
138
+ if (!this.settings.enabled) return false
139
+ return this.settings.tools?.[tool] === true
140
+ }
141
+
142
+ /**
143
+ * How a redirected write should encode its bytes: `'target'` preserves the file's own BOM and line
144
+ * endings, `'builtin'` reproduces the built-in UTF-8-without-BOM, LF behaviour exactly.
145
+ */
146
+ writeFormat() {
147
+ return this.settings.writeFormat === 'builtin' ? 'builtin' : 'target'
148
+ }
149
+
150
+ /** Apply a partial update, persist it, and tell the panel. Returns the new document. */
151
+ update(patch) {
152
+ const next = normalizeSettings({ ...this.settings, ...patch, tools: { ...this.settings.tools, ...(patch.tools ?? {}) } })
153
+ writeSettings(this.file, next)
154
+ this.settings = next
155
+ this.emit()
156
+ return this.current()
157
+ }
158
+
159
+ /**
160
+ * Record one intercepted call.
161
+ *
162
+ * Bounded and in-memory on purpose: this is a live activity feed for a panel, not an audit log. The
163
+ * durable trail is `permission.json`'s audit array, written by the consent delegate.
164
+ */
165
+ record(tool, exec, value) {
166
+ this.push({
167
+ at: new Date().toISOString(),
168
+ tool,
169
+ outcome: 'taken-over',
170
+ // The path or command, which is what makes the feed readable at a glance.
171
+ subject: subjectOf(tool, exec?.arguments),
172
+ bytes: typeof value?.after === 'string' ? Buffer.byteLength(value.after, 'utf8') : undefined,
173
+ })
174
+ }
175
+
176
+ /** Record that an interception declined a call and the built-in tool answered it instead. */
177
+ recordFallback(tool, exec, error) {
178
+ this.push({
179
+ at: new Date().toISOString(),
180
+ tool,
181
+ outcome: 'fell-back',
182
+ subject: subjectOf(tool, exec?.arguments),
183
+ reason: error instanceof Error ? error.message : String(error),
184
+ })
185
+ }
186
+
187
+ push(entry) {
188
+ this.activity.push(entry)
189
+ if (this.activity.length > ACTIVITY_LIMIT) this.activity.splice(0, this.activity.length - ACTIVITY_LIMIT)
190
+ this.emit()
191
+ }
192
+
193
+ /** Subscribe to changes; returns the unsubscribe function, as `ctx.effect` expects. */
194
+ subscribe(listener) {
195
+ this.listeners.add(listener)
196
+ return () => this.listeners.delete(listener)
197
+ }
198
+
199
+ emit() {
200
+ for (const listener of this.listeners) {
201
+ try { listener() } catch { /* a panel listener must not break a tool call */ }
202
+ }
203
+ }
204
+ }
205
+
206
+ /** The tool-call subject a human would recognise: the file for file tools, the command for pwsh. */
207
+ function subjectOf(tool, args) {
208
+ if (args === null || typeof args !== 'object') return ''
209
+ if (tool === 'pwsh') return typeof args.command === 'string' ? args.command.slice(0, 200) : ''
210
+ return typeof args.file_path === 'string' ? args.file_path : ''
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // HTTP surface for the browser panel
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /** Write one JSON response. Local rather than imported: this package has no runtime dependencies. */
218
+ function writeJson(res, status, value) {
219
+ const body = JSON.stringify(value)
220
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
221
+ res.end(body)
222
+ }
223
+
224
+ /** Read one JSON request body with a ceiling, so a malformed caller cannot grow the heap. */
225
+ function readJsonBody(req, limit = 65536) {
226
+ return new Promise((resolve, reject) => {
227
+ const chunks = []
228
+ let size = 0
229
+ req.on('data', (chunk) => {
230
+ size += chunk.length
231
+ if (size > limit) {
232
+ reject(new Error('request body too large'))
233
+ req.destroy()
234
+ return
235
+ }
236
+ chunks.push(chunk)
237
+ })
238
+ req.on('error', reject)
239
+ req.on('end', () => {
240
+ const text = Buffer.concat(chunks).toString('utf8').trim()
241
+ if (text === '') return resolve({})
242
+ try { resolve(JSON.parse(text)) } catch (error) { reject(new Error(`request body is not JSON: ${error.message}`)) }
243
+ })
244
+ })
245
+ }
246
+
247
+ /**
248
+ * The panel's three endpoints.
249
+ *
250
+ * They are registered through `ctx.webServer`, the same seam the built-in SPA and the other UI plugins
251
+ * use, so they are reachable at the same origin with no port, token or CORS negotiation.
252
+ */
253
+ export function makeSettingsRoutes(service) {
254
+ const state = () => ({ ok: true, settings: service.current(), available: TAKEOVER_TOOLS })
255
+ return [
256
+ {
257
+ kind: 'exact',
258
+ path: '/api/dsh-os/settings',
259
+ handler: (req, res) => {
260
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' })
261
+ writeJson(res, 200, state())
262
+ },
263
+ },
264
+ {
265
+ kind: 'exact',
266
+ path: '/api/dsh-os/set',
267
+ handler: (req, res) => {
268
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' })
269
+ readJsonBody(req).then((body) => {
270
+ // Only known keys are forwarded: `update` normalises anyway, but refusing an unknown shape here
271
+ // means a typo in the panel is a visible error instead of a silent no-op.
272
+ const patch = {}
273
+ if (typeof body.enabled === 'boolean') patch.enabled = body.enabled
274
+ if (body.writeFormat === 'target' || body.writeFormat === 'builtin') patch.writeFormat = body.writeFormat
275
+ if (body.tools !== null && typeof body.tools === 'object') {
276
+ patch.tools = {}
277
+ for (const tool of TAKEOVER_TOOLS) {
278
+ if (typeof body.tools[tool] === 'boolean') patch.tools[tool] = body.tools[tool]
279
+ }
280
+ }
281
+ if (Object.keys(patch).length === 0) {
282
+ writeJson(res, 400, { ok: false, error: 'no recognised setting in the request body' })
283
+ return
284
+ }
285
+ try {
286
+ service.update(patch)
287
+ writeJson(res, 200, state())
288
+ } catch (error) {
289
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
290
+ }
291
+ }, (error) => {
292
+ writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
293
+ })
294
+ },
295
+ },
296
+ ]
297
+ }
298
+
299
+ /**
300
+ * The plugin row for the settings service.
301
+ *
302
+ * There is deliberately NO `inject` here, and that is the whole point of this shape.
303
+ *
304
+ * An `inject: ['webServer']` on the row would gate the row's activation on a service that a profile may
305
+ * legitimately never provide — and the settings service is what the TAKEOVER injects, so a profile
306
+ * without a web server would not merely lose the panel, it would silently lose the interception too.
307
+ * The panel is an interface; the interception is the feature.
308
+ *
309
+ * So the service is provided unconditionally, and the HTTP routes are registered through `ctx.inject`,
310
+ * which runs its callback when (and only when) `webServer` is available and unwinds it if it goes away.
311
+ * A headless deployment therefore keeps a working takeover with no endpoints, and a Web one gets both.
312
+ */
313
+ export const name = 'dsh-os-settings'
314
+
315
+ export function apply(ctx, config = {}) {
316
+ const service = new SettingsService(ctx, config)
317
+
318
+ // `ctx.provide` REGISTERS the service and returns a disposer; `ctx.set` only overwrites the value of a
319
+ // service that was already provided, and calling it first throws
320
+ // Error: cannot set property "dsh-os-settings" without provide
321
+ // which would abort the whole entry. Same trap the consent delegate documents.
322
+ const disposeService = ctx.provide?.(SERVICE_NAME, service)
323
+ void disposeService
324
+
325
+ // A context without the injection API (the unit tests' stand-in) simply gets no routes, which is
326
+ // honest rather than half-registered.
327
+ if (typeof ctx.inject !== 'function') return
328
+
329
+ ctx.inject(['webServer'], (scoped) => {
330
+ scoped.effect(() => {
331
+ const disposers = makeSettingsRoutes(service).map((route) => scoped.webServer.register(route))
332
+ return () => {
333
+ for (const dispose of disposers) dispose()
334
+ }
335
+ }, 'dsh-os-settings: routes')
336
+ })
337
+ }
338
+
339
+ /** Exported for tests. */
340
+ export const __internals = { normalizeSettings, writeJson, readJsonBody, subjectOf, ACTIVITY_LIMIT }
@@ -54,7 +54,7 @@
54
54
  */
55
55
 
56
56
  import { randomBytes } from 'node:crypto'
57
- import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
57
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
58
58
  import { basename, dirname, isAbsolute, join } from 'node:path'
59
59
 
60
60
  import { invokeForResult, resolveInvocation } from './invoke.js'
@@ -157,25 +157,9 @@ export function writeFileAtomic(path, bytes) {
157
157
  // per-tool handlers
158
158
  // ---------------------------------------------------------------------------
159
159
 
160
- /** Split an `output.text` slice into the `{ number, text }` records the built-in `read` reports. */
161
- function windowFromSlice(sliceText, offset, totalLines) {
162
- const all = sliceText.split('\n')
163
- // dsh-os splits on LF and a file ending in a newline therefore yields a final empty element. The
164
- // built-in `read` does not report that trailing element as a line, so it is dropped here rather than
165
- // inflating totalLines by one.
166
- if (all.length > 0 && all[all.length - 1] === '') all.pop()
167
- const lines = []
168
- for (let index = 0; index < all.length; index += 1) {
169
- const number = offset + index
170
- if (number > totalLines) break
171
- lines.push({ number, text: all[index] })
172
- }
173
- return lines
174
- }
175
-
176
160
  /** Run `file view` and return its envelope result. */
177
- async function viewFile(invocation, path, flags = []) {
178
- return invokeForResult(invocation, 'file view', [path], flags)
161
+ async function viewFile(invoke, invocation, path, flags = []) {
162
+ return invoke(invocation, 'file view', [path], flags)
179
163
  }
180
164
 
181
165
  /**
@@ -184,23 +168,35 @@ async function viewFile(invocation, path, flags = []) {
184
168
  * The value must satisfy the built-in schema exactly (`path`, `offset`, `lines[{number,text}]`,
185
169
  * `totalLines`, additionalProperties false), because that value is what the built-in renderer formats
186
170
  * and what the built-in presentation metadata is derived from.
171
+ *
172
+ * `lines` and `lineCount` are read from the envelope rather than derived from `text`. That is not a
173
+ * style choice: `file view` builds its line objects BEFORE applying `--max-bytes` (file.ps1:387-419),
174
+ * so `lineCount` is the file's real line count while `text` is a truncated slice of it. Deriving
175
+ * `totalLines` from `text` would silently under-report every file larger than the flag's cap, and the
176
+ * built-in renderer uses `totalLines` to decide whether to print the "use offset to continue" footer.
187
177
  */
188
- async function handleRead(args, invocation) {
178
+ async function handleRead(args, invocation, readLimit, invoke = invokeForResult) {
189
179
  const path = String(args.file_path)
190
180
  const offset = Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 1
191
- const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 2000
181
+ // The built-in default comes from the `tool-fs` row's `readLimit` config (2000), so it is carried in
182
+ // rather than hardcoded: a deployment that raised it must not see a different window size here.
183
+ const limit = Number.isInteger(args.limit) && args.limit > 0 ? Math.min(args.limit, readLimit) : readLimit
192
184
 
193
- const view = await viewFile(invocation, path)
194
- // `lineCount` counts the trailing empty element the same way `split` does, so the reported total is
195
- // taken from the slice arithmetic instead: the verb's own text is authoritative.
196
- const text = String(view.text ?? '')
197
- const endsWithNewline = text.endsWith('\n')
198
- const allLines = text.split('\n')
199
- if (endsWithNewline) allLines.pop()
200
- const totalLines = allLines.length
185
+ const view = await viewFile(invoke, invocation, path)
186
+ const totalLines = Number.isInteger(view.lineCount) ? view.lineCount : 0
201
187
 
202
- const selected = allLines.slice(offset - 1, offset - 1 + limit)
203
- const lines = selected.map((lineText, index) => ({ number: offset + index, text: lineText }))
188
+ // The built-in tool treats an offset past the end of a non-empty file as an error rather than an
189
+ // empty read, so a caller that asked for line 900 of a 10-line file is told, not handed nothing.
190
+ if (totalLines > 0 && offset > totalLines) {
191
+ throw new Error(`offset ${offset} is out of range for ${path}, which has ${totalLines} line(s)`)
192
+ }
193
+
194
+ const all = Array.isArray(view.lines) ? view.lines : []
195
+ const lines = []
196
+ for (const line of all) {
197
+ if (line.number < offset || line.number > offset + limit - 1) continue
198
+ lines.push({ number: line.number, text: String(line.text ?? '') })
199
+ }
204
200
 
205
201
  return { path: view.path ?? path, offset, lines, totalLines }
206
202
  }
@@ -209,24 +205,36 @@ async function handleRead(args, invocation) {
209
205
  * Answer a built-in `write` call.
210
206
  *
211
207
  * `before` and `after` are the decoded text, matching what the built-in `read` reports for the same
212
- * file, while the bytes actually landing on disk keep the target's original BOM and line endings. That
213
- * split is deliberate: the values are compared by the model and diffed by the UI (both of which work in
214
- * normalised text), and only the byte-level write needs the file's convention.
208
+ * file, while the bytes actually landing on disk follow `writeFormat`:
209
+ *
210
+ * * `'target'` (the default) keeps the target's original BOM and line endings, which is the reason the
211
+ * takeover exists: rewriting a CRLF or BOM file as plain LF+UTF-8 produces a whole-file diff and can
212
+ * break whatever depended on the convention;
213
+ * * `'builtin'` reproduces the built-in tool exactly — UTF-8 without a BOM, LF — for a caller who wants
214
+ * takeover to change where the bytes came from and nothing else.
215
+ *
216
+ * The split between value and bytes is deliberate: the values are compared by the model and diffed by
217
+ * the UI, both of which work in normalised text, and only the byte-level write needs the convention.
215
218
  */
216
- async function handleWrite(args, invocation) {
219
+ async function handleWrite(args, invocation, writeFormat = 'target', invoke = invokeForResult) {
217
220
  const path = String(args.file_path)
218
221
  const content = String(args.content ?? '')
219
222
 
220
- const before = existsSync(path) ? decodeTextFile(readFileSync(path)) : null
221
- const format = readTargetFormat(path)
222
-
223
- writeFileAtomic(path, encodeTextForTarget(content, format))
224
-
225
- // The verb is still consulted for the write itself only when the target did not exist: `file create`
226
- // refuses to overwrite by contract, so an existing target is replaced here. A new target goes through
227
- // the verb, which is where the parent-directory creation and the protected-path check live.
228
- if (before === null) {
229
- const created = await invokeForResult(invocation, 'file create', [path, content, format.bom ? 'utf-8-bom' : 'utf-8'], [], { allow: true })
223
+ // A target that does not exist yet goes through `file create`, which is where parent-directory
224
+ // creation and the protected-path check live. The order matters: an earlier version wrote the file
225
+ // first and only then called the verb, which made both of those guarantees unreachable — the write
226
+ // had already succeeded (or already failed on a missing parent) before the verb ever ran.
227
+ //
228
+ // A new file is always UTF-8 without a BOM and LF, under either write format: there is no target
229
+ // convention to preserve, so the built-in behaviour is the only defensible answer.
230
+ if (!existsSync(path)) {
231
+ const created = await invoke(
232
+ invocation,
233
+ 'file create',
234
+ [path, content, 'utf-8'],
235
+ [],
236
+ { allow: true },
237
+ )
230
238
  return {
231
239
  path: created.path ?? path,
232
240
  operation: 'create',
@@ -235,6 +243,15 @@ async function handleWrite(args, invocation) {
235
243
  }
236
244
  }
237
245
 
246
+ const before = decodeTextFile(readFileSync(path))
247
+ const format = writeFormat === 'builtin' ? { bom: false, eol: 'lf' } : readTargetFormat(path)
248
+
249
+ // The existing target is replaced here because `file create` refuses to overwrite by contract
250
+ // (`E_EXISTS`), and no verb performs a whole-file replacement. The bytes still go through dsh-os's
251
+ // encoding rules — `readTargetFormat` reads the BOM and line-ending convention off the target and
252
+ // `encodeTextForTarget` re-applies them — which is the behaviour this takeover exists to provide.
253
+ writeFileAtomic(path, encodeTextForTarget(content, format))
254
+
238
255
  return {
239
256
  path,
240
257
  operation: 'update',
@@ -251,17 +268,23 @@ async function handleWrite(args, invocation) {
251
268
  * built-in contract: one match is required unless `replace_all` is set, and a violation throws so the
252
269
  * caller falls back to the built-in tool rather than inventing a different answer.
253
270
  */
254
- async function handleEdit(args, invocation) {
271
+ async function handleEdit(args, invocation, writeFormat = 'target', invoke = invokeForResult) {
255
272
  const path = String(args.file_path)
256
273
  const oldString = String(args.old_string ?? '')
257
274
  const newString = String(args.new_string ?? '')
258
275
  const replaceAll = args.replace_all === true
259
276
 
277
+ // An edit never creates, so the verb layer is not consulted; the injectable invoker is accepted for
278
+ // symmetry with the other handlers and so a test can pin that no verb is reached on this path.
279
+ void invoke
280
+ void invocation
281
+
260
282
  if (oldString === '') throw new Error('old_string must be a non-empty string')
261
283
  if (oldString === newString) throw new Error('old_string and new_string must differ')
262
284
 
263
- const format = readTargetFormat(path)
264
- if (!format.exists) throw new Error(`cannot edit '${path}': it does not exist`)
285
+ const target = readTargetFormat(path)
286
+ if (!target.exists) throw new Error(`cannot edit '${path}': it does not exist`)
287
+ const format = writeFormat === 'builtin' ? { bom: false, eol: 'lf' } : target
265
288
  const before = decodeTextFile(readFileSync(path))
266
289
 
267
290
  const occurrences = before.split(oldString).length - 1
@@ -286,8 +309,18 @@ async function handleEdit(args, invocation) {
286
309
  * `kind: "foreground"`, both stream objects with their `truncated` flags, and the null-able
287
310
  * `exitCode`/`signal` pair.
288
311
  *
289
- * `signal` is always null: dsh-os reports a numeric exit status and does not surface the terminating
290
- * signal separately, and inventing a value there would be a claim the envelope does not support.
312
+ * Two fields are necessarily constant here, and both are honest rather than lazy:
313
+ *
314
+ * * `signal` is always null. An envelope carries a numeric exit status and no terminating-signal
315
+ * field, so the built-in `[killed by signal: X]` marker is unreachable through this path.
316
+ * * `truncated` is always false. The `execute.command` result carries the whole stdout string with no
317
+ * cap and no spill path, so there is nothing to report as truncated. The built-in tool's own
318
+ * `[output truncated; full output: …]` marker is therefore also unreachable here — which is a real
319
+ * loss for a command that prints megabytes, not a cosmetic one.
320
+ *
321
+ * `timedOut` IS reachable: a deadline that expires returns an envelope with `timedOut: true` and a null
322
+ * exit code rather than throwing (execute.ps1), so the built-in `[timed out after Nms]` marker is
323
+ * preserved.
291
324
  */
292
325
  async function handlePwsh(args, invocation, timeoutMs) {
293
326
  const command = String(args.command ?? '')
@@ -302,9 +335,6 @@ async function handlePwsh(args, invocation, timeoutMs) {
302
335
 
303
336
  const result = await invokeForResult(invocation, 'execute.command', ['pwsh', '-NoLogo', '-NoProfile', '-Command', command], flags, { allow: true, timeoutMs: requestedTimeout + 5000 })
304
337
 
305
- const stdoutText = String(result.stdout ?? '')
306
- const stderrText = String(result.stderr ?? '')
307
-
308
338
  return {
309
339
  kind: 'foreground',
310
340
  exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
@@ -312,8 +342,8 @@ async function handlePwsh(args, invocation, timeoutMs) {
312
342
  timedOut: result.timedOut === true,
313
343
  aborted: false,
314
344
  timeoutMs: Math.round(requestedTimeout),
315
- stdout: { text: stdoutText, truncated: result.stdoutTruncated === true },
316
- stderr: { text: stderrText, truncated: result.stderrTruncated === true },
345
+ stdout: { text: String(result.stdout ?? ''), truncated: false },
346
+ stderr: { text: String(result.stderr ?? ''), truncated: false },
317
347
  }
318
348
  }
319
349
 
@@ -341,10 +371,17 @@ function eligible(tool, args) {
341
371
  case 'edit':
342
372
  return forwardablePath(args.file_path)
343
373
  case 'pwsh':
344
- // A background call's result is a harness job id, and this surface cannot produce one. The
345
- // session-scoped sandbox escalation arguments are likewise the harness's own seam.
374
+ // A background call's result is a harness job id, and this surface cannot produce one: the verb
375
+ // rejects `--background` outright. The session-scoped sandbox escalation arguments are likewise
376
+ // the harness's own seam and are not consulted here.
346
377
  if (args.run_in_background === true) return false
347
378
  if (args.sandbox_permissions !== undefined) return false
379
+ // A relative workdir is resolved by the built-in tool against the session's working directory, and
380
+ // this plugin has no equivalent of that resolution. It is NOT because the verb would resolve it
381
+ // wrongly: `execute.command` sets the spawned process's working directory explicitly
382
+ // (execute.ps1 `$psi.WorkingDirectory`), so an absolute path behaves identically either way. The
383
+ // gap is only in resolving a relative one, so only a relative one keeps the built-in path.
384
+ if (args.workdir !== undefined && !forwardablePath(args.workdir)) return false
348
385
  return typeof args.command === 'string' && args.command.trim() !== ''
349
386
  default:
350
387
  return false
@@ -353,6 +390,9 @@ function eligible(tool, args) {
353
390
 
354
391
  export function apply(ctx, config = {}) {
355
392
  const toolTimeoutMs = config.timeoutMs ?? 120000
393
+ // Mirrors the `tool-fs` row's `readLimit` (2000 by default): the interception must not quietly apply
394
+ // a different window size than the built-in it replaced.
395
+ const readLimit = Number.isInteger(config.readLimit) && config.readLimit > 0 ? config.readLimit : 2000
356
396
 
357
397
  ctx.on('tools/execute', async (exec, next) => {
358
398
  const settings = ctx['dsh-os-settings']
@@ -370,9 +410,9 @@ export function apply(ctx, config = {}) {
370
410
 
371
411
  try {
372
412
  let value
373
- if (tool === 'read') value = await handleRead(args, invocation)
374
- else if (tool === 'write') value = await handleWrite(args, invocation)
375
- else if (tool === 'edit') value = await handleEdit(args, invocation)
413
+ if (tool === 'read') value = await handleRead(args, invocation, readLimit)
414
+ else if (tool === 'write') value = await handleWrite(args, invocation, settings.writeFormat())
415
+ else if (tool === 'edit') value = await handleEdit(args, invocation, settings.writeFormat())
376
416
  else value = await handlePwsh(args, invocation, toolTimeoutMs)
377
417
 
378
418
  settings.record(tool, exec, value)
@@ -388,5 +428,5 @@ export function apply(ctx, config = {}) {
388
428
  })
389
429
  }
390
430
 
391
- /** Exported for tests: the mapping from built-in arguments onto dsh-os argv. */
392
- export const __internals = { eligible, handleRead, handleWrite, handleEdit, handlePwsh, windowFromSlice, statSync }
431
+ /** Exported for tests: the eligibility rule and the per-tool handlers. */
432
+ export const __internals = { eligible, handleRead, handleWrite, handleEdit, handlePwsh }