deepseek-coordination 0.1.9 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +349 -1083
- package/README.zh-CN.md +349 -697
- package/cordis.patch.yml +5 -74
- package/lib/client.js +33772 -0
- package/lib/index.d.mts +252 -0
- package/lib/index.mjs +27374 -0
- package/package.json +98 -114
- package/client.js +0 -338
- package/src/host/bin/dsh-os.bat +0 -19
- package/src/host/bin/dsh-os.ps1 +0 -5039
- package/src/host/bin/dsh-os.sh +0 -3385
- package/src/host/bin/dsh-os7.ps1 +0 -5518
- package/src/host/command.txt +0 -136
- package/src/host/docs/command-original.txt +0 -26
- package/src/host/docs/os-spec.md +0 -528
- package/src/host/docs/permission-model.md +0 -206
- package/src/host/spec/charsets.tsv +0 -28
- package/src/host/spec/verbs.tsv +0 -120
- package/src/permission/index.js +0 -546
- package/src/plugin/index.js +0 -226
- package/src/plugin/invoke.js +0 -306
- package/src/plugin/settings.js +0 -349
- package/src/plugin/takeover.js +0 -436
- package/src/plugin/workflow.js +0 -1494
- package/tools/measure-cache.mjs +0 -223
package/src/plugin/takeover.js
DELETED
|
@@ -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 }
|