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/lib/tools.js CHANGED
@@ -1,30 +1,51 @@
1
1
  /**
2
- * The model-facing half of dsh-computers (PROTOCOL §5): `computer_list`,
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
- * Tool definitions are registered as raw JSON Schema objects (no
7
- * `@deepseek-ai/*` imports, so the bundle loads in any profile). Argument
8
- * validation therefore lives here and produces messages the model can act on.
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: ~20 000 chars). */
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 enrolled computer, online or not.' },
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 for the command; defaults to the home 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: 'Timeout in milliseconds (defaults to the host exec timeout). The command is cancelled when it expires.',
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
- type: 'object',
83
- properties: {
84
- computer: {
124
+ oneOf: [
125
+ {
85
126
  type: 'object',
86
- properties: { id: { type: 'string' }, name: { type: 'string' } },
87
- required: ['id', 'name'],
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
- command: { type: 'string' },
91
- exitCode: { oneOf: [{ type: 'number' }, { type: 'null' }], description: 'Exit code, or null when the process died by signal.' },
92
- stdout: { type: 'string' },
93
- stderr: { type: 'string' },
94
- durationMs: { type: 'number' },
95
- ok: { type: 'boolean' },
96
- timedOut: { type: 'boolean' },
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', 'command', 'exitCode', 'stdout', 'stderr', 'durationMs', 'ok', 'timedOut', 'truncated'],
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 ? `* ${computer.name}` : computer.name
129
- return `| ${name} | ${computer.platformLabel} | ${status} | ${lastSeen} |`
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 |', '|---|---|---|---|', ...rows)
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 += `\n\nRows marked * are selected in the composer for this session.`
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 + optional systemPrompt).
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({ ctx, config, store, hub, selections = null, onWarn = () => {} }) {
171
- /** Resolve `computer` by exact id, exact name, then unique substring. */
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 = store.read().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 store.read().computers.some((computer) => computer.id === stored) ? stored : null
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 = store.read().computers.find((computer) => computer.id === selectedId)
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
- return { computer, command, cwd, timeoutMs: requestedTimeout ?? config.execTimeoutMs }
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
- /** Map a hub failure onto actionable model-facing text (PROTOCOL §5.2). */
263
- const actionable = (error, computer, timeoutMs) => {
264
- if (error instanceof HubError) {
265
- switch (error.code) {
266
- case 'offline':
267
- 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`)
268
- case 'timeout':
269
- return new Error(`computer '${computer.name}' did not finish within ${timeoutMs}ms; the command was cancelled. Retry with a larger timeoutMs, or run a shorter command`)
270
- case 'aborted':
271
- return new Error(`computer_exec on '${computer.name}' was aborted by the caller, and the command was cancelled on that machine`)
272
- case 'disposed':
273
- return new Error('the dsh-computers host plugin is shutting down; try again in a moment')
274
- default:
275
- return error
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
- // Each registration is wrapped in its own effect: the tool registry also
282
- // owns it, but an explicit effect makes the reload path deterministic.
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 their platform, online state and last-seen time. Call this before computer_exec to get a valid computer id or name.',
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 = store.read().computers.map((computer) => ({
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 { computers, selectedComputerId: selectedId }
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 (POSIX `sh -lc` on macOS/Linux, `cmd.exe` on Windows); there is no streaming, and the output is capped by the host. The machine must be online — call computer_list first.',
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
- if (!hub.isOnline(computer)) {
322
- 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`)
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: input.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: { id: computer.id, name: computer.name },
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 computers = store.read().computers
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 online state.',
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})`)