deepseek-coordination 0.1.9 → 0.10.1

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.
@@ -1,436 +0,0 @@
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, 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
- /** Run `file view` and return its envelope result. */
161
- async function viewFile(invoke, invocation, path, flags = []) {
162
- return invoke(invocation, 'file view', [path], flags)
163
- }
164
-
165
- /**
166
- * Answer a built-in `read` call.
167
- *
168
- * The value must satisfy the built-in schema exactly (`path`, `offset`, `lines[{number,text}]`,
169
- * `totalLines`, additionalProperties false), because that value is what the built-in renderer formats
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.
177
- */
178
- async function handleRead(args, invocation, readLimit, invoke = invokeForResult) {
179
- const path = String(args.file_path)
180
- const offset = Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 1
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
184
-
185
- const view = await viewFile(invoke, invocation, path)
186
- const totalLines = Number.isInteger(view.lineCount) ? view.lineCount : 0
187
-
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
- }
200
-
201
- return { path: view.path ?? path, offset, lines, totalLines }
202
- }
203
-
204
- /**
205
- * Answer a built-in `write` call.
206
- *
207
- * `before` and `after` are the decoded text, matching what the built-in `read` reports for the same
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.
218
- */
219
- async function handleWrite(args, invocation, writeFormat = 'target', invoke = invokeForResult) {
220
- const path = String(args.file_path)
221
- const content = String(args.content ?? '')
222
-
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
- )
238
- return {
239
- path: created.path ?? path,
240
- operation: 'create',
241
- before: null,
242
- after: content,
243
- }
244
- }
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
-
255
- return {
256
- path,
257
- operation: 'update',
258
- before,
259
- after: content,
260
- }
261
- }
262
-
263
- /**
264
- * Answer a built-in `edit` call.
265
- *
266
- * The literal replacement is performed on the decoded text — the same text the model was shown by
267
- * `read` — and the result is written back in the target's own convention. Match counting mirrors the
268
- * built-in contract: one match is required unless `replace_all` is set, and a violation throws so the
269
- * caller falls back to the built-in tool rather than inventing a different answer.
270
- */
271
- async function handleEdit(args, invocation, writeFormat = 'target', invoke = invokeForResult) {
272
- const path = String(args.file_path)
273
- const oldString = String(args.old_string ?? '')
274
- const newString = String(args.new_string ?? '')
275
- const replaceAll = args.replace_all === true
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
-
282
- if (oldString === '') throw new Error('old_string must be a non-empty string')
283
- if (oldString === newString) throw new Error('old_string and new_string must differ')
284
-
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
288
- const before = decodeTextFile(readFileSync(path))
289
-
290
- const occurrences = before.split(oldString).length - 1
291
- if (occurrences === 0) throw new Error(`old_string was not found in ${path}`)
292
- if (occurrences > 1 && !replaceAll) {
293
- throw new Error(`old_string appears ${occurrences} times in ${path}; pass replace_all to replace every occurrence`)
294
- }
295
-
296
- const after = replaceAll
297
- ? before.split(oldString).join(newString)
298
- : before.replace(oldString, newString)
299
-
300
- writeFileAtomic(path, encodeTextForTarget(after, format))
301
-
302
- return { path, before, after }
303
- }
304
-
305
- /**
306
- * Answer a built-in `pwsh` call.
307
- *
308
- * The value must satisfy the built-in foreground variant of the `oneOf` output schema, including
309
- * `kind: "foreground"`, both stream objects with their `truncated` flags, and the null-able
310
- * `exitCode`/`signal` pair.
311
- *
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.
324
- */
325
- async function handlePwsh(args, invocation, timeoutMs) {
326
- const command = String(args.command ?? '')
327
- const workdir = typeof args.workdir === 'string' && args.workdir !== '' ? args.workdir : undefined
328
- const requestedTimeout = Number.isFinite(args.timeoutMs) && args.timeoutMs > 0 ? args.timeoutMs : timeoutMs
329
-
330
- // `pwsh -Command <command>` is what the built-in tool runs, so the same argv shape is handed to
331
- // execute.command: the arguments are passed as separate argv elements and never spliced into a
332
- // shell string.
333
- const flags = ['--timeout', String(Math.round(requestedTimeout))]
334
- if (workdir !== undefined) flags.push('--workdir', workdir)
335
-
336
- const result = await invokeForResult(invocation, 'execute.command', ['pwsh', '-NoLogo', '-NoProfile', '-Command', command], flags, { allow: true, timeoutMs: requestedTimeout + 5000 })
337
-
338
- return {
339
- kind: 'foreground',
340
- exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
341
- signal: null,
342
- timedOut: result.timedOut === true,
343
- aborted: false,
344
- timeoutMs: Math.round(requestedTimeout),
345
- stdout: { text: String(result.stdout ?? ''), truncated: false },
346
- stderr: { text: String(result.stderr ?? ''), truncated: false },
347
- }
348
- }
349
-
350
- // ---------------------------------------------------------------------------
351
- // plugin
352
- // ---------------------------------------------------------------------------
353
-
354
- /**
355
- * Whether a path can be forwarded at all.
356
- *
357
- * An absolute path is required because the built-in tools resolve relative paths against the session
358
- * workspace root through `ctx.fs`, and this plugin has no equivalent of that resolution. Guessing the
359
- * process working directory would read a different file than the one the model named, which is worse
360
- * than not taking the call over.
361
- */
362
- function forwardablePath(value) {
363
- return typeof value === 'string' && value.trim() !== '' && isAbsolute(value)
364
- }
365
-
366
- /** Whether the call is one this row can answer without changing what the result means. */
367
- function eligible(tool, args) {
368
- switch (tool) {
369
- case 'read':
370
- case 'write':
371
- case 'edit':
372
- return forwardablePath(args.file_path)
373
- case 'pwsh':
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.
377
- if (args.run_in_background === true) return false
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
385
- return typeof args.command === 'string' && args.command.trim() !== ''
386
- default:
387
- return false
388
- }
389
- }
390
-
391
- export function apply(ctx, rawConfig) {
392
- // `?? {}` rather than a default parameter: Cordis passes a row's config as `null` when the row declares
393
- // none, and a default parameter only fires for `undefined`, so the first property read threw and the
394
- // row never activated. This row is shipped WITHOUT a config, so it hit exactly that.
395
- const config = rawConfig ?? {}
396
- const toolTimeoutMs = config.timeoutMs ?? 120000
397
- // Mirrors the `tool-fs` row's `readLimit` (2000 by default): the interception must not quietly apply
398
- // a different window size than the built-in it replaced.
399
- const readLimit = Number.isInteger(config.readLimit) && config.readLimit > 0 ? config.readLimit : 2000
400
-
401
- ctx.on('tools/execute', async (exec, next) => {
402
- const settings = ctx['dsh-os-settings']
403
- if (settings === undefined || !settings.enabled()) return next()
404
-
405
- const tool = exec?.name
406
- if (!TAKEOVER_TOOLS.includes(tool)) return next()
407
- if (!settings.toolEnabled(tool)) return next()
408
-
409
- const args = exec.arguments ?? {}
410
- if (!eligible(tool, args)) return next()
411
-
412
- const invocation = resolveInvocation(config)
413
- if (invocation === null) return next()
414
-
415
- try {
416
- let value
417
- if (tool === 'read') value = await handleRead(args, invocation, readLimit)
418
- else if (tool === 'write') value = await handleWrite(args, invocation, settings.writeFormat())
419
- else if (tool === 'edit') value = await handleEdit(args, invocation, settings.writeFormat())
420
- else value = await handlePwsh(args, invocation, toolTimeoutMs)
421
-
422
- settings.record(tool, exec, value)
423
- // Returning without calling next() is what replaces the built-in body. The value still travels
424
- // through the built-in output schema and renderer.
425
- return { value }
426
- } catch (error) {
427
- // Fallback is the contract: a redirected call may change how a result is produced, never whether
428
- // the caller gets one. The reason is recorded so the settings panel can show why.
429
- settings.recordFallback(tool, exec, error)
430
- return next()
431
- }
432
- })
433
- }
434
-
435
- /** Exported for tests: the eligibility rule and the per-tool handlers. */
436
- export const __internals = { eligible, handleRead, handleWrite, handleEdit, handlePwsh }