@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/README.zh.md +195 -0
  4. package/assets/icon.svg +47 -0
  5. package/cordis.patch.yml +41 -0
  6. package/lib/bridge/acp-bridge.js +905 -0
  7. package/lib/bridge/peer-connection.js +730 -0
  8. package/lib/client.js +2842 -0
  9. package/lib/core/acp-model-overlay.js +121 -0
  10. package/lib/core/approval-queue.js +216 -0
  11. package/lib/core/audit-log.js +322 -0
  12. package/lib/core/desktop-notify.js +173 -0
  13. package/lib/core/environment-prompt.js +76 -0
  14. package/lib/core/relay-supervisor.js +278 -0
  15. package/lib/core/review-engine.js +679 -0
  16. package/lib/core/sandbox-diagnosis.js +258 -0
  17. package/lib/core/sandbox-probe.js +310 -0
  18. package/lib/core/self-acp-spec.js +117 -0
  19. package/lib/core/selfcheck-runner.js +150 -0
  20. package/lib/core/settings-store.js +226 -0
  21. package/lib/core/shot-gc.js +334 -0
  22. package/lib/core/task-cwd.js +50 -0
  23. package/lib/core/task-memory.js +163 -0
  24. package/lib/core/task-registry.js +238 -0
  25. package/lib/core/toolcall-cache.js +206 -0
  26. package/lib/core/transfer-queue.js +58 -0
  27. package/lib/core/transfer.js +1144 -0
  28. package/lib/core/worker-arming.js +334 -0
  29. package/lib/index.js +2207 -0
  30. package/lib/provider/out-of-process-helpers.js +76 -0
  31. package/lib/provider/remote-provider.js +261 -0
  32. package/lib/tools/mediator-shim.mjs +180 -0
  33. package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
  34. package/lib/tools/remote-tools.js +680 -0
  35. package/lib/tools/selfcheck-fix-prompt.md +206 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +72 -4
  39. package/slave/acp-profile-approval.patch.yml +66 -0
  40. package/slave/file-transfer.mjs +552 -0
  41. package/slave/integrity-probe.ps1 +99 -0
  42. package/slave/make-launch-spec.mjs +184 -0
  43. package/slave/net-address.mjs +138 -0
  44. package/slave/node_modules/.package-lock.json +26 -0
  45. package/slave/node_modules/ws/LICENSE +20 -0
  46. package/slave/node_modules/ws/README.md +548 -0
  47. package/slave/node_modules/ws/browser.js +8 -0
  48. package/slave/node_modules/ws/index.js +22 -0
  49. package/slave/node_modules/ws/lib/buffer-util.js +131 -0
  50. package/slave/node_modules/ws/lib/constants.js +19 -0
  51. package/slave/node_modules/ws/lib/event-target.js +292 -0
  52. package/slave/node_modules/ws/lib/extension.js +203 -0
  53. package/slave/node_modules/ws/lib/limiter.js +55 -0
  54. package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
  55. package/slave/node_modules/ws/lib/receiver.js +743 -0
  56. package/slave/node_modules/ws/lib/sender.js +607 -0
  57. package/slave/node_modules/ws/lib/stream.js +161 -0
  58. package/slave/node_modules/ws/lib/subprotocol.js +62 -0
  59. package/slave/node_modules/ws/lib/validation.js +152 -0
  60. package/slave/node_modules/ws/lib/websocket-server.js +562 -0
  61. package/slave/node_modules/ws/lib/websocket.js +1421 -0
  62. package/slave/node_modules/ws/package.json +74 -0
  63. package/slave/node_modules/ws/wrapper.mjs +21 -0
  64. package/slave/pairing.mjs +175 -0
  65. package/slave/permission-pairing.mjs +240 -0
  66. package/slave/prune-shots.mjs +170 -0
  67. package/slave/relay.mjs +1351 -0
  68. package/slave/repair-workspace-integrity.ps1 +75 -0
  69. package/slave/screenshot.ps1 +114 -0
  70. package/slave/selfcheck.ps1 +350 -0
  71. package/slave/stage-app.ps1 +116 -0
  72. package/slave/workspace-integrity.mjs +157 -0
