dsh-computers 1.1.0 → 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/PROTOCOL.md +135 -0
- package/README.md +45 -16
- package/agent/README.md +112 -5
- package/agent/dsh-computers-agent.mjs +835 -20
- package/client.js +777 -10
- package/cordis.patch.yml +2 -0
- package/index.js +32 -2
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/hub.js +346 -140
- package/lib/routes.js +425 -38
- package/lib/state.js +11 -2
- package/lib/tools.js +684 -85
- package/lib/version.js +62 -0
- package/package.json +1 -1
package/lib/tools.js
CHANGED
|
@@ -1,30 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The model-facing half of dsh-computers (PROTOCOL §5)
|
|
3
|
-
* `computer_exec`, and the short system-prompt section that makes the tools
|
|
4
|
-
* discoverable.
|
|
2
|
+
* The model-facing half of dsh-computers (PROTOCOL §5, §11.1, §11.2, §11.3).
|
|
5
3
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
9
15
|
*
|
|
10
16
|
* Node builtins only.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
import { HubError } from './hub.js'
|
|
14
20
|
import { normalizeSessionId } from './selection.js'
|
|
21
|
+
import { versionAtLeast, versionsDiffer } from './version.js'
|
|
15
22
|
|
|
16
|
-
/** Hard cap for one rendered tool result (PROTOCOL §5.2
|
|
23
|
+
/** Hard cap for one rendered tool result (~20 000 chars, PROTOCOL §5.2). */
|
|
17
24
|
const MAX_RENDER_CHARS = 20_000
|
|
18
25
|
|
|
19
26
|
/** Smallest accepted per-call timeout; below this the shell cannot start. */
|
|
20
27
|
const MIN_TIMEOUT_MS = 100
|
|
21
28
|
|
|
22
|
-
/** Largest accepted per-call timeout (30 minutes). */
|
|
29
|
+
/** Largest accepted per-call foreground timeout (30 minutes). */
|
|
23
30
|
const MAX_TIMEOUT_MS = 30 * 60 * 1000
|
|
24
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
|
+
|
|
25
39
|
/** Cap on prompt lines / list rows so the context stays small. */
|
|
26
40
|
const MAX_LISTED_COMPUTERS = 12
|
|
27
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
|
+
|
|
28
49
|
const COMPUTER_ITEM_SCHEMA = {
|
|
29
50
|
type: 'object',
|
|
30
51
|
properties: {
|
|
@@ -42,21 +63,38 @@ const COMPUTER_ITEM_SCHEMA = {
|
|
|
42
63
|
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
43
64
|
description: 'Version of the agent running on that machine (null when unknown).',
|
|
44
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
|
+
},
|
|
45
79
|
},
|
|
46
|
-
required: ['id', 'name', 'online', 'platformLabel', 'os', 'hostname', 'lastSeenAt', 'agentVersion'],
|
|
80
|
+
required: ['id', 'name', 'online', 'platformLabel', 'os', 'hostname', 'lastSeenAt', 'agentVersion', 'capabilities', 'drift', 'updateRequired'],
|
|
47
81
|
additionalProperties: false,
|
|
48
82
|
}
|
|
49
83
|
|
|
50
84
|
const LIST_OUTPUT_SCHEMA = {
|
|
51
85
|
type: 'object',
|
|
52
86
|
properties: {
|
|
53
|
-
computers: { type: 'array', items: COMPUTER_ITEM_SCHEMA, description: 'Every
|
|
87
|
+
computers: { type: 'array', items: COMPUTER_ITEM_SCHEMA, description: 'Every activated computer this conversation may use.' },
|
|
54
88
|
selectedComputerId: {
|
|
55
89
|
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
56
90
|
description: 'Computer pinned for this session (composer target picker), or null.',
|
|
57
91
|
},
|
|
92
|
+
hiddenComputerCount: {
|
|
93
|
+
type: 'number',
|
|
94
|
+
description: 'Connected computers this conversation may not use (access-controlled), omitted from the list.',
|
|
95
|
+
},
|
|
58
96
|
},
|
|
59
|
-
required: ['computers', 'selectedComputerId'],
|
|
97
|
+
required: ['computers', 'selectedComputerId', 'hiddenComputerCount'],
|
|
60
98
|
additionalProperties: false,
|
|
61
99
|
}
|
|
62
100
|
|
|
@@ -68,10 +106,14 @@ const EXEC_PARAMETERS_SCHEMA = {
|
|
|
68
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.',
|
|
69
107
|
},
|
|
70
108
|
command: { type: 'string', description: 'Shell command to run on that computer.' },
|
|
71
|
-
cwd: { type: 'string', description: 'Working directory
|
|
109
|
+
cwd: { type: 'string', description: 'Working directory; defaults to the computer\'s reported workspace base (usually the home directory).' },
|
|
72
110
|
timeoutMs: {
|
|
73
111
|
type: 'number',
|
|
74
|
-
description: '
|
|
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.',
|
|
75
117
|
},
|
|
76
118
|
},
|
|
77
119
|
required: ['command'],
|
|
@@ -79,24 +121,174 @@ const EXEC_PARAMETERS_SCHEMA = {
|
|
|
79
121
|
}
|
|
80
122
|
|
|
81
123
|
const EXEC_OUTPUT_SCHEMA = {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
computer: {
|
|
124
|
+
oneOf: [
|
|
125
|
+
{
|
|
85
126
|
type: 'object',
|
|
86
|
-
properties: {
|
|
87
|
-
|
|
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'],
|
|
88
139
|
additionalProperties: false,
|
|
89
140
|
},
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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' },
|
|
97
184
|
truncated: { type: 'boolean' },
|
|
98
185
|
},
|
|
99
|
-
required: ['computer', '
|
|
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'],
|
|
100
292
|
additionalProperties: false,
|
|
101
293
|
}
|
|
102
294
|
|
|
@@ -110,14 +302,14 @@ function boundRender(text) {
|
|
|
110
302
|
: `${text.slice(0, MAX_RENDER_CHARS)}\n… output truncated for display (${text.length - MAX_RENDER_CHARS} more characters)`
|
|
111
303
|
}
|
|
112
304
|
|
|
113
|
-
/**
|
|
114
|
-
* Markdown table for `computer_list`, with the session's pinned target marked
|
|
115
|
-
* by a leading `*` (and named up front, so the model knows what a bare
|
|
116
|
-
* `computer_exec` will hit).
|
|
117
|
-
*/
|
|
305
|
+
/** Markdown table for `computer_list`. */
|
|
118
306
|
function renderComputerList(value) {
|
|
119
307
|
const computers = Array.isArray(value?.computers) ? value.computers : []
|
|
120
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
|
+
}
|
|
121
313
|
return 'No computers are connected yet. Ask the user to open Settings → 电脑 and use “连接电脑”.'
|
|
122
314
|
}
|
|
123
315
|
const selectedId = typeof value?.selectedComputerId === 'string' ? value.selectedComputerId : null
|
|
@@ -125,24 +317,37 @@ function renderComputerList(value) {
|
|
|
125
317
|
const rows = computers.slice(0, MAX_LISTED_COMPUTERS).map((computer) => {
|
|
126
318
|
const status = computer.online === true ? 'online' : 'offline'
|
|
127
319
|
const lastSeen = typeof computer.lastSeenAt === 'string' ? computer.lastSeenAt : '—'
|
|
128
|
-
const name = computer.id === selectedId ?
|
|
129
|
-
|
|
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} |`
|
|
130
327
|
})
|
|
131
328
|
const lines = []
|
|
132
329
|
if (selected !== undefined) {
|
|
133
330
|
lines.push(`Pinned target for this session: **${selected.name}** (\`computer_exec\` without \`computer\` runs here).`, '')
|
|
134
331
|
}
|
|
135
|
-
lines.push('| Name | Platform | Status | Last seen |', '
|
|
332
|
+
lines.push('| Name | Platform | Status | Agent | Last seen |', '|---|---|---|---|---|', ...rows)
|
|
136
333
|
const hidden = computers.length - Math.min(computers.length, MAX_LISTED_COMPUTERS)
|
|
137
334
|
let table = lines.join('\n')
|
|
138
335
|
if (hidden > 0) table += `\n… and ${hidden} more (only the first ${MAX_LISTED_COMPUTERS} are shown)`
|
|
139
|
-
if (selected !== undefined) table +=
|
|
336
|
+
if (selected !== undefined) table += '\n\nRows marked * are selected in the composer for this session.'
|
|
140
337
|
return table
|
|
141
338
|
}
|
|
142
339
|
|
|
143
340
|
/** The protocol's `$ command` / status / stdout / stderr layout. */
|
|
144
341
|
function renderExec(value) {
|
|
145
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
|
+
}
|
|
146
351
|
const status = [
|
|
147
352
|
`exit ${value?.exitCode ?? 'signal'}`,
|
|
148
353
|
`${value?.durationMs ?? 0}ms`,
|
|
@@ -158,19 +363,105 @@ function renderExec(value) {
|
|
|
158
363
|
return boundRender(lines.join('\n'))
|
|
159
364
|
}
|
|
160
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
|
+
|
|
161
408
|
/**
|
|
162
409
|
* Register the tools and the system-prompt section for one plugin mount.
|
|
163
410
|
* @param {object} options - wiring.
|
|
164
|
-
* @param {object} options.ctx - Cordis context (tools
|
|
411
|
+
* @param {object} options.ctx - Cordis context (tools, systemPrompt, jobs).
|
|
165
412
|
* @param {object} options.config - resolved plugin config.
|
|
166
413
|
* @param {object} options.store - registry store.
|
|
167
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).
|
|
168
419
|
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
169
420
|
*/
|
|
170
|
-
export function createTools({
|
|
171
|
-
|
|
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). */
|
|
172
463
|
const resolveComputer = (query) => {
|
|
173
|
-
const computers =
|
|
464
|
+
const computers = visibleComputers()
|
|
174
465
|
if (computers.length === 0) {
|
|
175
466
|
throw new Error('no computers are connected to this deployment; ask the user to connect one from Settings → 电脑')
|
|
176
467
|
}
|
|
@@ -191,15 +482,7 @@ export function createTools({ ctx, config, store, hub, selections = null, onWarn
|
|
|
191
482
|
throw new Error(`no computer matches '${query}'; available computers: ${available}. Run computer_list for details.`)
|
|
192
483
|
}
|
|
193
484
|
|
|
194
|
-
/**
|
|
195
|
-
* The caller's session identity.
|
|
196
|
-
*
|
|
197
|
-
* `exec.agent.id` IS the session-backed Agent identity (`SessionId`), and the
|
|
198
|
-
* live runtime face additionally exposes the same value as `agent.session.id`
|
|
199
|
-
* (see core/agent/src/types.ts: `readonly id: SessionId`, "Session-backed
|
|
200
|
-
* Agent identity"). Both are read so a future handle shape cannot silently
|
|
201
|
-
* disable the composer default.
|
|
202
|
-
*/
|
|
485
|
+
/** The caller's session identity (`exec.agent.id` / `agent.session.id`). */
|
|
203
486
|
const sessionIdOf = (exec) => {
|
|
204
487
|
const agent = exec?.agent
|
|
205
488
|
if (agent === undefined || agent === null) return null
|
|
@@ -214,9 +497,16 @@ export function createTools({ ctx, config, store, hub, selections = null, onWarn
|
|
|
214
497
|
if (sessionId === null || selections === null) return null
|
|
215
498
|
const stored = selections.get(sessionId)
|
|
216
499
|
if (stored === null) return null
|
|
217
|
-
return
|
|
500
|
+
return visibleComputers().some((computer) => computer.id === stored) ? stored : null
|
|
218
501
|
}
|
|
219
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
|
+
|
|
220
510
|
/** Explicit argument wins; otherwise the computer pinned in the composer. */
|
|
221
511
|
const resolveTargetComputer = (query, exec) => {
|
|
222
512
|
if (query !== undefined) return resolveComputer(query)
|
|
@@ -224,13 +514,95 @@ export function createTools({ ctx, config, store, hub, selections = null, onWarn
|
|
|
224
514
|
if (selectedId === null) {
|
|
225
515
|
throw new Error('no computer selected: pass `computer` (an id or name from computer_list), or pick a target computer in the composer')
|
|
226
516
|
}
|
|
227
|
-
const selected =
|
|
517
|
+
const selected = visibleComputers().find((computer) => computer.id === selectedId)
|
|
228
518
|
if (selected === undefined) {
|
|
229
519
|
throw new Error('the computer selected in the composer no longer exists; run computer_list and pick another target')
|
|
230
520
|
}
|
|
231
521
|
return selected
|
|
232
522
|
}
|
|
233
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
|
+
|
|
234
606
|
/** Validate `computer_exec` arguments into a normalized shape. */
|
|
235
607
|
const validateExecArgs = (args) => {
|
|
236
608
|
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
|
|
@@ -256,41 +628,51 @@ export function createTools({ ctx, config, store, hub, selections = null, onWarn
|
|
|
256
628
|
}
|
|
257
629
|
requestedTimeout = Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.trunc(args.timeoutMs)))
|
|
258
630
|
}
|
|
259
|
-
|
|
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 }
|
|
260
637
|
}
|
|
261
638
|
|
|
262
|
-
/**
|
|
263
|
-
const
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
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)
|
|
277
653
|
}
|
|
278
|
-
return error instanceof Error ? error : new Error(String(error))
|
|
279
654
|
}
|
|
280
655
|
|
|
281
|
-
//
|
|
282
|
-
//
|
|
656
|
+
// -------------------------------------------------------------------------
|
|
657
|
+
// computer_list
|
|
658
|
+
// -------------------------------------------------------------------------
|
|
659
|
+
|
|
283
660
|
ctx.effect(() => ctx.tools.register({
|
|
284
661
|
name: 'computer_list',
|
|
285
|
-
description: 'List the computers connected to this DSH Web deployment (the user\'s own machines), with
|
|
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.',
|
|
286
663
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
287
664
|
output: {
|
|
288
665
|
schema: LIST_OUTPUT_SCHEMA,
|
|
289
666
|
render: (_args, value) => textBlock(renderComputerList(value)),
|
|
290
667
|
},
|
|
291
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))
|
|
292
674
|
const selectedId = selectedComputerId(exec)
|
|
293
|
-
const computers =
|
|
675
|
+
const computers = visible.map((computer) => ({
|
|
294
676
|
id: computer.id,
|
|
295
677
|
name: computer.name,
|
|
296
678
|
online: hub.isOnline(computer),
|
|
@@ -299,14 +681,25 @@ export function createTools({ ctx, config, store, hub, selections = null, onWarn
|
|
|
299
681
|
hostname: computer.hostname ?? '',
|
|
300
682
|
lastSeenAt: typeof computer.lastSeenAt === 'string' ? computer.lastSeenAt : null,
|
|
301
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),
|
|
302
687
|
}))
|
|
303
|
-
return {
|
|
688
|
+
return {
|
|
689
|
+
computers,
|
|
690
|
+
selectedComputerId: selectedId !== null && visibleIds.has(selectedId) ? selectedId : null,
|
|
691
|
+
hiddenComputerCount: all.length - visible.length,
|
|
692
|
+
}
|
|
304
693
|
},
|
|
305
694
|
}), 'dsh-computers: computer_list tool')
|
|
306
695
|
|
|
696
|
+
// -------------------------------------------------------------------------
|
|
697
|
+
// computer_exec
|
|
698
|
+
// -------------------------------------------------------------------------
|
|
699
|
+
|
|
307
700
|
ctx.effect(() => ctx.tools.register({
|
|
308
701
|
name: 'computer_exec',
|
|
309
|
-
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
|
|
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.',
|
|
310
703
|
parameters: EXEC_PARAMETERS_SCHEMA,
|
|
311
704
|
output: {
|
|
312
705
|
schema: EXEC_OUTPUT_SCHEMA,
|
|
@@ -318,24 +711,78 @@ export function createTools({ ctx, config, store, hub, selections = null, onWarn
|
|
|
318
711
|
}
|
|
319
712
|
const input = validateExecArgs(args)
|
|
320
713
|
const computer = resolveTargetComputer(input.computer, exec)
|
|
321
|
-
|
|
322
|
-
|
|
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 }
|
|
323
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
|
|
324
749
|
let result
|
|
325
750
|
try {
|
|
326
751
|
result = await hub.dispatch({
|
|
327
752
|
computerId: computer.id,
|
|
328
753
|
name: computer.name,
|
|
329
754
|
command: input.command,
|
|
330
|
-
cwd
|
|
755
|
+
cwd,
|
|
331
756
|
timeoutMs: input.timeoutMs,
|
|
757
|
+
agentTimeoutMs: AGENT_MAX_TIMEOUT_MS,
|
|
332
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,
|
|
333
777
|
})
|
|
334
778
|
} catch (error) {
|
|
335
779
|
throw actionable(error, computer, input.timeoutMs)
|
|
336
780
|
}
|
|
781
|
+
if (result !== null && typeof result === 'object' && result.promoted === true) {
|
|
782
|
+
return { computer: ref, command: input.command, jobId: result.jobId, promoted: true }
|
|
783
|
+
}
|
|
337
784
|
return {
|
|
338
|
-
computer:
|
|
785
|
+
computer: ref,
|
|
339
786
|
command: input.command,
|
|
340
787
|
exitCode: result.exitCode,
|
|
341
788
|
stdout: result.stdout,
|
|
@@ -348,16 +795,162 @@ export function createTools({ ctx, config, store, hub, selections = null, onWarn
|
|
|
348
795
|
},
|
|
349
796
|
}), 'dsh-computers: computer_exec tool')
|
|
350
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
|
+
|
|
351
946
|
// ---------------------------------------------------------------------------
|
|
352
|
-
// System prompt section (PROTOCOL §5.3)
|
|
947
|
+
// System prompt section (PROTOCOL §5.3, §11)
|
|
353
948
|
// ---------------------------------------------------------------------------
|
|
354
949
|
// `systemPrompt` is not in `inject` (it is optional for this plugin), so it
|
|
355
950
|
// is looked up defensively; a host without it keeps working unchanged.
|
|
356
951
|
try {
|
|
357
952
|
const systemPrompt = ctx.get('systemPrompt')
|
|
358
953
|
if (systemPrompt !== undefined && systemPrompt !== null && typeof systemPrompt.section === 'function') {
|
|
359
|
-
// The nearest centrally allocated slot is COMPUTER_USE; fall back to its
|
|
360
|
-
// numeric value when the host's table predates that name.
|
|
361
954
|
let order = 3000
|
|
362
955
|
try {
|
|
363
956
|
const resolved = typeof systemPrompt.getSectionOrder === 'function'
|
|
@@ -367,19 +960,25 @@ export function createTools({ ctx, config, store, hub, selections = null, onWarn
|
|
|
367
960
|
} catch {
|
|
368
961
|
// Unknown placement name: keep the numeric fallback.
|
|
369
962
|
}
|
|
370
|
-
// Wrapped in an effect: a plugin reload must remove the old section, or
|
|
371
|
-
// the duplicate name 'computers' throws and the section silently vanishes.
|
|
372
963
|
ctx.effect(() => systemPrompt.section({
|
|
373
964
|
name: 'computers',
|
|
374
965
|
order,
|
|
375
|
-
text: () => {
|
|
376
|
-
const
|
|
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 })
|
|
377
973
|
if (computers.length === 0) return ''
|
|
378
974
|
const lines = [
|
|
379
975
|
'You can run shell commands on the user\'s connected computers with `computer_exec`.',
|
|
380
|
-
'Call `computer_list` to see their ids, names and
|
|
976
|
+
'Call `computer_list` to see their ids, names, online state, capabilities and the target pinned in the composer.',
|
|
381
977
|
'The user can pin a target computer in the composer; `computer_exec` without a `computer` argument runs on that pinned computer.',
|
|
382
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
|
+
}
|
|
383
982
|
for (const computer of computers.slice(0, MAX_LISTED_COMPUTERS)) {
|
|
384
983
|
const state = hub.isOnline(computer) ? 'online' : 'offline'
|
|
385
984
|
lines.push(`- ${computer.name} (${computer.platformLabel ?? 'Unknown'}, ${state})`)
|