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/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
+ }