@@ -0,0 +1,680 @@
1
+ /**
2
+ * Model-facing tools for remote work.
3
+ *
4
+ * WHY THESE EXIST (DESIGN §2.2 C3/C4):
5
+ * - A remote run has no local child session, so `list_agents` and `wait_agent`
6
+ * cannot see it, and `send_message`/`interrupt_agent` are dead for it (they go
7
+ * through an Activation the remote run does not have). The official control
8
+ * tools therefore give the model no way to observe or stop remote work.
9
+ * - These three tools close that gap. They are the reason a remote delegation
10
+ * feels like a subagent rather than a fire-and-forget RPC.
11
+ * - `remote_subagent` (added later, PITFALLS F11) is the delegation entry point itself.
12
+ * It has to live here rather than in a profile patch's `dsh-tool-subagent` row, because a
13
+ * Web session's tool catalog comes from the agent preset, not the loader tree.
14
+ *
15
+ * Deliberately NOT provided: `remote_send_message`. The subagent provider seam
16
+ * cannot support continuable children (C2), so there is nothing to continue.
17
+ *
18
+ * @module dsh-plugin-remote-use/remote-tools
19
+ */
20
+
21
+ import { blocksToText } from '../provider/remote-provider.js'
22
+ import { makeTransferQueue } from '../core/transfer-queue.js'
23
+
24
+ /**
25
+ * Build an object-rooted JSON Schema from a field map.
26
+ *
27
+ * WHY THIS IS HAND-ROLLED INSTEAD OF USING `defineTool`:
28
+ *
29
+ * `defineTool` from `@deepseek-ai/dsh-tools` exists to perform exactly this conversion
30
+ * (`const parameters = parameterSchemaSpecToJsonSchema(options.parameters)`), but a
31
+ * third-party plugin cannot rely on importing `@deepseek-ai/*` here: the profile's
32
+ * `node_modules/@deepseek-ai/...` entries are dangling junctions left by an old npm
33
+ * install, and Node resolves a linked package through its real path, so the import
34
+ * fails. Confirmed empirically — `@deepseek-ai/schemastery` is not importable either.
35
+ *
36
+ * `ctx.tools.register()` does NOT convert parameters; it validates only `output` and
37
+ * stores the definition verbatim. So registering the author shorthand unconverted hands
38
+ * the model a schema with no root type, and EVERY call fails with:
39
+ *
40
+ * Invalid schema for function 'remote_agents':
41
+ * schema must be a JSON Schema of 'type: "object"', got 'type: null'.
42
+ *
43
+ * The mapping mirrors `parameterSchemaSpecToJsonSchema`: a field's `required: true`
44
+ * moves its NAME into the root `required` array and is stripped from the property (it is
45
+ * not a JSON Schema property keyword). `required` is omitted entirely when empty, exactly
46
+ * as the official converter does.
47
+ *
48
+ * @param {Record<string, { required?: boolean, [k: string]: unknown }>} fields
49
+ * @returns {{ type: 'object', properties: Record<string, unknown>, required?: string[] }}
50
+ */
51
+ export function objectSchema(fields) {
52
+ const properties = {}
53
+ const required = []
54
+ for (const [name, spec] of Object.entries(fields ?? {})) {
55
+ const { required: isRequired, ...rest } = spec ?? {}
56
+ properties[name] = rest
57
+ if (isRequired === true) required.push(name)
58
+ }
59
+ return required.length === 0
60
+ ? { type: 'object', properties }
61
+ : { type: 'object', properties, required }
62
+ }
63
+
64
+ /**
65
+ * One tool definition, shaped per DSH's verified registration contract:
66
+ * `output` is REQUIRED and holds `render`; `parameters` must already be a COMPLETE
67
+ * JSON Schema (see objectSchema above) because nothing downstream converts it.
68
+ *
69
+ * @typedef {object} ToolDefinition
70
+ * @property {string} name
71
+ * @property {string} description
72
+ * @property {Record<string, unknown>} parameters
73
+ * @property {{ schema: unknown, render: (args: unknown, result: unknown) => unknown[] }} output
74
+ * @property {(args: any, exec: { signal?: AbortSignal }) => Promise<unknown>} execute
75
+ * @property {string} [group]
76
+ */
77
+
78
+ /**
79
+ * Build the remote-control tool set.
80
+ *
81
+ * @param {object} deps
82
+ * @param {import('./task-registry.js').TaskRegistry} deps.registry
83
+ * @param {Map<string, {peerId:string, name:string, bridge:any, selfcheck:() => Promise<object>, defaultCwd:string}>} deps.peers
84
+ * @param {{ record: (entry: object) => unknown }} [deps.audit] the transfer ledger
85
+ * @param {{ maxBytes: number, chunkBytes: number }} [deps.transfer] ceilings, read once at load
86
+ * @returns {ToolDefinition[]}
87
+ */
88
+ export function makeRemoteTools(deps) {
89
+ const { registry, peers } = deps
90
+ const audit = deps.audit
91
+ const transfer = deps.transfer ?? {}
92
+ // One lane for the life of this plugin instance, i.e. per host, keyed by peer inside.
93
+ const transfers = makeTransferQueue()
94
+
95
+ /** @type {ToolDefinition} */
96
+ const remoteAgents = {
97
+ name: 'remote_agents',
98
+ group: 'remote',
99
+ description:
100
+ 'Inspect work delegated to paired LAN workers. A remote task has no local child session, '
101
+ + 'so it never appears in `list_agents` — use this instead.\n'
102
+ + 'action="list": every paired worker and its in-flight tasks.\n'
103
+ + 'action="status": one task by taskId — state, elapsed, tool calls, whether it is waiting for '
104
+ + 'approval, and, once the task has settled, the full answer text it produced.\n'
105
+ + 'action="wait": block until a task settles, then report its outcome (same detail as status).\n'
106
+ + 'This is how a background delegation\'s answer is collected: `remote_subagent` returns only a '
107
+ + 'task id, and the text arrives here after the task settles.\n'
108
+ + 'Remote tasks cannot be continued: there is no `remote_send_message`. Delegate a fresh task instead.',
109
+ parameters: objectSchema({
110
+ action: { type: 'string', required: true, description: 'list | status | wait' },
111
+ taskId: { type: 'string', description: 'Required for status and wait.' },
112
+ peerId: { type: 'string', description: 'Optional filter for list.' },
113
+ timeoutMs: { type: 'number', description: 'For wait: give up after this many ms (default 120000).' },
114
+ }),
115
+ output: {
116
+ schema: { type: 'string' },
117
+ render: (_args, result) => [{ type: 'text', text: String(result ?? '') }],
118
+ },
119
+ async execute(args, exec) {
120
+ const action = String(args?.action ?? '')
121
+ if (action === 'list') return renderPeerList(peers, registry, args?.peerId)
122
+ if (action === 'status') {
123
+ if (typeof args?.taskId !== 'string') return 'remote_agents status: taskId is required'
124
+ return renderTaskDetail(registry.get(args.taskId))
125
+ }
126
+ if (action === 'wait') {
127
+ if (typeof args?.taskId !== 'string') return 'remote_agents wait: taskId is required'
128
+ return await waitForTask(registry, args.taskId, Number(args?.timeoutMs ?? 120_000), exec?.signal)
129
+ }
130
+ return `remote_agents: unknown action "${action}" (expected list | status | wait)`
131
+ },
132
+ }
133
+
134
+ /** @type {ToolDefinition} */
135
+ const remoteInterrupt = {
136
+ name: 'remote_interrupt',
137
+ group: 'remote',
138
+ description:
139
+ 'Stop a task running on a paired LAN worker. `interrupt_agent` does not work for remote tasks '
140
+ + '(they have no local Activation), so use this instead. Cancellation is cooperative and not '
141
+ + 'instant: the worker must reach a quiescent point before the task settles as aborted.',
142
+ parameters: objectSchema({
143
+ taskId: { type: 'string', required: true, description: 'The remote task to stop.' },
144
+ }),
145
+ output: {
146
+ schema: { type: 'string' },
147
+ render: (_args, result) => [{ type: 'text', text: String(result ?? '') }],
148
+ },
149
+ async execute(args) {
150
+ const task = registry.get(String(args?.taskId ?? ''))
151
+ if (task === undefined) return `remote_interrupt: no such task "${args?.taskId}"`
152
+ const peer = peers.get(task.peerId)
153
+ if (peer === undefined) return `remote_interrupt: worker "${task.peerId}" is not connected`
154
+ try {
155
+ await peer.bridge.cancel()
156
+ return `remote_interrupt: cancellation sent for ${task.taskId} (worker ${task.peerId}). `
157
+ + 'It settles as aborted once the worker reaches a quiescent point.'
158
+ } catch (error) {
159
+ return `remote_interrupt: failed to cancel ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`
160
+ }
161
+ },
162
+ }
163
+
164
+ /** @type {ToolDefinition} */
165
+ const remoteSelfcheck = {
166
+ name: 'remote_selfcheck',
167
+ group: 'remote',
168
+ description:
169
+ 'Ask a paired LAN worker whether it is currently able to run GUI tasks. Use this after a '
170
+ + 'delegation is refused, or before delegating something that needs the desktop.\n'
171
+ + 'Blocking findings (`S1`-`S8`, `S9`, `S12`, `S14`, `S15`) prevent dispatch. '
172
+ + '`no-interactive-session` means the worker is healthy but nobody is logged in on it — that is an '
173
+ + 'operational state, not a fault, and it cannot be fixed from here.',
174
+ parameters: objectSchema({
175
+ peerId: { type: 'string', description: 'Worker to check. Omit to check every paired worker.' },
176
+ }),
177
+ output: {
178
+ schema: { type: 'string' },
179
+ render: (_args, result) => [{ type: 'text', text: String(result ?? '') }],
180
+ },
181
+ async execute(args) {
182
+ const wanted = typeof args?.peerId === 'string' && args.peerId !== ''
183
+ ? [peers.get(args.peerId)].filter(Boolean)
184
+ : [...peers.values()]
185
+ if (wanted.length === 0) return 'remote_selfcheck: no paired workers'
186
+ const out = []
187
+ for (const peer of wanted) {
188
+ try {
189
+ out.push(renderSelfcheck(peer.name, await peer.selfcheck()))
190
+ } catch (error) {
191
+ out.push(`${peer.name}: self-check failed to run: ${error instanceof Error ? error.message : String(error)}`)
192
+ }
193
+ }
194
+ return out.join('\n\n')
195
+ },
196
+ }
197
+
198
+ /**
199
+ * Capture the worker's desktop.
200
+ *
201
+ * The return value is a path, not the picture: the model reaches the image with its own
202
+ * `read_image` on that path. That split is deliberate -- the picture never has to travel
203
+ * through the ACP message pump, which exists for small control frames, and it works even
204
+ * though the worker's own model may be unable to inspect images.
205
+ */
206
+ const remoteScreenshot = {
207
+ name: 'remote_screenshot',
208
+ group: 'remote',
209
+ description:
210
+ 'Capture the screen of a paired LAN worker and save it locally.\n'
211
+ + 'IMPORTANT: the tool returns a FILE PATH, not the picture. Call `read_image` on that path '
212
+ + 'to actually look at the worker\'s screen; without that step you have only a filename.\n'
213
+ + 'Use this to see what a GUI task did, to debug a failed visual assertion, or before '
214
+ + 'deciding whether a task that needs the desktop is worth delegating.\n'
215
+ + 'The capture covers the whole virtual desktop, so on a multi-monitor worker the frame '
216
+ + 'includes every display plus any uncovered area (which renders black). The worker-side '
217
+ + 'console is hidden by the relay so it does not appear in the frame.',
218
+ parameters: objectSchema({
219
+ peerId: { type: 'string', description: 'Worker to capture. Omit when only one worker is paired.' },
220
+ fresh: {
221
+ type: 'boolean',
222
+ description: 'Force a new capture. Defaults to true; pass false to reuse the relay\'s last frame.',
223
+ },
224
+ }),
225
+ output: {
226
+ schema: { type: 'string' },
227
+ render: (_args, result) => [{ type: 'text', text: String(result ?? '') }],
228
+ },
229
+ async execute(args) {
230
+ const wanted = typeof args?.peerId === 'string' && args.peerId !== ''
231
+ ? peers.get(args.peerId)
232
+ : [...peers.values()][0]
233
+ if (wanted === undefined) {
234
+ return typeof args?.peerId === 'string' && args.peerId !== ''
235
+ ? `remote_screenshot: no worker with peerId "${args.peerId}"`
236
+ : 'remote_screenshot: no paired workers'
237
+ }
238
+ if (wanted.connection?.screenshot === undefined) {
239
+ return `remote_screenshot: worker "${wanted.peerId}" has no screenshot channel`
240
+ }
241
+ try {
242
+ const shot = await wanted.connection.screenshot({ fresh: args?.fresh !== false })
243
+ const lines = [
244
+ `Captured ${wanted.name} at ${shot.capturedAt || 'now'} in ${shot.elapsedMs}ms.`,
245
+ `File: ${shot.localPath}`,
246
+ ]
247
+ if (shot.width > 0) lines.push(`Virtual desktop: ${shot.width}x${shot.height} across ${shot.displays} display(s).`)
248
+ lines.push(`Size: ${shot.bytes} bytes.`)
249
+ lines.push('')
250
+ lines.push(`Next: call read_image with file_path "${shot.localPath}" to see it.`)
251
+ return lines.join('\n')
252
+ } catch (error) {
253
+ return `remote_screenshot failed for "${wanted.peerId}": ${error instanceof Error ? error.message : String(error)}`
254
+ }
255
+ },
256
+ }
257
+
258
+ /**
259
+ * Delegate a task to a paired worker.
260
+ *
261
+ * WHY THE PLUGIN PROVIDES THIS ITSELF (PITFALLS F11):
262
+ *
263
+ * The obvious design is a `dsh-tool-subagent` loader row in the profile patch, one per worker,
264
+ * with `provider: remote-<id>`. That is what DESIGN.md originally specified, and it does compose
265
+ * correctly -- `dsh --dump-config` shows the row in the tree. **The model still never sees it.**
266
+ *
267
+ * A Web session's model-facing tool catalog is enumerated by the AGENT PRESET, not by the loader
268
+ * tree: `preset-standard.plugins` lists the tool rows that agent gets, and it lists its own four
269
+ * `dsh-tool-subagent` rows (`subagent`, `subagent_fork`, `subagent_codex`, `subagent_claude_code`)
270
+ * -- not ours. A row outside that list mounts nothing for the agent.
271
+ *
272
+ * Registering through `ctx.tools.register()` is the other route, and the tools package documents it
273
+ * as sufficient: "registering a tool is enough to make it visible -- the registry automatically
274
+ * feeds its schema into system-prompt assembly". The plugin's other four tools already work that
275
+ * way, which is the empirical proof.
276
+ *
277
+ * So this tool exists to close the gap, and it costs almost nothing: `RemoteProvider.start()`
278
+ * already owns the readiness gate, the serial queue, the TaskRegistry entry and result settling,
279
+ * and `remote_agents` already reads that registry. This file only builds the request and renders
280
+ * the answer.
281
+ */
282
+ const remoteSubagent = {
283
+ name: 'remote_subagent',
284
+ group: 'remote',
285
+ description:
286
+ 'Delegate a task to a paired LAN worker and drive it like a subagent.\n'
287
+ + 'Use this instead of `subagent` when the work needs a real desktop on ANOTHER machine: GUI '
288
+ + 'automation, launching and driving an application, confirming what an interface shows. Do all '
289
+ + 'code editing, builds and repository work HERE; delegate only what needs that desktop.\n'
290
+ + 'The worker cannot see this workspace, so anything written there does not come back.\n'
291
+ + 'The worker must pass its readiness check first; a worker that fails is refused rather than '
292
+ + 'half-run, so call `remote_selfcheck` if a delegation is refused.\n'
293
+ + 'Set `run_in_background: true` to keep working instead of waiting for the answer; read it '
294
+ + 'afterwards with `remote_agents action="status"`, and stop the task with `remote_interrupt`.\n'
295
+ + 'One worker runs one task at a time, because the desktop is exclusive. A worker that is already '
296
+ + 'busy does NOT accept this call straight away: even in background mode the call first waits for '
297
+ + 'its turn in that worker\'s queue, so it can take as long as the task ahead of it. Check '
298
+ + '`remote_agents action="list"` before delegating to a busy worker.\n'
299
+ + 'A remote task CANNOT be continued: there is no `remote_send_message`. Delegate a fresh task '
300
+ + 'instead of trying to reply to one.',
301
+ parameters: objectSchema({
302
+ prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the worker.' },
303
+ description: { type: 'string', description: 'Short label for this delegation, shown in remote_agents.' },
304
+ peerId: { type: 'string', description: 'Worker to use. Omit when only one is paired.' },
305
+ run_in_background: {
306
+ type: 'boolean',
307
+ description: 'Do not wait for the answer. The call still performs the readiness check and waits '
308
+ + 'for the worker\'s queue slot before returning a task id. Defaults to false (foreground).',
309
+ },
310
+ }),
311
+ output: {
312
+ schema: { type: 'string' },
313
+ render: (_args, result) => [{ type: 'text', text: String(result ?? '') }],
314
+ },
315
+ async execute(args) {
316
+ const prompt = typeof args?.prompt === 'string' ? args.prompt.trim() : ''
317
+ if (prompt === '') return 'remote_subagent: prompt is required'
318
+
319
+ const wanted = typeof args?.peerId === 'string' && args.peerId !== ''
320
+ ? peers.get(args.peerId)
321
+ : [...peers.values()][0]
322
+ if (wanted === undefined) {
323
+ return typeof args?.peerId === 'string' && args.peerId !== ''
324
+ ? `remote_subagent: no worker with peerId "${args.peerId}"`
325
+ : 'remote_subagent: no paired workers. Pair one in the plugin settings page.'
326
+ }
327
+ if (wanted.provider === undefined) {
328
+ // The provider is attached when the worker is wired. Absent means wiring has not finished
329
+ // (or this worker was just reconfigured), not that the worker is unusable.
330
+ return `remote_subagent: worker "${wanted.peerId}" is not ready to accept a delegation yet; check Settings -> Remote work nodes.`
331
+ }
332
+
333
+ const label = typeof args?.description === 'string' ? args.description : ''
334
+ let run
335
+ try {
336
+ run = await wanted.provider.start({ prompt, label })
337
+ } catch (error) {
338
+ const code = error?.code === undefined ? '' : ` [${error.code}]`
339
+ return `remote_subagent: delegation to "${wanted.peerId}" was refused${code}: ${error instanceof Error ? error.message : String(error)}`
340
+ }
341
+
342
+ if (args?.run_in_background === true) {
343
+ return [
344
+ `Delegated to ${wanted.name} in the background.`,
345
+ `taskId: ${run.id}`,
346
+ '',
347
+ `Read the answer once it settles: remote_agents action="status" taskId="${run.id}"`,
348
+ `Or block for it: remote_agents action="wait" taskId="${run.id}"`,
349
+ `Stop it with remote_interrupt taskId="${run.id}".`,
350
+ 'This task cannot be continued once it settles; delegate a fresh one.',
351
+ ].join('\n')
352
+ }
353
+
354
+ // Foreground: the provider's result never rejects -- a rejected promise after publication
355
+ // would strand the caller's bookkeeping -- so a failure arrives as stopReason/diagnostic.
356
+ const settled = await run.result
357
+ const text = blocksToText(settled?.output).trim()
358
+ const lines = [`Remote task ${run.id} on ${wanted.name}: ${settled?.stopReason ?? 'unknown'}`]
359
+ if (settled?.diagnostic !== undefined && settled.diagnostic !== '') {
360
+ lines.push(`diagnostic: ${settled.diagnostic}`)
361
+ }
362
+ lines.push('')
363
+ lines.push(text === '' ? '(the worker produced no text)' : text)
364
+ return lines.join('\n')
365
+ },
366
+ }
367
+
368
+ /**
369
+ * Move files between this machine and a paired worker.
370
+ *
371
+ * WHY THIS EXISTS: the only way to get a file onto a worker used to be `scp`, which requires the
372
+ * operator to install and configure SSH on that machine before any of this works. The paired link
373
+ * already carries an authenticated channel (the same token, the same port), so a transfer needs no
374
+ * new dependency, no new credential and no new firewall rule -- and it stays inside the LAN.
375
+ *
376
+ * WHY IT DOES NOT GO THROUGH THE WORKER'S AGENT: the alternative is to make the worker's model
377
+ * write the file, i.e. put the bytes in a prompt. That costs tokens proportional to the file, goes
378
+ * through a model that can mangle binary, cannot be verified, and -- worst -- occupies the worker's
379
+ * GUI slot, which is exclusive, so a 2 GiB copy would block the visual work it exists to support.
380
+ * Transfers ride the relay's own HTTP surface instead, on their own per-peer lane.
381
+ */
382
+ const remoteFile = {
383
+ name: 'remote_file',
384
+ group: 'remote',
385
+ description:
386
+ 'Move files between this machine and a paired LAN worker, over the link that is already paired.\n'
387
+ + 'Use it instead of `scp`/SSH: no SSH setup is needed on the worker, and it works while that '
388
+ + 'worker is busy with a GUI task, because a transfer never uses the worker\'s single task slot.\n'
389
+ + 'action="send": upload a local file to the worker. Needs `localPath` and `remotePath`.\n'
390
+ + 'action="fetch": download a file from the worker. Needs `remotePath`; `localPath` defaults to '
391
+ + 'this machine\'s transfer directory. The tool returns the absolute local path it wrote.\n'
392
+ + 'action="list": list a directory on the worker (name, size, mtime).\n'
393
+ + 'action="stat": existence, size and mtime of one path on the worker.\n'
394
+ + 'PATHS: a relative `remotePath` resolves against the worker\'s task directory; an absolute one '
395
+ + 'is used as given. The resolved path is always reported back, so what happened is visible.\n'
396
+ + 'An existing destination is NEVER overwritten unless you pass `overwrite: true`.\n'
397
+ + 'Large files are chunked and verified with a sha256 at the end; a dropped connection resumes '
398
+ + 'rather than restarting. The size ceiling is `transferMaxBytes` (config, default 8 GiB).',
399
+ parameters: objectSchema({
400
+ action: { type: 'string', required: true, description: 'send | fetch | list | stat' },
401
+ peerId: { type: 'string', description: 'Worker to use. Omit when only one worker is paired.' },
402
+ localPath: {
403
+ type: 'string',
404
+ description: 'For send: the local file to upload. For fetch: where to save it '
405
+ + '(default: this machine\'s transfer directory, and the name is de-duplicated, never clobbered).',
406
+ },
407
+ remotePath: {
408
+ type: 'string',
409
+ description: 'Path on the worker. Relative paths resolve against the worker\'s task directory.',
410
+ },
411
+ overwrite: { type: 'boolean', description: 'Allow replacing the destination. Defaults to false.' },
412
+ }),
413
+ output: {
414
+ schema: { type: 'string' },
415
+ render: (_args, result) => [{ type: 'text', text: String(result ?? '') }],
416
+ },
417
+ async execute(args, exec) {
418
+ const which = typeof args?.peerId === 'string' && args.peerId !== ''
419
+ ? peers.get(args.peerId)
420
+ : [...peers.values()][0]
421
+ if (which === undefined) {
422
+ return typeof args?.peerId === 'string' && args.peerId !== ''
423
+ ? `remote_file: no worker with peerId "${args.peerId}"`
424
+ : 'remote_file: no paired workers'
425
+ }
426
+ const connection = which.connection
427
+ if (connection === undefined) return `remote_file: worker "${which.peerId}" has no connection`
428
+ const action = String(args?.action ?? '')
429
+ const remotePath = typeof args?.remotePath === 'string' ? args.remotePath : ''
430
+ const localPath = typeof args?.localPath === 'string' ? args.localPath : ''
431
+
432
+ // list/stat are cheap metadata reads, not transfers: they must not queue behind a 2 GiB copy.
433
+ if (action === 'list' || action === 'stat') {
434
+ try {
435
+ if (action === 'stat') {
436
+ if (remotePath === '') return 'remote_file: stat needs remotePath'
437
+ const info = await connection.statFile(remotePath)
438
+ if (info?.exists !== true) return `remote_file: "${info?.path ?? remotePath}" does not exist on ${which.name}`
439
+ const kind = info.isDir === true ? 'directory' : 'file'
440
+ return [
441
+ `${which.name}: ${info.path} is a ${kind}.`,
442
+ info.isDir === true ? '' : `Size: ${info.size} bytes`,
443
+ `Modified: ${info.mtime || 'unknown'}`,
444
+ ].filter((l) => l !== '').join('\n')
445
+ }
446
+ const listing = await connection.listFiles(remotePath === '' ? '.' : remotePath)
447
+ const entries = Array.isArray(listing?.entries) ? listing.entries : []
448
+ const lines = [`${which.name}: ${listing?.path ?? remotePath} (${entries.length} entr${entries.length === 1 ? 'y' : 'ies'})`]
449
+ for (const e of entries) {
450
+ lines.push(` ${e.isDir === true ? 'd' : '-'} ${String(e.size ?? 0).padStart(12)} ${e.mtime ?? ''} ${e.name}`)
451
+ }
452
+ if (listing?.truncated === true) lines.push(' (truncated -- more entries than the worker returned)')
453
+ return lines.join('\n')
454
+ } catch (error) {
455
+ return `remote_file ${action} failed for "${which.peerId}": ${error instanceof Error ? error.message : String(error)}`
456
+ }
457
+ }
458
+
459
+ if (action !== 'send' && action !== 'fetch') {
460
+ return `remote_file: unknown action "${action}" (expected send, fetch, list or stat)`
461
+ }
462
+ if (remotePath === '') return `remote_file: ${action} needs remotePath`
463
+ if (action === 'send' && localPath === '') return 'remote_file: send needs localPath'
464
+
465
+ // Report the wait BEFORE entering the lane: a queued copy that says nothing is indistinguishable
466
+ // from a hung one (PITFALLS: a slow feature and a broken one look the same to the caller).
467
+ const ahead = transfers.depth(which.peerId)
468
+ const startedAt = Date.now()
469
+ const progress = []
470
+ const onProgress = (p) => {
471
+ // Keep the last few marks only; a 1 GiB transfer would otherwise build a huge array.
472
+ if (progress.length > 24) progress.shift()
473
+ progress.push(p)
474
+ }
475
+
476
+ try {
477
+ const result = await transfers.run(which.peerId, () => (action === 'send'
478
+ ? connection.sendFile({
479
+ localPath,
480
+ remotePath,
481
+ overwrite: args?.overwrite === true,
482
+ maxBytes: transfer.maxBytes,
483
+ chunkBytes: transfer.chunkBytes,
484
+ onProgress,
485
+ signal: exec?.signal,
486
+ })
487
+ : connection.fetchFile({
488
+ remotePath,
489
+ // `destPath`, not `localPath`: that is the name the connection takes, and passing the
490
+ // wrong one is silent -- the file lands in the default receive directory and the caller
491
+ // is told it succeeded somewhere else.
492
+ destPath: localPath === '' ? undefined : localPath,
493
+ overwrite: args?.overwrite === true,
494
+ maxBytes: transfer.maxBytes,
495
+ onProgress,
496
+ signal: exec?.signal,
497
+ })))
498
+ const elapsedMs = Date.now() - startedAt
499
+ const bytes = Number(result?.bytes ?? 0)
500
+ const sha = String(result?.sha256 ?? '')
501
+ // The RESOLVED remote path, not the one the caller typed.
502
+ //
503
+ // This echo is the mitigation for the worst failure this feature has produced: a relative path
504
+ // resolved against the wrong directory (the relay's inherited cwd), landed in the worker's DSH
505
+ // profile directory, and this tool reported the caller's own spelling back -- so the mistake
506
+ // looked exactly like success. The relay echoes what it resolved (`remote.path` from the commit
507
+ // body / `x-file-path`); the chain is defensive because the upload and download shapes differ.
508
+ const resolvedRemote = String(result?.remote?.path ?? result?.remotePath ?? result?.path ?? remotePath)
509
+ // The ledger row: a transfer is a durable write on someone else's disk, so it belongs in the
510
+ // same trail as approvals rather than only in this tool's output.
511
+ if (typeof audit?.record === 'function') {
512
+ audit.record({
513
+ kind: 'transfer',
514
+ direction: action,
515
+ peer: which.peerId,
516
+ localPath: action === 'send' ? localPath : String(result?.localPath ?? ''),
517
+ remotePath: resolvedRemote,
518
+ bytes,
519
+ sha256: sha,
520
+ ms: elapsedMs,
521
+ overwrite: args?.overwrite === true,
522
+ })
523
+ }
524
+ const head = action === 'send'
525
+ ? `Uploaded to ${which.name}: ${resolvedRemote}`
526
+ : `Downloaded from ${which.name}: ${resolvedRemote}`
527
+ const lines = [
528
+ head,
529
+ action === 'fetch' ? `Local file: ${String(result?.localPath ?? '')}` : `Local source: ${localPath}`,
530
+ `Size: ${bytes} bytes in ${elapsedMs}ms`,
531
+ sha === '' ? '' : `sha256: ${sha}`,
532
+ ].filter((l) => l !== '')
533
+ if (ahead > 0) lines.push(`(waited behind ${ahead} transfer(s) on this worker)`)
534
+ if (action === 'fetch') {
535
+ lines.push('')
536
+ lines.push(`Next: read it with the file tools, or \`read_image\` if it is an image.`)
537
+ }
538
+ return lines.join('\n')
539
+ } catch (error) {
540
+ return `remote_file ${action} failed for "${which.peerId}": ${error instanceof Error ? error.message : String(error)}`
541
+ }
542
+ },
543
+ }
544
+
545
+ return [remoteAgents, remoteInterrupt, remoteSelfcheck, remoteScreenshot, remoteFile, remoteSubagent]
546
+ }
547
+
548
+ /**
549
+ * @param {Map<string, any>} peers
550
+ * @param {import('./task-registry.js').TaskRegistry} registry
551
+ * @param {string} [peerId]
552
+ */
553
+ export function renderPeerList(peers, registry, peerId) {
554
+ if (peers.size === 0) return 'No paired LAN workers. Pair one in the plugin settings page.'
555
+ const lines = []
556
+ for (const peer of peers.values()) {
557
+ if (peerId !== undefined && peer.peerId !== peerId) continue
558
+ const tasks = registry.listByPeer(peer.peerId)
559
+ const active = tasks.filter((t) => !isSettled(t.state))
560
+ lines.push(`${peer.name} (${peer.peerId}) active=${active.length} total=${tasks.length} cwd=${peer.defaultCwd}`)
561
+ for (const t of tasks.slice(0, 10)) {
562
+ lines.push(' ' + renderTaskLine(t))
563
+ }
564
+ if (tasks.length > 10) lines.push(` ... and ${tasks.length - 10} more`)
565
+ }
566
+ return lines.length === 0 ? `No worker with peerId "${peerId}".` : lines.join('\n')
567
+ }
568
+
569
+ /** @param {import('./task-registry.js').RemoteTask | undefined} task */
570
+ export function renderTaskDetail(task) {
571
+ if (task === undefined) return 'No such remote task. Use remote_agents action="list" to see task ids.'
572
+ const lines = [
573
+ `taskId : ${task.taskId}`,
574
+ `worker : ${task.peerId}`,
575
+ `state : ${task.state}${isSettled(task.state) ? '' : ' (running)'}`,
576
+ `label : ${task.label || '(none)'}`,
577
+ `cwd : ${task.cwd}`,
578
+ `elapsed : ${Math.round(((task.endedAt ?? Date.now()) - task.startedAt) / 1000)}s`,
579
+ `tool calls : ${task.toolCallCount}${task.recentTools.length === 0 ? '' : ` (recent: ${task.recentTools.join(', ')})`}`,
580
+ ]
581
+ if (task.waitingForApproval) lines.push('waiting : for approval on the master')
582
+ if (task.stopReason !== undefined) lines.push(`stopReason : ${task.stopReason}`)
583
+ if (task.diagnostic !== undefined) lines.push(`diagnostic : ${task.diagnostic}`)
584
+ if (task.usage !== undefined) lines.push(`tokens used : ${task.usage.used}`)
585
+ if (task.promptDigest !== '') lines.push(`prompt : ${task.promptDigest}${task.promptDigest.length >= 160 ? '…' : ''}`)
586
+ lines.push(...renderOutputSection(task))
587
+ return lines.join('\n')
588
+ }
589
+
590
+ /**
591
+ * The answer a settled task produced, as a labelled section.
592
+ *
593
+ * WHY THIS IS THE POINT OF THE TOOL: a background delegation used to report only progress
594
+ * metrics, so its answer was unreachable and the delegation was a dead end (PITFALLS F15).
595
+ * The section is explicit about which of the three situations it is in — an answer, a
596
+ * truncated answer, or no answer at all — because a bare blank after the header reads as a
597
+ * rendering bug rather than as "the worker said nothing".
598
+ *
599
+ * @param {import('./task-registry.js').RemoteTask} task
600
+ * @returns {string[]}
601
+ */
602
+ function renderOutputSection(task) {
603
+ if (!isSettled(task.state)) return []
604
+ if (typeof task.output !== 'string' || task.output === '') {
605
+ // A running task has no answer yet; a settled one with nothing to say must say so.
606
+ return ['', task.state === 'completed' ? 'output : (the worker produced no text)' : 'output : (none)']
607
+ }
608
+ const total = task.outputChars ?? task.output.length
609
+ const truncated = total > task.output.length
610
+ const heading = truncated
611
+ ? `--- output (first ${task.output.length} of ${total} chars) ---`
612
+ : '--- output ---'
613
+ return ['', heading, task.output]
614
+ }
615
+
616
+ /**
617
+ * Block until a task settles.
618
+ * @param {import('./task-registry.js').TaskRegistry} registry
619
+ * @param {string} taskId
620
+ * @param {number} timeoutMs
621
+ * @param {AbortSignal} [signal]
622
+ */
623
+ export async function waitForTask(registry, taskId, timeoutMs, signal) {
624
+ const deadline = Date.now() + Math.max(0, timeoutMs)
625
+ for (;;) {
626
+ const task = registry.get(taskId)
627
+ if (task === undefined) return `remote_agents wait: no such task "${taskId}"`
628
+ if (isSettled(task.state)) {
629
+ return `Remote task settled.\n${renderTaskDetail(task)}`
630
+ }
631
+ if (Date.now() >= deadline) {
632
+ return `remote_agents wait: timed out after ${timeoutMs}ms; the task is still ${task.state}.\n${renderTaskDetail(task)}`
633
+ }
634
+ if (signal?.aborted === true) return `remote_agents wait: aborted by caller.\n${renderTaskDetail(task)}`
635
+ await new Promise((resolve) => setTimeout(resolve, 250))
636
+ }
637
+ }
638
+
639
+ /** @param {string} peerName */
640
+ export function renderSelfcheck(peerName, report) {
641
+ if (report === null || report === undefined) return `${peerName}: no self-check report`
642
+ const checks = Array.isArray(report.checks) ? report.checks : []
643
+ const bad = checks.filter((c) => c.status === 'fail' || c.status === 'warn')
644
+ const head = `${peerName}: verdict=${report.verdict ?? '?'} blocking=${report.blockingFailures ?? '?'} warn=${report.warnings ?? '?'} unchecked=${report.unchecked ?? '?'}`
645
+ const lines = [head]
646
+
647
+ // The missing-session note must ALWAYS print -- it is the difference between
648
+ // "the worker is broken" and "nobody is logged in", which are fixed by different
649
+ // people. Reporting it only when a check also fails would hide the common case.
650
+ if (report.sessionSegment === 'unavailable') {
651
+ lines.push(' NOTE: no interactive session on the worker -- someone must be logged in on it,')
652
+ lines.push(' with the desktop unlocked, before GUI tasks can run. This is not a fault.')
653
+ }
654
+
655
+ if (bad.length === 0) {
656
+ if (report.unchecked > 0 && report.sessionSegment !== 'unavailable') {
657
+ lines.push(` ${report.unchecked} check(s) could not run in the worker's current session.`)
658
+ }
659
+ return lines.join('\n')
660
+ }
661
+ for (const c of bad) {
662
+ lines.push(` [${c.status.toUpperCase()}] ${c.id} ${c.name}`)
663
+ lines.push(` expected: ${c.expected}`)
664
+ lines.push(` actual : ${c.actual}`)
665
+ if (typeof c.fix === 'string' && c.fix !== '' && c.fix !== '-') lines.push(` fix : ${c.fix}`)
666
+ }
667
+ return lines.join('\n')
668
+ }
669
+
670
+ function isSettled(state) {
671
+ return state === 'completed' || state === 'error' || state === 'aborted'
672
+ || state === 'refusal' || state === 'max-tokens' || state === 'cancelled'
673
+ }
674
+
675
+ /** @param {import('./task-registry.js').RemoteTask} t */
676
+ function renderTaskLine(t) {
677
+ const age = Math.round(((t.endedAt ?? Date.now()) - t.startedAt) / 1000)
678
+ const wait = t.waitingForApproval ? ' [awaiting approval]' : ''
679
+ return `${t.taskId} ${t.state} ${age}s tools=${t.toolCallCount}${wait} ${t.label || t.promptDigest.slice(0, 40)}`
680
+ }