@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.
- package/LICENSE +21 -0
- package/README.md +207 -2
- package/README.zh.md +195 -0
- package/assets/icon.svg +47 -0
- package/cordis.patch.yml +41 -0
- package/lib/bridge/acp-bridge.js +905 -0
- package/lib/bridge/peer-connection.js +730 -0
- package/lib/client.js +2842 -0
- package/lib/core/acp-model-overlay.js +121 -0
- package/lib/core/approval-queue.js +216 -0
- package/lib/core/audit-log.js +322 -0
- package/lib/core/desktop-notify.js +173 -0
- package/lib/core/environment-prompt.js +76 -0
- package/lib/core/relay-supervisor.js +278 -0
- package/lib/core/review-engine.js +679 -0
- package/lib/core/sandbox-diagnosis.js +258 -0
- package/lib/core/sandbox-probe.js +310 -0
- package/lib/core/self-acp-spec.js +117 -0
- package/lib/core/selfcheck-runner.js +150 -0
- package/lib/core/settings-store.js +226 -0
- package/lib/core/shot-gc.js +334 -0
- package/lib/core/task-cwd.js +50 -0
- package/lib/core/task-memory.js +163 -0
- package/lib/core/task-registry.js +238 -0
- package/lib/core/toolcall-cache.js +206 -0
- package/lib/core/transfer-queue.js +58 -0
- package/lib/core/transfer.js +1144 -0
- package/lib/core/worker-arming.js +334 -0
- package/lib/index.js +2207 -0
- package/lib/provider/out-of-process-helpers.js +76 -0
- package/lib/provider/remote-provider.js +261 -0
- package/lib/tools/mediator-shim.mjs +180 -0
- package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
- package/lib/tools/remote-tools.js +680 -0
- package/lib/tools/selfcheck-fix-prompt.md +206 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/slave/acp-profile-approval.patch.yml +66 -0
- package/slave/file-transfer.mjs +552 -0
- package/slave/integrity-probe.ps1 +99 -0
- package/slave/make-launch-spec.mjs +184 -0
- package/slave/net-address.mjs +138 -0
- package/slave/node_modules/.package-lock.json +26 -0
- package/slave/node_modules/ws/LICENSE +20 -0
- package/slave/node_modules/ws/README.md +548 -0
- package/slave/node_modules/ws/browser.js +8 -0
- package/slave/node_modules/ws/index.js +22 -0
- package/slave/node_modules/ws/lib/buffer-util.js +131 -0
- package/slave/node_modules/ws/lib/constants.js +19 -0
- package/slave/node_modules/ws/lib/event-target.js +292 -0
- package/slave/node_modules/ws/lib/extension.js +203 -0
- package/slave/node_modules/ws/lib/limiter.js +55 -0
- package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/slave/node_modules/ws/lib/receiver.js +743 -0
- package/slave/node_modules/ws/lib/sender.js +607 -0
- package/slave/node_modules/ws/lib/stream.js +161 -0
- package/slave/node_modules/ws/lib/subprotocol.js +62 -0
- package/slave/node_modules/ws/lib/validation.js +152 -0
- package/slave/node_modules/ws/lib/websocket-server.js +562 -0
- package/slave/node_modules/ws/lib/websocket.js +1421 -0
- package/slave/node_modules/ws/package.json +74 -0
- package/slave/node_modules/ws/wrapper.mjs +21 -0
- package/slave/pairing.mjs +175 -0
- package/slave/permission-pairing.mjs +240 -0
- package/slave/prune-shots.mjs +170 -0
- package/slave/relay.mjs +1351 -0
- package/slave/repair-workspace-integrity.ps1 +75 -0
- package/slave/screenshot.ps1 +114 -0
- package/slave/selfcheck.ps1 +350 -0
- package/slave/stage-app.ps1 +116 -0
- 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
|
+
}
|