dsh-computers 0.0.0-stage → 1.3.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 -0
- package/PROTOCOL.md +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/tools.js
ADDED
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing half of dsh-computers (PROTOCOL §5, §11.1, §11.2, §11.3).
|
|
3
|
+
*
|
|
4
|
+
* Tools: `computer_list`, `computer_exec` (foreground, background job, or
|
|
5
|
+
* timeout-promoted job), and the remote file tools `computer_read`,
|
|
6
|
+
* `computer_write`, `computer_edit`, `computer_glob`, `computer_grep`.
|
|
7
|
+
*
|
|
8
|
+
* Every tool resolves the same target: explicit `computer` argument → the
|
|
9
|
+
* session pin → an actionable error, then applies the same gates in the same
|
|
10
|
+
* order (activation, agent version, capability, access, presence).
|
|
11
|
+
*
|
|
12
|
+
* Tool definitions are raw JSON Schema objects (no `@deepseek-ai/*` imports,
|
|
13
|
+
* so the bundle loads in any profile), therefore argument validation lives
|
|
14
|
+
* here and produces messages the model can act on.
|
|
15
|
+
*
|
|
16
|
+
* Node builtins only.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { HubError } from './hub.js'
|
|
20
|
+
import { normalizeSessionId } from './selection.js'
|
|
21
|
+
import { versionAtLeast, versionsDiffer } from './version.js'
|
|
22
|
+
|
|
23
|
+
/** Hard cap for one rendered tool result (~20 000 chars, PROTOCOL §5.2). */
|
|
24
|
+
const MAX_RENDER_CHARS = 20_000
|
|
25
|
+
|
|
26
|
+
/** Smallest accepted per-call timeout; below this the shell cannot start. */
|
|
27
|
+
const MIN_TIMEOUT_MS = 100
|
|
28
|
+
|
|
29
|
+
/** Largest accepted per-call foreground timeout (30 minutes). */
|
|
30
|
+
const MAX_TIMEOUT_MS = 30 * 60 * 1000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Timeout sent to the agent for every v2 execution. The HOST owns the
|
|
34
|
+
* foreground timeout (§11.6.3 promotes instead of killing), so the agent must
|
|
35
|
+
* not self-kill at the foreground deadline; 24 h is the agent's own cap.
|
|
36
|
+
*/
|
|
37
|
+
const AGENT_MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
|
38
|
+
|
|
39
|
+
/** Cap on prompt lines / list rows so the context stays small. */
|
|
40
|
+
const MAX_LISTED_COMPUTERS = 12
|
|
41
|
+
|
|
42
|
+
const COMPUTER_REF_SCHEMA = {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: { id: { type: 'string' }, name: { type: 'string' } },
|
|
45
|
+
required: ['id', 'name'],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const COMPUTER_ITEM_SCHEMA = {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
id: { type: 'string', description: 'Stable computer id (cmp_...).' },
|
|
53
|
+
name: { type: 'string', description: 'Display name shown in the Computers settings page.' },
|
|
54
|
+
online: { type: 'boolean', description: 'Whether the machine currently holds a live connection.' },
|
|
55
|
+
platformLabel: { type: 'string', description: 'macOS, Linux, Windows or Unknown.' },
|
|
56
|
+
os: { type: 'string', description: 'Node os.platform() value.' },
|
|
57
|
+
hostname: { type: 'string', description: 'Machine hostname as reported at enrollment.' },
|
|
58
|
+
lastSeenAt: {
|
|
59
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
60
|
+
description: 'ISO timestamp of the last stream/heartbeat/result (null when unknown).',
|
|
61
|
+
},
|
|
62
|
+
agentVersion: {
|
|
63
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
64
|
+
description: 'Version of the agent running on that machine (null when unknown).',
|
|
65
|
+
},
|
|
66
|
+
capabilities: {
|
|
67
|
+
type: 'array',
|
|
68
|
+
items: { type: 'string' },
|
|
69
|
+
description: 'Agent capabilities: exec, fs, pty.',
|
|
70
|
+
},
|
|
71
|
+
drift: {
|
|
72
|
+
type: 'boolean',
|
|
73
|
+
description: 'True when the agent version differs from this host plugin version.',
|
|
74
|
+
},
|
|
75
|
+
updateRequired: {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
description: 'True when the agent is older than the configured minimum and must be upgraded.',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
required: ['id', 'name', 'online', 'platformLabel', 'os', 'hostname', 'lastSeenAt', 'agentVersion', 'capabilities', 'drift', 'updateRequired'],
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const LIST_OUTPUT_SCHEMA = {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
computers: { type: 'array', items: COMPUTER_ITEM_SCHEMA, description: 'Every activated computer this conversation may use.' },
|
|
88
|
+
selectedComputerId: {
|
|
89
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
90
|
+
description: 'Computer pinned for this session (composer target picker), or null.',
|
|
91
|
+
},
|
|
92
|
+
hiddenComputerCount: {
|
|
93
|
+
type: 'number',
|
|
94
|
+
description: 'Connected computers this conversation may not use (access-controlled), omitted from the list.',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
required: ['computers', 'selectedComputerId', 'hiddenComputerCount'],
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const EXEC_PARAMETERS_SCHEMA = {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
computer: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
description: 'Optional target: a computer id or name from computer_list (an unambiguous name prefix is accepted). Omit it to use the computer the user pinned in the composer for this session.',
|
|
107
|
+
},
|
|
108
|
+
command: { type: 'string', description: 'Shell command to run on that computer.' },
|
|
109
|
+
cwd: { type: 'string', description: 'Working directory; defaults to the computer\'s reported workspace base (usually the home directory).' },
|
|
110
|
+
timeoutMs: {
|
|
111
|
+
type: 'number',
|
|
112
|
+
description: 'Foreground timeout in milliseconds (defaults to the host exec timeout). When it expires the host promotes the command to a background job instead of killing it.',
|
|
113
|
+
},
|
|
114
|
+
run_in_background: {
|
|
115
|
+
type: 'boolean',
|
|
116
|
+
description: 'Return a job id immediately and keep the command running; read it with job_output and stop it with job_kill.',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
required: ['command'],
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const EXEC_OUTPUT_SCHEMA = {
|
|
124
|
+
oneOf: [
|
|
125
|
+
{
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
computer: COMPUTER_REF_SCHEMA,
|
|
129
|
+
command: { type: 'string' },
|
|
130
|
+
exitCode: { oneOf: [{ type: 'number' }, { type: 'null' }], description: 'Exit code, or null when the process died by signal.' },
|
|
131
|
+
stdout: { type: 'string' },
|
|
132
|
+
stderr: { type: 'string' },
|
|
133
|
+
durationMs: { type: 'number' },
|
|
134
|
+
ok: { type: 'boolean' },
|
|
135
|
+
timedOut: { type: 'boolean' },
|
|
136
|
+
truncated: { type: 'boolean' },
|
|
137
|
+
},
|
|
138
|
+
required: ['computer', 'command', 'exitCode', 'stdout', 'stderr', 'durationMs', 'ok', 'timedOut', 'truncated'],
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
computer: COMPUTER_REF_SCHEMA,
|
|
145
|
+
command: { type: 'string' },
|
|
146
|
+
jobId: { type: 'string', description: 'DSH job id: read it with job_output, stop it with job_kill.' },
|
|
147
|
+
promoted: { type: 'boolean', description: 'True when a foreground timeout was adopted as a background job.' },
|
|
148
|
+
started: { type: 'boolean', description: 'True when the command was started explicitly in the background.' },
|
|
149
|
+
},
|
|
150
|
+
required: ['computer', 'command', 'jobId'],
|
|
151
|
+
additionalProperties: false,
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Shared parameter fragment: optional computer target. */
|
|
157
|
+
const OPTIONAL_COMPUTER_ARG = {
|
|
158
|
+
type: 'string',
|
|
159
|
+
description: 'Optional target: a computer id or name from computer_list. Omit it to use the computer pinned in the composer.',
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const FS_OUTPUT_COMPUTER = COMPUTER_REF_SCHEMA
|
|
163
|
+
|
|
164
|
+
const READ_PARAMETERS_SCHEMA = {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
computer: OPTIONAL_COMPUTER_ARG,
|
|
168
|
+
path: { type: 'string', description: 'Absolute path of the file to read on that computer.' },
|
|
169
|
+
offset: { type: 'number', description: '0-based line offset to start from (default 0).' },
|
|
170
|
+
limit: { type: 'number', description: 'Maximum number of lines to return (default 2000, max 20000).' },
|
|
171
|
+
},
|
|
172
|
+
required: ['path'],
|
|
173
|
+
additionalProperties: false,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const READ_OUTPUT_SCHEMA = {
|
|
177
|
+
type: 'object',
|
|
178
|
+
properties: {
|
|
179
|
+
computer: FS_OUTPUT_COMPUTER,
|
|
180
|
+
path: { type: 'string' },
|
|
181
|
+
text: { type: 'string' },
|
|
182
|
+
lines: { type: 'number' },
|
|
183
|
+
bytes: { type: 'number' },
|
|
184
|
+
truncated: { type: 'boolean' },
|
|
185
|
+
},
|
|
186
|
+
required: ['computer', 'path', 'text', 'lines', 'bytes', 'truncated'],
|
|
187
|
+
additionalProperties: false,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const WRITE_PARAMETERS_SCHEMA = {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: {
|
|
193
|
+
computer: OPTIONAL_COMPUTER_ARG,
|
|
194
|
+
path: { type: 'string', description: 'Absolute path of the file to write on that computer.' },
|
|
195
|
+
content: { type: 'string', description: 'Full UTF-8 file content.' },
|
|
196
|
+
createDirs: { type: 'boolean', description: 'Create parent directories when missing.' },
|
|
197
|
+
},
|
|
198
|
+
required: ['path', 'content'],
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const WRITE_OUTPUT_SCHEMA = {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
computer: FS_OUTPUT_COMPUTER,
|
|
206
|
+
path: { type: 'string' },
|
|
207
|
+
bytes: { type: 'number' },
|
|
208
|
+
created: { type: 'boolean' },
|
|
209
|
+
},
|
|
210
|
+
required: ['computer', 'path', 'bytes', 'created'],
|
|
211
|
+
additionalProperties: false,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const EDIT_PARAMETERS_SCHEMA = {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
computer: OPTIONAL_COMPUTER_ARG,
|
|
218
|
+
path: { type: 'string', description: 'Absolute path of the file to edit on that computer.' },
|
|
219
|
+
old: { type: 'string', description: 'Exact text to replace; must be unique unless replaceAll is true.' },
|
|
220
|
+
new: { type: 'string', description: 'Replacement text.' },
|
|
221
|
+
replaceAll: { type: 'boolean', description: 'Replace every occurrence of `old`.' },
|
|
222
|
+
},
|
|
223
|
+
required: ['path', 'old', 'new'],
|
|
224
|
+
additionalProperties: false,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const EDIT_OUTPUT_SCHEMA = {
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {
|
|
230
|
+
computer: FS_OUTPUT_COMPUTER,
|
|
231
|
+
path: { type: 'string' },
|
|
232
|
+
replacements: { type: 'number' },
|
|
233
|
+
bytes: { type: 'number' },
|
|
234
|
+
},
|
|
235
|
+
required: ['computer', 'path', 'replacements', 'bytes'],
|
|
236
|
+
additionalProperties: false,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const GLOB_PARAMETERS_SCHEMA = {
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: {
|
|
242
|
+
computer: OPTIONAL_COMPUTER_ARG,
|
|
243
|
+
pattern: { type: 'string', description: 'Glob pattern, for example "**/*.ts".' },
|
|
244
|
+
cwd: { type: 'string', description: 'Base directory; defaults to the computer\'s workspace base.' },
|
|
245
|
+
},
|
|
246
|
+
required: ['pattern'],
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const GLOB_OUTPUT_SCHEMA = {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: {
|
|
253
|
+
computer: FS_OUTPUT_COMPUTER,
|
|
254
|
+
paths: { type: 'array', items: { type: 'string' } },
|
|
255
|
+
},
|
|
256
|
+
required: ['computer', 'paths'],
|
|
257
|
+
additionalProperties: false,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const GREP_PARAMETERS_SCHEMA = {
|
|
261
|
+
type: 'object',
|
|
262
|
+
properties: {
|
|
263
|
+
computer: OPTIONAL_COMPUTER_ARG,
|
|
264
|
+
pattern: { type: 'string', description: 'Regular expression to search for.' },
|
|
265
|
+
path: { type: 'string', description: 'File or directory to search; defaults to the workspace base.' },
|
|
266
|
+
include: { type: 'string', description: 'Only search files whose relative path matches this glob.' },
|
|
267
|
+
maxMatches: { type: 'number', description: 'Maximum number of matches to return (default 500).' },
|
|
268
|
+
},
|
|
269
|
+
required: ['pattern'],
|
|
270
|
+
additionalProperties: false,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const GREP_OUTPUT_SCHEMA = {
|
|
274
|
+
type: 'object',
|
|
275
|
+
properties: {
|
|
276
|
+
computer: FS_OUTPUT_COMPUTER,
|
|
277
|
+
matches: {
|
|
278
|
+
type: 'array',
|
|
279
|
+
items: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
path: { type: 'string' },
|
|
283
|
+
line: { type: 'number' },
|
|
284
|
+
text: { type: 'string' },
|
|
285
|
+
},
|
|
286
|
+
required: ['path', 'line', 'text'],
|
|
287
|
+
additionalProperties: false,
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
required: ['computer', 'matches'],
|
|
292
|
+
additionalProperties: false,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** One text block, as every tool renderer returns. */
|
|
296
|
+
const textBlock = (text) => [{ type: 'text', text }]
|
|
297
|
+
|
|
298
|
+
/** Trim one rendered result to the protocol's display budget. */
|
|
299
|
+
function boundRender(text) {
|
|
300
|
+
return text.length <= MAX_RENDER_CHARS
|
|
301
|
+
? text
|
|
302
|
+
: `${text.slice(0, MAX_RENDER_CHARS)}\n… output truncated for display (${text.length - MAX_RENDER_CHARS} more characters)`
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Markdown table for `computer_list`. */
|
|
306
|
+
function renderComputerList(value) {
|
|
307
|
+
const computers = Array.isArray(value?.computers) ? value.computers : []
|
|
308
|
+
if (computers.length === 0) {
|
|
309
|
+
if (typeof value?.hiddenComputerCount === 'number' && value.hiddenComputerCount > 0) {
|
|
310
|
+
return `${value.hiddenComputerCount} computer(s) are connected but this conversation may not use any of them. `
|
|
311
|
+
+ 'Ask the user to grant access in 管理访问权 (Access management) for this conversation.'
|
|
312
|
+
}
|
|
313
|
+
return 'No computers are connected yet. Ask the user to open Settings → 电脑 and use “连接电脑”.'
|
|
314
|
+
}
|
|
315
|
+
const selectedId = typeof value?.selectedComputerId === 'string' ? value.selectedComputerId : null
|
|
316
|
+
const selected = selectedId === null ? undefined : computers.find((computer) => computer.id === selectedId)
|
|
317
|
+
const rows = computers.slice(0, MAX_LISTED_COMPUTERS).map((computer) => {
|
|
318
|
+
const status = computer.online === true ? 'online' : 'offline'
|
|
319
|
+
const lastSeen = typeof computer.lastSeenAt === 'string' ? computer.lastSeenAt : '—'
|
|
320
|
+
const name = `${computer.id === selectedId ? '* ' : ''}${computer.name}`
|
|
321
|
+
const notes = []
|
|
322
|
+
if (typeof computer.agentVersion === 'string') notes.push(`agent ${computer.agentVersion}`)
|
|
323
|
+
if (computer.updateRequired === true) notes.push('UPDATE REQUIRED')
|
|
324
|
+
else if (computer.drift === true) notes.push('drift')
|
|
325
|
+
if (Array.isArray(computer.capabilities) && computer.capabilities.length > 0) notes.push(computer.capabilities.join(','))
|
|
326
|
+
return `| ${name} | ${computer.platformLabel} | ${status} | ${notes.join(' · ')} | ${lastSeen} |`
|
|
327
|
+
})
|
|
328
|
+
const lines = []
|
|
329
|
+
if (selected !== undefined) {
|
|
330
|
+
lines.push(`Pinned target for this session: **${selected.name}** (\`computer_exec\` without \`computer\` runs here).`, '')
|
|
331
|
+
}
|
|
332
|
+
lines.push('| Name | Platform | Status | Agent | Last seen |', '|---|---|---|---|---|', ...rows)
|
|
333
|
+
const hidden = computers.length - Math.min(computers.length, MAX_LISTED_COMPUTERS)
|
|
334
|
+
let table = lines.join('\n')
|
|
335
|
+
if (hidden > 0) table += `\n… and ${hidden} more (only the first ${MAX_LISTED_COMPUTERS} are shown)`
|
|
336
|
+
if (selected !== undefined) table += '\n\nRows marked * are selected in the composer for this session.'
|
|
337
|
+
return table
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The protocol's `$ command` / status / stdout / stderr layout. */
|
|
341
|
+
function renderExec(value) {
|
|
342
|
+
const computer = value?.computer ?? {}
|
|
343
|
+
if (typeof value?.jobId === 'string') {
|
|
344
|
+
const how = value.promoted === true ? 'promoted a timed-out foreground command to background job' : 'started background job'
|
|
345
|
+
return boundRender([
|
|
346
|
+
`$ ${value?.command ?? ''}`,
|
|
347
|
+
`${how} ${value.jobId} on ${computer.name ?? computer.id ?? 'unknown computer'}`,
|
|
348
|
+
'Use job_output to read its output and job_kill to stop it.',
|
|
349
|
+
].join('\n'))
|
|
350
|
+
}
|
|
351
|
+
const status = [
|
|
352
|
+
`exit ${value?.exitCode ?? 'signal'}`,
|
|
353
|
+
`${value?.durationMs ?? 0}ms`,
|
|
354
|
+
computer.name ?? computer.id ?? 'unknown computer',
|
|
355
|
+
]
|
|
356
|
+
if (value?.timedOut === true) status.push('timed out')
|
|
357
|
+
if (value?.truncated === true) status.push('output truncated')
|
|
358
|
+
const lines = [`$ ${value?.command ?? ''}`, status.join(' · ')]
|
|
359
|
+
const stdout = typeof value?.stdout === 'string' ? value.stdout : ''
|
|
360
|
+
const stderr = typeof value?.stderr === 'string' ? value.stderr : ''
|
|
361
|
+
if (stdout !== '') lines.push('', stdout.replace(/\n+$/, ''))
|
|
362
|
+
if (stderr !== '') lines.push('', 'stderr:', stderr.replace(/\n+$/, ''))
|
|
363
|
+
return boundRender(lines.join('\n'))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Numbered file content, in the built-in `read` tool's style. */
|
|
367
|
+
function renderRead(value) {
|
|
368
|
+
const text = typeof value?.text === 'string' ? value.text : ''
|
|
369
|
+
const header = `${value?.path ?? ''} (${value?.lines ?? 0} lines, ${value?.bytes ?? 0} bytes${value?.truncated === true ? ', truncated' : ''})`
|
|
370
|
+
const numbered = text.split('\n').map((line, index) => `${String(index + 1).padStart(6, ' ')}\t${line}`).join('\n')
|
|
371
|
+
return boundRender(`${header}\n\n${numbered}`)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Built-in-style write/edit/glob/grep renders. */
|
|
375
|
+
function renderWrite(value) {
|
|
376
|
+
return `${value?.created === true ? 'Created' : 'Wrote'} ${value?.bytes ?? 0} bytes to ${value?.path ?? ''} on ${value?.computer?.name ?? 'the computer'}`
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function renderEdit(value) {
|
|
380
|
+
return `Applied ${value?.replacements ?? 0} replacement(s) to ${value?.path ?? ''} (${value?.bytes ?? 0} bytes) on ${value?.computer?.name ?? 'the computer'}`
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderGlob(value) {
|
|
384
|
+
const paths = Array.isArray(value?.paths) ? value.paths : []
|
|
385
|
+
if (paths.length === 0) return 'No matching files.'
|
|
386
|
+
return boundRender(`Found ${paths.length} path(s) on ${value?.computer?.name ?? 'the computer'}:\n${paths.join('\n')}`)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function renderGrep(value) {
|
|
390
|
+
const matches = Array.isArray(value?.matches) ? value.matches : []
|
|
391
|
+
if (matches.length === 0) return 'No matches.'
|
|
392
|
+
return boundRender(`${matches.length} match(es) on ${value?.computer?.name ?? 'the computer'}:\n`
|
|
393
|
+
+ matches.map((match) => `${match.path}:${match.line}: ${match.text}`).join('\n'))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Outcome mapping for a DSH job fed by a remote execution. */
|
|
397
|
+
const jobOutcome = (result) => {
|
|
398
|
+
if (result.killed === true) return { status: 'killed', detail: 'cancelled' }
|
|
399
|
+
if (result.ok === true && result.timedOut !== true) {
|
|
400
|
+
return { status: 'completed', detail: `exit ${result.exitCode ?? 0}${result.truncated === true ? ', output truncated' : ''}` }
|
|
401
|
+
}
|
|
402
|
+
return { status: 'failed', detail: result.error ?? `exit ${result.exitCode ?? result.signal ?? '?'}` }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Absolute POSIX or Windows path? (the agent requires absolute paths). */
|
|
406
|
+
const isAbsolutePath = (value) => typeof value === 'string' && /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(value.trim())
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Register the tools and the system-prompt section for one plugin mount.
|
|
410
|
+
* @param {object} options - wiring.
|
|
411
|
+
* @param {object} options.ctx - Cordis context (tools, systemPrompt, jobs).
|
|
412
|
+
* @param {object} options.config - resolved plugin config.
|
|
413
|
+
* @param {object} options.store - registry store.
|
|
414
|
+
* @param {object} options.hub - SSE/exec hub.
|
|
415
|
+
* @param {object} options.selections - per-session selection store.
|
|
416
|
+
* @param {object} options.access - access control.
|
|
417
|
+
* @param {object} options.fsOps - remote file dispatcher.
|
|
418
|
+
* @param {string} options.version - plugin version (drift baseline).
|
|
419
|
+
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
420
|
+
*/
|
|
421
|
+
export function createTools({
|
|
422
|
+
ctx, config, store, hub, selections = null, access = null, fsOps = null, version = '1.0.0', onWarn = () => {},
|
|
423
|
+
}) {
|
|
424
|
+
/** Live `jobs` service, when the composition has one. */
|
|
425
|
+
const jobsOf = () => {
|
|
426
|
+
try {
|
|
427
|
+
const service = ctx.get('jobs')
|
|
428
|
+
return service === undefined || service === null ? undefined : service
|
|
429
|
+
} catch {
|
|
430
|
+
return undefined
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** §11.6.4: only activated computers exist for tools. */
|
|
435
|
+
const visibleComputers = () => store.read().computers.filter((computer) => typeof computer.activatedAt === 'string')
|
|
436
|
+
|
|
437
|
+
/** Agent capabilities; a v1 agent reports none and is exec-only. */
|
|
438
|
+
const capabilitiesOf = (computer) => (Array.isArray(computer.capabilities) && computer.capabilities.length > 0
|
|
439
|
+
? computer.capabilities
|
|
440
|
+
: ['exec'])
|
|
441
|
+
|
|
442
|
+
/** The computer's reported default working directory. */
|
|
443
|
+
const workspaceBaseOf = (computer) => (typeof computer.workspaceBase === 'string' && computer.workspaceBase !== ''
|
|
444
|
+
? computer.workspaceBase
|
|
445
|
+
: null)
|
|
446
|
+
|
|
447
|
+
/** §11.6.5: reject tool calls against an agent below the configured minimum. */
|
|
448
|
+
const requireCurrentAgent = (computer) => {
|
|
449
|
+
if (versionAtLeast(computer.agentVersion, config.minAgentVersion)) return
|
|
450
|
+
throw new Error(`computer '${computer.name}' runs agent ${computer.agentVersion ?? 'unknown'}, older than the required v${config.minAgentVersion}. `
|
|
451
|
+
+ `Ask the user to upgrade the agent on that machine (re-run the install command / update the desktop plugin) before using it.`)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** §11.1: a tool that needs a capability must say so when the agent lacks it. */
|
|
455
|
+
const requireCapability = (computer, capability, feature) => {
|
|
456
|
+
const capabilities = capabilitiesOf(computer)
|
|
457
|
+
if (capabilities.includes(capability)) return
|
|
458
|
+
throw new Error(`computer '${computer.name}' does not support ${feature}: its agent reports [${capabilities.join(', ')}] but not '${capability}'. `
|
|
459
|
+
+ 'Ask the user to upgrade the agent on that machine.')
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Resolve `computer` by exact id, exact name, then unique substring (activated only). */
|
|
463
|
+
const resolveComputer = (query) => {
|
|
464
|
+
const computers = visibleComputers()
|
|
465
|
+
if (computers.length === 0) {
|
|
466
|
+
throw new Error('no computers are connected to this deployment; ask the user to connect one from Settings → 电脑')
|
|
467
|
+
}
|
|
468
|
+
const needle = query.trim()
|
|
469
|
+
const lower = needle.toLowerCase()
|
|
470
|
+
const byId = computers.find((computer) => computer.id === needle)
|
|
471
|
+
if (byId !== undefined) return byId
|
|
472
|
+
const byName = computers.find((computer) => String(computer.name).toLowerCase() === lower)
|
|
473
|
+
if (byName !== undefined) return byName
|
|
474
|
+
const candidates = computers.filter((computer) =>
|
|
475
|
+
String(computer.name).toLowerCase().includes(lower) || String(computer.id).toLowerCase().includes(lower))
|
|
476
|
+
if (candidates.length === 1) return candidates[0]
|
|
477
|
+
if (candidates.length > 1) {
|
|
478
|
+
const names = candidates.map((computer) => `${computer.name} (${computer.id})`).join(', ')
|
|
479
|
+
throw new Error(`computer '${query}' is ambiguous; it matches ${names}. Pass the exact id or name.`)
|
|
480
|
+
}
|
|
481
|
+
const available = computers.map((computer) => `${computer.name} (${computer.id})`).join(', ')
|
|
482
|
+
throw new Error(`no computer matches '${query}'; available computers: ${available}. Run computer_list for details.`)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The caller's session identity (`exec.agent.id` / `agent.session.id`). */
|
|
486
|
+
const sessionIdOf = (exec) => {
|
|
487
|
+
const agent = exec?.agent
|
|
488
|
+
if (agent === undefined || agent === null) return null
|
|
489
|
+
const fromSession = agent.session?.id
|
|
490
|
+
const raw = typeof fromSession === 'string' ? fromSession : agent.id
|
|
491
|
+
return normalizeSessionId(raw)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The session's pinned computer id, but only while that computer exists. */
|
|
495
|
+
const selectedComputerId = (exec) => {
|
|
496
|
+
const sessionId = sessionIdOf(exec)
|
|
497
|
+
if (sessionId === null || selections === null) return null
|
|
498
|
+
const stored = selections.get(sessionId)
|
|
499
|
+
if (stored === null) return null
|
|
500
|
+
return visibleComputers().some((computer) => computer.id === stored) ? stored : null
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The Agent preset id that owns this tool call (`default` when unknown). */
|
|
504
|
+
const presetIdOf = (exec) => (access === null ? 'default' : access.presetIdForAgent(exec?.agent))
|
|
505
|
+
|
|
506
|
+
/** True when the caller's preset + session allow-list permit this computer. */
|
|
507
|
+
const callerAllowed = (exec, computerId) => access === null
|
|
508
|
+
|| access.isAllowed({ sessionId: sessionIdOf(exec), presetId: presetIdOf(exec), computerId })
|
|
509
|
+
|
|
510
|
+
/** Explicit argument wins; otherwise the computer pinned in the composer. */
|
|
511
|
+
const resolveTargetComputer = (query, exec) => {
|
|
512
|
+
if (query !== undefined) return resolveComputer(query)
|
|
513
|
+
const selectedId = selectedComputerId(exec)
|
|
514
|
+
if (selectedId === null) {
|
|
515
|
+
throw new Error('no computer selected: pass `computer` (an id or name from computer_list), or pick a target computer in the composer')
|
|
516
|
+
}
|
|
517
|
+
const selected = visibleComputers().find((computer) => computer.id === selectedId)
|
|
518
|
+
if (selected === undefined) {
|
|
519
|
+
throw new Error('the computer selected in the composer no longer exists; run computer_list and pick another target')
|
|
520
|
+
}
|
|
521
|
+
return selected
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Common gates applied after target resolution, in a stable order. */
|
|
525
|
+
const gateTarget = (computer, exec, { capability = null, feature = null } = {}) => {
|
|
526
|
+
requireCurrentAgent(computer)
|
|
527
|
+
if (capability !== null) requireCapability(computer, capability, feature ?? capability)
|
|
528
|
+
if (access !== null && !callerAllowed(exec, computer.id)) {
|
|
529
|
+
throw new Error(access.denialMessage(computer, presetIdOf(exec)))
|
|
530
|
+
}
|
|
531
|
+
if (!hub.isOnline(computer)) {
|
|
532
|
+
throw new Error(`computer '${computer.name}' is offline; run computer_list to see which computers are connected, and ask the user to wake or reconnect it`)
|
|
533
|
+
}
|
|
534
|
+
return computer
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Map a hub/fs failure onto actionable model-facing text. */
|
|
538
|
+
const actionable = (error, computer, timeoutMs) => {
|
|
539
|
+
if (error instanceof HubError) {
|
|
540
|
+
switch (error.code) {
|
|
541
|
+
case 'offline':
|
|
542
|
+
return new Error(`computer '${computer.name}' is offline; run computer_list to see which computers are connected, and ask the user to wake or reconnect it`)
|
|
543
|
+
case 'timeout':
|
|
544
|
+
return new Error(`computer '${computer.name}' did not answer within ${timeoutMs}ms; the command was cancelled. Retry with a larger timeoutMs, or run a shorter command`)
|
|
545
|
+
case 'aborted':
|
|
546
|
+
return new Error(`the call on '${computer.name}' was aborted by the caller, and the command was cancelled on that machine`)
|
|
547
|
+
case 'disposed':
|
|
548
|
+
return new Error('the dsh-computers host plugin is shutting down; try again in a moment')
|
|
549
|
+
default:
|
|
550
|
+
return error
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Register one DSH job that feeds on a remote execution (§11.2/§11.6.3).
|
|
558
|
+
* The returned adapter is what the hub appends chunks into and settles.
|
|
559
|
+
* Returns null when the composition has no jobs registry.
|
|
560
|
+
*/
|
|
561
|
+
const createExecJob = ({ computer, command, owner, start }) => {
|
|
562
|
+
const jobs = jobsOf()
|
|
563
|
+
if (jobs === undefined || typeof jobs.start !== 'function') return null
|
|
564
|
+
let handle = null
|
|
565
|
+
let execution = null
|
|
566
|
+
let resolveDone = () => {}
|
|
567
|
+
const done = new Promise((resolve) => {
|
|
568
|
+
resolveDone = resolve
|
|
569
|
+
})
|
|
570
|
+
const sink = {
|
|
571
|
+
jobId: null,
|
|
572
|
+
append(channel, text) {
|
|
573
|
+
try {
|
|
574
|
+
handle?.append(text, { channel })
|
|
575
|
+
} catch {
|
|
576
|
+
// Writes after settlement are dropped by the registry.
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
settle(result) {
|
|
580
|
+
resolveDone(jobOutcome(result))
|
|
581
|
+
},
|
|
582
|
+
failed(error) {
|
|
583
|
+
resolveDone({ status: 'failed', detail: error && error.message ? error.message : String(error) })
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
const jobId = jobs.start({
|
|
587
|
+
kind: 'computer-exec',
|
|
588
|
+
label: `${computer.name}: ${command}`,
|
|
589
|
+
...(owner === null ? {} : { owner }),
|
|
590
|
+
outputLimitBytes: config.maxOutputBytes,
|
|
591
|
+
run(jobHandle) {
|
|
592
|
+
handle = jobHandle
|
|
593
|
+
execution = start(sink)
|
|
594
|
+
return {
|
|
595
|
+
cancel() {
|
|
596
|
+
if (execution !== null) hub.cancelExecution(computer.id, execution.requestId)
|
|
597
|
+
},
|
|
598
|
+
done,
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
})
|
|
602
|
+
sink.jobId = jobId
|
|
603
|
+
return sink
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Validate `computer_exec` arguments into a normalized shape. */
|
|
607
|
+
const validateExecArgs = (args) => {
|
|
608
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
|
|
609
|
+
throw new Error('computer_exec expects an object with "command"')
|
|
610
|
+
}
|
|
611
|
+
let computer
|
|
612
|
+
if (args.computer !== undefined && args.computer !== null) {
|
|
613
|
+
if (typeof args.computer !== 'string') throw new Error('computer_exec "computer" must be a string when provided')
|
|
614
|
+
const trimmed = args.computer.trim()
|
|
615
|
+
computer = trimmed === '' ? undefined : trimmed
|
|
616
|
+
}
|
|
617
|
+
const command = typeof args.command === 'string' ? args.command : ''
|
|
618
|
+
if (command.trim() === '') throw new Error('computer_exec requires "command": a non-empty shell command')
|
|
619
|
+
let cwd = null
|
|
620
|
+
if (args.cwd !== undefined && args.cwd !== null) {
|
|
621
|
+
if (typeof args.cwd !== 'string') throw new Error('computer_exec "cwd" must be a string')
|
|
622
|
+
cwd = args.cwd.trim() === '' ? null : args.cwd
|
|
623
|
+
}
|
|
624
|
+
let requestedTimeout = null
|
|
625
|
+
if (args.timeoutMs !== undefined && args.timeoutMs !== null) {
|
|
626
|
+
if (typeof args.timeoutMs !== 'number' || !Number.isFinite(args.timeoutMs)) {
|
|
627
|
+
throw new Error('computer_exec "timeoutMs" must be a finite number of milliseconds')
|
|
628
|
+
}
|
|
629
|
+
requestedTimeout = Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.trunc(args.timeoutMs)))
|
|
630
|
+
}
|
|
631
|
+
let runInBackground = false
|
|
632
|
+
if (args.run_in_background !== undefined && args.run_in_background !== null) {
|
|
633
|
+
if (typeof args.run_in_background !== 'boolean') throw new Error('computer_exec "run_in_background" must be a boolean')
|
|
634
|
+
runInBackground = args.run_in_background
|
|
635
|
+
}
|
|
636
|
+
return { computer, command, cwd, timeoutMs: requestedTimeout ?? config.execTimeoutMs, runInBackground }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Resolve the target for one remote-file tool call. */
|
|
640
|
+
const resolveFsComputer = (arg, exec) => {
|
|
641
|
+
if (fsOps === null) throw new Error('remote file access is unavailable in this deployment')
|
|
642
|
+
const query = typeof arg === 'string' && arg.trim() !== '' ? arg.trim() : undefined
|
|
643
|
+
const computer = resolveTargetComputer(query, exec)
|
|
644
|
+
return gateTarget(computer, exec, { capability: 'fs', feature: 'remote file access' })
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Run one fs op with a clear absolute-path check and error mapping. */
|
|
648
|
+
const fsCall = async (computer, op, payload, exec) => {
|
|
649
|
+
try {
|
|
650
|
+
return await fsOps.request({ computerId: computer.id, name: computer.name, op, payload, signal: exec?.signal })
|
|
651
|
+
} catch (error) {
|
|
652
|
+
throw actionable(error, computer, 15_000)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// -------------------------------------------------------------------------
|
|
657
|
+
// computer_list
|
|
658
|
+
// -------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
ctx.effect(() => ctx.tools.register({
|
|
661
|
+
name: 'computer_list',
|
|
662
|
+
description: 'List the activated computers connected to this DSH Web deployment (the user\'s own machines), with platform, online state, agent version/capabilities and the target pinned in the composer. Call this before computer_exec to get a valid computer id or name.',
|
|
663
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
664
|
+
output: {
|
|
665
|
+
schema: LIST_OUTPUT_SCHEMA,
|
|
666
|
+
render: (_args, value) => textBlock(renderComputerList(value)),
|
|
667
|
+
},
|
|
668
|
+
async execute(_args, exec) {
|
|
669
|
+
const all = visibleComputers()
|
|
670
|
+
const sessionId = sessionIdOf(exec)
|
|
671
|
+
const presetId = presetIdOf(exec)
|
|
672
|
+
const visible = access === null ? all : access.filterComputers(all, { sessionId, presetId })
|
|
673
|
+
const visibleIds = new Set(visible.map((computer) => computer.id))
|
|
674
|
+
const selectedId = selectedComputerId(exec)
|
|
675
|
+
const computers = visible.map((computer) => ({
|
|
676
|
+
id: computer.id,
|
|
677
|
+
name: computer.name,
|
|
678
|
+
online: hub.isOnline(computer),
|
|
679
|
+
platformLabel: computer.platformLabel ?? 'Unknown',
|
|
680
|
+
os: computer.os ?? 'unknown',
|
|
681
|
+
hostname: computer.hostname ?? '',
|
|
682
|
+
lastSeenAt: typeof computer.lastSeenAt === 'string' ? computer.lastSeenAt : null,
|
|
683
|
+
agentVersion: typeof computer.agentVersion === 'string' ? computer.agentVersion : null,
|
|
684
|
+
capabilities: capabilitiesOf(computer),
|
|
685
|
+
drift: versionsDiffer(computer.agentVersion, version),
|
|
686
|
+
updateRequired: !versionAtLeast(computer.agentVersion, config.minAgentVersion),
|
|
687
|
+
}))
|
|
688
|
+
return {
|
|
689
|
+
computers,
|
|
690
|
+
selectedComputerId: selectedId !== null && visibleIds.has(selectedId) ? selectedId : null,
|
|
691
|
+
hiddenComputerCount: all.length - visible.length,
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
}), 'dsh-computers: computer_list tool')
|
|
695
|
+
|
|
696
|
+
// -------------------------------------------------------------------------
|
|
697
|
+
// computer_exec
|
|
698
|
+
// -------------------------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
ctx.effect(() => ctx.tools.register({
|
|
701
|
+
name: 'computer_exec',
|
|
702
|
+
description: 'Run one shell command on one of the user\'s connected computers and return its exit code, stdout and stderr. The command runs in that machine\'s own shell; output is capped. Use run_in_background for long-running work (job_output/job_kill). A foreground command that exceeds its timeout is promoted to a background job instead of being killed. The machine must be online — call computer_list first.',
|
|
703
|
+
parameters: EXEC_PARAMETERS_SCHEMA,
|
|
704
|
+
output: {
|
|
705
|
+
schema: EXEC_OUTPUT_SCHEMA,
|
|
706
|
+
render: (_args, value) => textBlock(renderExec(value)),
|
|
707
|
+
},
|
|
708
|
+
async execute(args, exec) {
|
|
709
|
+
if (config.execEnabled !== true) {
|
|
710
|
+
throw new Error('computer_exec is disabled on this host (dsh-computers config execEnabled: false)')
|
|
711
|
+
}
|
|
712
|
+
const input = validateExecArgs(args)
|
|
713
|
+
const computer = resolveTargetComputer(input.computer, exec)
|
|
714
|
+
gateTarget(computer, exec)
|
|
715
|
+
const owner = sessionIdOf(exec)
|
|
716
|
+
const cwd = input.cwd ?? workspaceBaseOf(computer)
|
|
717
|
+
const ref = { id: computer.id, name: computer.name }
|
|
718
|
+
|
|
719
|
+
if (input.runInBackground === true) {
|
|
720
|
+
let sink
|
|
721
|
+
try {
|
|
722
|
+
sink = createExecJob({
|
|
723
|
+
computer,
|
|
724
|
+
command: input.command,
|
|
725
|
+
owner,
|
|
726
|
+
start: (jobSink) => hub.startExecution({
|
|
727
|
+
computerId: computer.id,
|
|
728
|
+
name: computer.name,
|
|
729
|
+
command: input.command,
|
|
730
|
+
cwd,
|
|
731
|
+
timeoutMs: 0,
|
|
732
|
+
agentTimeoutMs: AGENT_MAX_TIMEOUT_MS,
|
|
733
|
+
job: jobSink,
|
|
734
|
+
}),
|
|
735
|
+
})
|
|
736
|
+
} catch (error) {
|
|
737
|
+
throw actionable(error, computer, input.timeoutMs)
|
|
738
|
+
}
|
|
739
|
+
if (sink === null) {
|
|
740
|
+
throw new Error('background execution is unavailable on this host: the DSH jobs registry is not loaded (load @deepseek-ai/dsh-tool-jobs in the composition)')
|
|
741
|
+
}
|
|
742
|
+
return { computer: ref, command: input.command, jobId: sink.jobId, started: true }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// §11.6.3: a foreground timeout adopts the running command into a job
|
|
746
|
+
// instead of killing it (unless the operator disabled that).
|
|
747
|
+
const jobs = jobsOf()
|
|
748
|
+
const canPromote = config.promoteOnTimeout === true && jobs !== undefined
|
|
749
|
+
let result
|
|
750
|
+
try {
|
|
751
|
+
result = await hub.dispatch({
|
|
752
|
+
computerId: computer.id,
|
|
753
|
+
name: computer.name,
|
|
754
|
+
command: input.command,
|
|
755
|
+
cwd,
|
|
756
|
+
timeoutMs: input.timeoutMs,
|
|
757
|
+
agentTimeoutMs: AGENT_MAX_TIMEOUT_MS,
|
|
758
|
+
signal: exec?.signal,
|
|
759
|
+
stream: true,
|
|
760
|
+
onTimeout: canPromote
|
|
761
|
+
? (entry) => {
|
|
762
|
+
try {
|
|
763
|
+
// Adopt the existing execution: the job's chunks flow from
|
|
764
|
+
// the same pending entry from now on.
|
|
765
|
+
return createExecJob({
|
|
766
|
+
computer,
|
|
767
|
+
command: input.command,
|
|
768
|
+
owner,
|
|
769
|
+
start: () => ({ requestId: entry.requestId }),
|
|
770
|
+
})
|
|
771
|
+
} catch (error) {
|
|
772
|
+
onWarn(`dsh-computers: could not promote timed-out execution: ${error && error.message ? error.message : error}`)
|
|
773
|
+
return null
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
: null,
|
|
777
|
+
})
|
|
778
|
+
} catch (error) {
|
|
779
|
+
throw actionable(error, computer, input.timeoutMs)
|
|
780
|
+
}
|
|
781
|
+
if (result !== null && typeof result === 'object' && result.promoted === true) {
|
|
782
|
+
return { computer: ref, command: input.command, jobId: result.jobId, promoted: true }
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
computer: ref,
|
|
786
|
+
command: input.command,
|
|
787
|
+
exitCode: result.exitCode,
|
|
788
|
+
stdout: result.stdout,
|
|
789
|
+
stderr: result.stderr,
|
|
790
|
+
durationMs: result.durationMs,
|
|
791
|
+
ok: result.ok === true && result.timedOut !== true,
|
|
792
|
+
timedOut: result.timedOut === true,
|
|
793
|
+
truncated: result.truncated === true,
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
}), 'dsh-computers: computer_exec tool')
|
|
797
|
+
|
|
798
|
+
// -------------------------------------------------------------------------
|
|
799
|
+
// Remote file tools (§11.3)
|
|
800
|
+
// -------------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
ctx.effect(() => ctx.tools.register({
|
|
803
|
+
name: 'computer_read',
|
|
804
|
+
description: 'Read a UTF-8 file from a connected computer. `path` must be absolute. Returns the text, line count, byte count and whether it was truncated. Only works when that machine\'s agent supports file access.',
|
|
805
|
+
parameters: READ_PARAMETERS_SCHEMA,
|
|
806
|
+
output: { schema: READ_OUTPUT_SCHEMA, render: (_args, value) => textBlock(renderRead(value)) },
|
|
807
|
+
async execute(args, exec) {
|
|
808
|
+
const path = typeof args?.path === 'string' ? args.path : ''
|
|
809
|
+
if (!isAbsolutePath(path)) throw new Error('computer_read requires an absolute "path"')
|
|
810
|
+
const computer = resolveFsComputer(args?.computer, exec)
|
|
811
|
+
const payload = { path: path.trim() }
|
|
812
|
+
if (typeof args.offset === 'number' && Number.isFinite(args.offset) && args.offset >= 0) payload.offset = Math.trunc(args.offset)
|
|
813
|
+
if (typeof args.limit === 'number' && Number.isFinite(args.limit) && args.limit > 0) payload.limit = Math.trunc(args.limit)
|
|
814
|
+
payload.encoding = 'utf8'
|
|
815
|
+
const response = await fsCall(computer, 'read', payload, exec)
|
|
816
|
+
const text = typeof response.text === 'string'
|
|
817
|
+
? response.text
|
|
818
|
+
: typeof response.content === 'string'
|
|
819
|
+
? Buffer.from(response.content, 'base64').toString('utf8')
|
|
820
|
+
: ''
|
|
821
|
+
return {
|
|
822
|
+
computer: { id: computer.id, name: computer.name },
|
|
823
|
+
path: typeof response.path === 'string' ? response.path : path,
|
|
824
|
+
text,
|
|
825
|
+
lines: typeof response.lines === 'number' ? response.lines : text.split('\n').length,
|
|
826
|
+
bytes: typeof response.bytes === 'number' ? response.bytes : Buffer.byteLength(text, 'utf8'),
|
|
827
|
+
truncated: response.truncated === true,
|
|
828
|
+
}
|
|
829
|
+
},
|
|
830
|
+
}), 'dsh-computers: computer_read tool')
|
|
831
|
+
|
|
832
|
+
ctx.effect(() => ctx.tools.register({
|
|
833
|
+
name: 'computer_write',
|
|
834
|
+
description: 'Write a complete UTF-8 file on a connected computer. `path` must be absolute; set createDirs to create missing parent directories. Overwrites existing content. Only works when that machine\'s agent supports file access.',
|
|
835
|
+
parameters: WRITE_PARAMETERS_SCHEMA,
|
|
836
|
+
output: { schema: WRITE_OUTPUT_SCHEMA, render: (_args, value) => textBlock(renderWrite(value)) },
|
|
837
|
+
async execute(args, exec) {
|
|
838
|
+
const path = typeof args?.path === 'string' ? args.path : ''
|
|
839
|
+
if (!isAbsolutePath(path)) throw new Error('computer_write requires an absolute "path"')
|
|
840
|
+
if (typeof args.content !== 'string') throw new Error('computer_write requires "content"')
|
|
841
|
+
const computer = resolveFsComputer(args?.computer, exec)
|
|
842
|
+
const payload = { path: path.trim(), content: args.content, encoding: 'utf8' }
|
|
843
|
+
if (args.createDirs === true) payload.createDirs = true
|
|
844
|
+
const response = await fsCall(computer, 'write', payload, exec)
|
|
845
|
+
return {
|
|
846
|
+
computer: { id: computer.id, name: computer.name },
|
|
847
|
+
path: typeof response.path === 'string' ? response.path : path,
|
|
848
|
+
bytes: typeof response.bytes === 'number' ? response.bytes : Buffer.byteLength(args.content, 'utf8'),
|
|
849
|
+
created: response.created === true,
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
}), 'dsh-computers: computer_write tool')
|
|
853
|
+
|
|
854
|
+
ctx.effect(() => ctx.tools.register({
|
|
855
|
+
name: 'computer_edit',
|
|
856
|
+
description: 'Replace literal text in one file on a connected computer. `old` must appear exactly once unless replaceAll is true; `new` replaces every match. Read the file first with computer_read. Only works when that machine\'s agent supports file access.',
|
|
857
|
+
parameters: EDIT_PARAMETERS_SCHEMA,
|
|
858
|
+
output: { schema: EDIT_OUTPUT_SCHEMA, render: (_args, value) => textBlock(renderEdit(value)) },
|
|
859
|
+
async execute(args, exec) {
|
|
860
|
+
const path = typeof args?.path === 'string' ? args.path : ''
|
|
861
|
+
if (!isAbsolutePath(path)) throw new Error('computer_edit requires an absolute "path"')
|
|
862
|
+
const oldText = typeof args.old === 'string' ? args.old : ''
|
|
863
|
+
const newText = typeof args.new === 'string' ? args.new : ''
|
|
864
|
+
if (oldText === '') throw new Error('computer_edit requires a non-empty "old" string')
|
|
865
|
+
const replaceAll = args.replaceAll === true
|
|
866
|
+
const computer = resolveFsComputer(args?.computer, exec)
|
|
867
|
+
// Read → literal replace → write. Two fs round trips, both with the same
|
|
868
|
+
// 15 s budget and the caller's signal.
|
|
869
|
+
const current = await fsCall(computer, 'read', { path: path.trim(), encoding: 'utf8' }, exec)
|
|
870
|
+
const text = typeof current.text === 'string'
|
|
871
|
+
? current.text
|
|
872
|
+
: typeof current.content === 'string'
|
|
873
|
+
? Buffer.from(current.content, 'base64').toString('utf8')
|
|
874
|
+
: ''
|
|
875
|
+
const occurrences = text.split(oldText).length - 1
|
|
876
|
+
if (occurrences === 0) throw new Error(`computer_edit: "old" text was not found in ${path}`)
|
|
877
|
+
if (occurrences > 1 && !replaceAll) {
|
|
878
|
+
throw new Error(`computer_edit: "old" text appears ${occurrences} times in ${path}; pass a longer unique snippet or set replaceAll: true`)
|
|
879
|
+
}
|
|
880
|
+
const replacements = replaceAll ? occurrences : 1
|
|
881
|
+
const next = replaceAll ? text.split(oldText).join(newText) : text.replace(oldText, newText)
|
|
882
|
+
const written = await fsCall(computer, 'write', { path: path.trim(), content: next, encoding: 'utf8' }, exec)
|
|
883
|
+
return {
|
|
884
|
+
computer: { id: computer.id, name: computer.name },
|
|
885
|
+
path: typeof written.path === 'string' ? written.path : path,
|
|
886
|
+
replacements,
|
|
887
|
+
bytes: typeof written.bytes === 'number' ? written.bytes : Buffer.byteLength(next, 'utf8'),
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
}), 'dsh-computers: computer_edit tool')
|
|
891
|
+
|
|
892
|
+
ctx.effect(() => ctx.tools.register({
|
|
893
|
+
name: 'computer_glob',
|
|
894
|
+
description: 'Find file paths on a connected computer by glob pattern (for example "**/*.ts"). Relative patterns resolve against the computer\'s workspace base; use cwd to search elsewhere. Only works when that machine\'s agent supports file access.',
|
|
895
|
+
parameters: GLOB_PARAMETERS_SCHEMA,
|
|
896
|
+
output: { schema: GLOB_OUTPUT_SCHEMA, render: (_args, value) => textBlock(renderGlob(value)) },
|
|
897
|
+
async execute(args, exec) {
|
|
898
|
+
const pattern = typeof args?.pattern === 'string' ? args.pattern.trim() : ''
|
|
899
|
+
if (pattern === '') throw new Error('computer_glob requires a non-empty "pattern"')
|
|
900
|
+
const computer = resolveFsComputer(args?.computer, exec)
|
|
901
|
+
// The agent's `glob` op accepts only {pattern} and anchors relative
|
|
902
|
+
// patterns at its workspaceBase, so a user-provided cwd is folded in.
|
|
903
|
+
let effectivePattern = pattern
|
|
904
|
+
if (typeof args.cwd === 'string' && args.cwd.trim() !== '' && !isAbsolutePath(pattern)) {
|
|
905
|
+
const base = args.cwd.trim().replace(/[\\/]+$/, '')
|
|
906
|
+
const separator = base.includes('\\') && !base.includes('/') ? '\\' : '/'
|
|
907
|
+
effectivePattern = `${base}${separator}${pattern}`
|
|
908
|
+
}
|
|
909
|
+
const response = await fsCall(computer, 'glob', { pattern: effectivePattern }, exec)
|
|
910
|
+
return {
|
|
911
|
+
computer: { id: computer.id, name: computer.name },
|
|
912
|
+
paths: Array.isArray(response.paths) ? response.paths.filter((entry) => typeof entry === 'string') : [],
|
|
913
|
+
}
|
|
914
|
+
},
|
|
915
|
+
}), 'dsh-computers: computer_glob tool')
|
|
916
|
+
|
|
917
|
+
ctx.effect(() => ctx.tools.register({
|
|
918
|
+
name: 'computer_grep',
|
|
919
|
+
description: 'Search file contents on a connected computer with a regular expression. `path` defaults to the computer\'s workspace base; `include` filters by relative glob; results are capped. Only works when that machine\'s agent supports file access.',
|
|
920
|
+
parameters: GREP_PARAMETERS_SCHEMA,
|
|
921
|
+
output: { schema: GREP_OUTPUT_SCHEMA, render: (_args, value) => textBlock(renderGrep(value)) },
|
|
922
|
+
async execute(args, exec) {
|
|
923
|
+
const pattern = typeof args?.pattern === 'string' ? args.pattern.trim() : ''
|
|
924
|
+
if (pattern === '') throw new Error('computer_grep requires a non-empty "pattern"')
|
|
925
|
+
const computer = resolveFsComputer(args?.computer, exec)
|
|
926
|
+
const payload = { pattern }
|
|
927
|
+
if (typeof args.path === 'string' && args.path.trim() !== '') payload.path = args.path.trim()
|
|
928
|
+
if (typeof args.include === 'string' && args.include.trim() !== '') payload.include = args.include.trim()
|
|
929
|
+
if (typeof args.maxMatches === 'number' && Number.isFinite(args.maxMatches) && args.maxMatches > 0) {
|
|
930
|
+
payload.maxMatches = Math.trunc(args.maxMatches)
|
|
931
|
+
}
|
|
932
|
+
const response = await fsCall(computer, 'grep', payload, exec)
|
|
933
|
+
const matches = Array.isArray(response.matches)
|
|
934
|
+
? response.matches
|
|
935
|
+
.filter((match) => match !== null && typeof match === 'object')
|
|
936
|
+
.map((match) => ({
|
|
937
|
+
path: typeof match.path === 'string' ? match.path : '',
|
|
938
|
+
line: typeof match.line === 'number' ? Math.trunc(match.line) : 0,
|
|
939
|
+
text: typeof match.text === 'string' ? match.text : '',
|
|
940
|
+
}))
|
|
941
|
+
: []
|
|
942
|
+
return { computer: { id: computer.id, name: computer.name }, matches }
|
|
943
|
+
},
|
|
944
|
+
}), 'dsh-computers: computer_grep tool')
|
|
945
|
+
|
|
946
|
+
// ---------------------------------------------------------------------------
|
|
947
|
+
// System prompt section (PROTOCOL §5.3, §11)
|
|
948
|
+
// ---------------------------------------------------------------------------
|
|
949
|
+
// `systemPrompt` is not in `inject` (it is optional for this plugin), so it
|
|
950
|
+
// is looked up defensively; a host without it keeps working unchanged.
|
|
951
|
+
try {
|
|
952
|
+
const systemPrompt = ctx.get('systemPrompt')
|
|
953
|
+
if (systemPrompt !== undefined && systemPrompt !== null && typeof systemPrompt.section === 'function') {
|
|
954
|
+
let order = 3000
|
|
955
|
+
try {
|
|
956
|
+
const resolved = typeof systemPrompt.getSectionOrder === 'function'
|
|
957
|
+
? systemPrompt.getSectionOrder('TOOL_COMPUTER_USE')
|
|
958
|
+
: undefined
|
|
959
|
+
if (Number.isFinite(resolved)) order = resolved
|
|
960
|
+
} catch {
|
|
961
|
+
// Unknown placement name: keep the numeric fallback.
|
|
962
|
+
}
|
|
963
|
+
ctx.effect(() => systemPrompt.section({
|
|
964
|
+
name: 'computers',
|
|
965
|
+
order,
|
|
966
|
+
text: (context) => {
|
|
967
|
+
const all = visibleComputers()
|
|
968
|
+
const agent = context !== null && typeof context === 'object' ? context.agent : undefined
|
|
969
|
+
const presetId = agent === undefined || access === null ? 'default' : access.presetIdForAgent(agent)
|
|
970
|
+
const computers = access === null
|
|
971
|
+
? all
|
|
972
|
+
: access.filterComputers(all, { sessionId: null, presetId })
|
|
973
|
+
if (computers.length === 0) return ''
|
|
974
|
+
const lines = [
|
|
975
|
+
'You can run shell commands on the user\'s connected computers with `computer_exec`.',
|
|
976
|
+
'Call `computer_list` to see their ids, names, online state, capabilities and the target pinned in the composer.',
|
|
977
|
+
'The user can pin a target computer in the composer; `computer_exec` without a `computer` argument runs on that pinned computer.',
|
|
978
|
+
]
|
|
979
|
+
if (computers.some((computer) => capabilitiesOf(computer).includes('fs'))) {
|
|
980
|
+
lines.push('Use `computer_read`/`computer_write`/`computer_edit`/`computer_glob`/`computer_grep` for files on machines whose agent supports file access.')
|
|
981
|
+
}
|
|
982
|
+
for (const computer of computers.slice(0, MAX_LISTED_COMPUTERS)) {
|
|
983
|
+
const state = hub.isOnline(computer) ? 'online' : 'offline'
|
|
984
|
+
lines.push(`- ${computer.name} (${computer.platformLabel ?? 'Unknown'}, ${state})`)
|
|
985
|
+
}
|
|
986
|
+
return lines.join('\n')
|
|
987
|
+
},
|
|
988
|
+
}), 'dsh-computers: prompt section')
|
|
989
|
+
}
|
|
990
|
+
} catch (error) {
|
|
991
|
+
onWarn(`dsh-computers: could not register the system prompt section: ${error && error.message ? error.message : error}`)
|
|
992
|
+
}
|
|
993
|
+
}
|