deepseek-coordination 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * dsh-os takeover: route the built-in file and shell tools onto dsh-os verbs.
3
+ *
4
+ * WHY THIS IS A SEPARATE PLUGIN ROW
5
+ *
6
+ * `dsh_os` is a tool the model has to be *told* to use. In practice it never is: the built-in
7
+ * `read`/`write`/`edit`/`pwsh` tools are right there, they overlap almost completely, and nothing in
8
+ * the prompt prefers one over the other. The result is that the byte-level guarantees this project
9
+ * exists for are never exercised — the plugin is installed and imperceptible.
10
+ *
11
+ * This row removes the choice. When a user enables takeover in the settings panel, the built-in calls
12
+ * are intercepted and answered by the same machinery, so the guarantees apply whether or not the model
13
+ * cooperates.
14
+ *
15
+ * WHY `tools/execute` AND NOT A SHADOWING REGISTRATION
16
+ *
17
+ * `@deepseek-ai/dsh-tools` lets a scoped registration shadow a global tool, which looks like the
18
+ * obvious mechanism. It does not work here. The Web/desktop composition disables the host-side
19
+ * `tool-fs`/`tool-pwsh` rows and remounts them inside every agent *preset* (`dsh-web-app`'s patch),
20
+ * so the built-ins live in a scope layer that is NEARER than any global registration this plugin could
21
+ * make — the preset would shadow us, not the other way round.
22
+ *
23
+ * `tools/execute` (dsh-tools `dispatchScheduledExecution`) is the around-dispatch waterfall:
24
+ *
25
+ * ctx.waterfall(carrier, "tools/execute", mutableExec, () => this.dispatchToolBody(mutableExec))
26
+ *
27
+ * A handler that returns `{ value }` without calling `next()` replaces the tool body's value. The
28
+ * value is then validated against the BUILT-IN tool's own `output.schema` and rendered by the BUILT-IN
29
+ * `output.render` (`normalizeDispatchResult` -> `createSuccessResult`), so the model-facing text, the
30
+ * diff cards and the presentation metadata stay exactly what they were: the only thing that changes is
31
+ * where the bytes came from.
32
+ *
33
+ * WHAT IS DELIBERATELY NOT TAKEN OVER
34
+ *
35
+ * * `glob` and `grep` stay built-in. dsh-os has no content-search verb at all, and `file search`
36
+ * matches names rather than contents, so forwarding them would silently lose results.
37
+ * * `run_in_background` command calls stay built-in: the built-in result for a background call is a
38
+ * job id from the harness job system, and dsh-os's `--background` starts an unrelated process with
39
+ * a pid. Claiming otherwise would hand the model a handle it cannot collect.
40
+ * * Relative paths are not intercepted. The built-in `read`/`write`/`edit` resolve against the
41
+ * session's workspace root through `ctx.fs`; an interception that used the process working
42
+ * directory instead would silently read a different file. Interception requires an absolute path.
43
+ * * `sandbox_permissions` escalation is not honoured. The built-in body asks `ctx.approval` for a
44
+ * wider sandbox before touching the file; a redirected call runs the verb directly, so the file
45
+ * sandbox is not what bounds it — dsh-os's own protected-path check is. This is a real reduction
46
+ * in defence and is documented in README.md rather than hidden.
47
+ *
48
+ * FALLBACK IS THE CONTRACT
49
+ *
50
+ * Every interception is wrapped: anything unexpected — a verb this build does not have, an encoding
51
+ * dsh-os refuses, a malformed path — calls `next()` and lets the built-in tool do the work. Takeover
52
+ * can therefore only ever change HOW a call is answered, never whether it is answered. A model that
53
+ * cannot see the difference is the point.
54
+ */
55
+
56
+ import { randomBytes } from 'node:crypto'
57
+ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
58
+ import { basename, dirname, isAbsolute, join } from 'node:path'
59
+
60
+ import { invokeForResult, resolveInvocation } from './invoke.js'
61
+
62
+ export const name = 'dsh-os-takeover'
63
+
64
+ /** The interception needs the tool pipeline, and the switch lives in the settings service. */
65
+ export const inject = ['tools', 'dsh-os-settings']
66
+
67
+ /**
68
+ * Tools this row knows how to answer.
69
+ *
70
+ * The order is also the order the settings panel presents them in. Leaving a tool out is a supported
71
+ * configuration, not a degraded one: with `read` off, reads are the built-in implementation and writes
72
+ * still go through dsh-os.
73
+ */
74
+ export const TAKEOVER_TOOLS = ['read', 'write', 'edit', 'pwsh']
75
+
76
+ /** Defaults matching the "follow the built-in, preserve the target" policy chosen for this feature. */
77
+ export const DEFAULT_CONFIG = {
78
+ read: true,
79
+ write: true,
80
+ edit: true,
81
+ pwsh: true,
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // target format detection
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Read the byte-level facts the built-in tools do not preserve.
90
+ *
91
+ * The built-in `write` always emits UTF-8 with no BOM and LF line endings. Rewriting a file that had a
92
+ * BOM or CRLF therefore produces a whole-file diff and can break a script that depended on the
93
+ * convention. Which of the two the target already had is only knowable from its bytes, so they are
94
+ * read here rather than guessed.
95
+ *
96
+ * A file that does not exist yields the built-in defaults, which is what makes a newly created file
97
+ * behave exactly as it did before takeover was enabled.
98
+ */
99
+ export function readTargetFormat(path) {
100
+ let bytes
101
+ try {
102
+ bytes = readFileSync(path)
103
+ } catch {
104
+ return { exists: false, bom: false, eol: 'lf' }
105
+ }
106
+ const bom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
107
+ let crlf = 0
108
+ let lf = 0
109
+ for (let index = 0; index < bytes.length; index += 1) {
110
+ if (bytes[index] !== 0x0a) continue
111
+ if (index > 0 && bytes[index - 1] === 0x0d) crlf += 1
112
+ else lf += 1
113
+ }
114
+ // A file with no line ending at all is treated as LF, which is the built-in default and therefore
115
+ // changes nothing about how it will be written.
116
+ const eol = crlf > lf ? 'crlf' : 'lf'
117
+ return { exists: true, bom, eol }
118
+ }
119
+
120
+ /** Decode a text file's bytes the way the built-in `read` does: BOM stripped, CRLF normalised to LF. */
121
+ export function decodeTextFile(bytes) {
122
+ const hasBom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
123
+ const body = hasBom ? bytes.subarray(3) : bytes
124
+ return body.toString('utf8').replace(/\r\n/g, '\n')
125
+ }
126
+
127
+ /** Encode text back into a target's convention, so the write preserves what was already there. */
128
+ export function encodeTextForTarget(text, format) {
129
+ const body = format.eol === 'crlf' ? text.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n') : text.replace(/\r\n/g, '\n')
130
+ const payload = Buffer.from(body, 'utf8')
131
+ if (!format.bom) return payload
132
+ return Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), payload])
133
+ }
134
+
135
+ /**
136
+ * Replace a file's bytes atomically.
137
+ *
138
+ * Same-directory temporary file then `rename`, which is the same shape dsh-os's own `Write-Atomic`
139
+ * uses. A cross-volume rename would not be atomic, and this is never one: the temporary file is a
140
+ * sibling of its target by construction.
141
+ *
142
+ * The temporary name is random rather than sequential so two concurrent interceptions of the same
143
+ * path cannot collide, and it is removed on any failure so a failed write leaves no debris behind.
144
+ */
145
+ export function writeFileAtomic(path, bytes) {
146
+ const temporary = join(dirname(path), `.${basename(path)}.dsh-os-${randomBytes(6).toString('hex')}.tmp`)
147
+ try {
148
+ writeFileSync(temporary, bytes)
149
+ renameSync(temporary, path)
150
+ } catch (error) {
151
+ try { rmSync(temporary, { force: true }) } catch { /* the failure that matters is the original */ }
152
+ throw error
153
+ }
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // per-tool handlers
158
+ // ---------------------------------------------------------------------------
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
+ /** Run `file view` and return its envelope result. */
177
+ async function viewFile(invocation, path, flags = []) {
178
+ return invokeForResult(invocation, 'file view', [path], flags)
179
+ }
180
+
181
+ /**
182
+ * Answer a built-in `read` call.
183
+ *
184
+ * The value must satisfy the built-in schema exactly (`path`, `offset`, `lines[{number,text}]`,
185
+ * `totalLines`, additionalProperties false), because that value is what the built-in renderer formats
186
+ * and what the built-in presentation metadata is derived from.
187
+ */
188
+ async function handleRead(args, invocation) {
189
+ const path = String(args.file_path)
190
+ 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
192
+
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
201
+
202
+ const selected = allLines.slice(offset - 1, offset - 1 + limit)
203
+ const lines = selected.map((lineText, index) => ({ number: offset + index, text: lineText }))
204
+
205
+ return { path: view.path ?? path, offset, lines, totalLines }
206
+ }
207
+
208
+ /**
209
+ * Answer a built-in `write` call.
210
+ *
211
+ * `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.
215
+ */
216
+ async function handleWrite(args, invocation) {
217
+ const path = String(args.file_path)
218
+ const content = String(args.content ?? '')
219
+
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 })
230
+ return {
231
+ path: created.path ?? path,
232
+ operation: 'create',
233
+ before: null,
234
+ after: content,
235
+ }
236
+ }
237
+
238
+ return {
239
+ path,
240
+ operation: 'update',
241
+ before,
242
+ after: content,
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Answer a built-in `edit` call.
248
+ *
249
+ * The literal replacement is performed on the decoded text — the same text the model was shown by
250
+ * `read` — and the result is written back in the target's own convention. Match counting mirrors the
251
+ * built-in contract: one match is required unless `replace_all` is set, and a violation throws so the
252
+ * caller falls back to the built-in tool rather than inventing a different answer.
253
+ */
254
+ async function handleEdit(args, invocation) {
255
+ const path = String(args.file_path)
256
+ const oldString = String(args.old_string ?? '')
257
+ const newString = String(args.new_string ?? '')
258
+ const replaceAll = args.replace_all === true
259
+
260
+ if (oldString === '') throw new Error('old_string must be a non-empty string')
261
+ if (oldString === newString) throw new Error('old_string and new_string must differ')
262
+
263
+ const format = readTargetFormat(path)
264
+ if (!format.exists) throw new Error(`cannot edit '${path}': it does not exist`)
265
+ const before = decodeTextFile(readFileSync(path))
266
+
267
+ const occurrences = before.split(oldString).length - 1
268
+ if (occurrences === 0) throw new Error(`old_string was not found in ${path}`)
269
+ if (occurrences > 1 && !replaceAll) {
270
+ throw new Error(`old_string appears ${occurrences} times in ${path}; pass replace_all to replace every occurrence`)
271
+ }
272
+
273
+ const after = replaceAll
274
+ ? before.split(oldString).join(newString)
275
+ : before.replace(oldString, newString)
276
+
277
+ writeFileAtomic(path, encodeTextForTarget(after, format))
278
+
279
+ return { path, before, after }
280
+ }
281
+
282
+ /**
283
+ * Answer a built-in `pwsh` call.
284
+ *
285
+ * The value must satisfy the built-in foreground variant of the `oneOf` output schema, including
286
+ * `kind: "foreground"`, both stream objects with their `truncated` flags, and the null-able
287
+ * `exitCode`/`signal` pair.
288
+ *
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.
291
+ */
292
+ async function handlePwsh(args, invocation, timeoutMs) {
293
+ const command = String(args.command ?? '')
294
+ const workdir = typeof args.workdir === 'string' && args.workdir !== '' ? args.workdir : undefined
295
+ const requestedTimeout = Number.isFinite(args.timeoutMs) && args.timeoutMs > 0 ? args.timeoutMs : timeoutMs
296
+
297
+ // `pwsh -Command <command>` is what the built-in tool runs, so the same argv shape is handed to
298
+ // execute.command: the arguments are passed as separate argv elements and never spliced into a
299
+ // shell string.
300
+ const flags = ['--timeout', String(Math.round(requestedTimeout))]
301
+ if (workdir !== undefined) flags.push('--workdir', workdir)
302
+
303
+ const result = await invokeForResult(invocation, 'execute.command', ['pwsh', '-NoLogo', '-NoProfile', '-Command', command], flags, { allow: true, timeoutMs: requestedTimeout + 5000 })
304
+
305
+ const stdoutText = String(result.stdout ?? '')
306
+ const stderrText = String(result.stderr ?? '')
307
+
308
+ return {
309
+ kind: 'foreground',
310
+ exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
311
+ signal: null,
312
+ timedOut: result.timedOut === true,
313
+ aborted: false,
314
+ timeoutMs: Math.round(requestedTimeout),
315
+ stdout: { text: stdoutText, truncated: result.stdoutTruncated === true },
316
+ stderr: { text: stderrText, truncated: result.stderrTruncated === true },
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // plugin
322
+ // ---------------------------------------------------------------------------
323
+
324
+ /**
325
+ * Whether a path can be forwarded at all.
326
+ *
327
+ * An absolute path is required because the built-in tools resolve relative paths against the session
328
+ * workspace root through `ctx.fs`, and this plugin has no equivalent of that resolution. Guessing the
329
+ * process working directory would read a different file than the one the model named, which is worse
330
+ * than not taking the call over.
331
+ */
332
+ function forwardablePath(value) {
333
+ return typeof value === 'string' && value.trim() !== '' && isAbsolute(value)
334
+ }
335
+
336
+ /** Whether the call is one this row can answer without changing what the result means. */
337
+ function eligible(tool, args) {
338
+ switch (tool) {
339
+ case 'read':
340
+ case 'write':
341
+ case 'edit':
342
+ return forwardablePath(args.file_path)
343
+ 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.
346
+ if (args.run_in_background === true) return false
347
+ if (args.sandbox_permissions !== undefined) return false
348
+ return typeof args.command === 'string' && args.command.trim() !== ''
349
+ default:
350
+ return false
351
+ }
352
+ }
353
+
354
+ export function apply(ctx, config = {}) {
355
+ const toolTimeoutMs = config.timeoutMs ?? 120000
356
+
357
+ ctx.on('tools/execute', async (exec, next) => {
358
+ const settings = ctx['dsh-os-settings']
359
+ if (settings === undefined || !settings.enabled()) return next()
360
+
361
+ const tool = exec?.name
362
+ if (!TAKEOVER_TOOLS.includes(tool)) return next()
363
+ if (!settings.toolEnabled(tool)) return next()
364
+
365
+ const args = exec.arguments ?? {}
366
+ if (!eligible(tool, args)) return next()
367
+
368
+ const invocation = resolveInvocation(config)
369
+ if (invocation === null) return next()
370
+
371
+ try {
372
+ 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)
376
+ else value = await handlePwsh(args, invocation, toolTimeoutMs)
377
+
378
+ settings.record(tool, exec, value)
379
+ // Returning without calling next() is what replaces the built-in body. The value still travels
380
+ // through the built-in output schema and renderer.
381
+ return { value }
382
+ } catch (error) {
383
+ // Fallback is the contract: a redirected call may change how a result is produced, never whether
384
+ // the caller gets one. The reason is recorded so the settings panel can show why.
385
+ settings.recordFallback(tool, exec, error)
386
+ return next()
387
+ }
388
+ })
389
+ }
390
+
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 }