@twinsearth/w2m-dsh-plugin 2.0.4 → 2.0.6

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 (70) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +1795 -1779
  4. package/bin/w2m-joint.mjs +69 -7
  5. package/codex/mcp-servers.toml +1 -1
  6. package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
  7. package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
  8. package/docs/acceptance/2.0.6/RELEASE-2060.json +22 -0
  9. package/docs/adr/0001-collaboration-crdt.md +59 -0
  10. package/lib/client.js +215 -19
  11. package/package.json +2 -2
  12. package/plugin.json +1 -1
  13. package/src/agent/agent.mjs +97 -36
  14. package/src/agent/artifact-delivery.mjs +89 -0
  15. package/src/agent/caps.mjs +16 -2
  16. package/src/agent/sinks/git-sink.mjs +78 -0
  17. package/src/agent/sinks/ssh-sink.mjs +83 -0
  18. package/src/agent/sse-cursor.mjs +16 -0
  19. package/src/agent/sse-watchdog.mjs +71 -0
  20. package/src/plugin/dataplane-sink-route.mjs +118 -0
  21. package/src/plugin/fleet-chat-route.mjs +11 -0
  22. package/src/plugin/joint-dev/dispatch.mjs +5 -1
  23. package/src/plugin/joint-dev/merge-approval.mjs +115 -0
  24. package/src/plugin/joint-dev/progress.mjs +107 -0
  25. package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
  26. package/src/plugin/project-stream-route.mjs +58 -0
  27. package/src/plugin/tools-joint.mjs +6 -1
  28. package/src/plugin/tools.mjs +25 -5
  29. package/src/relay/fleet-messages.mjs +48 -24
  30. package/src/relay/project-room.mjs +47 -2
  31. package/src/relay/server.mjs +11 -5
  32. package/src/relay/state.mjs +63 -3
  33. package/src/util/chat-crypto.mjs +70 -1
  34. package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
  35. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
  36. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
  37. package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
  38. package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
  39. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
  40. package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
  41. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
  42. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
  43. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
  44. package/docs/COLLAB/TASK-MODEL.md +0 -281
  45. package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
  46. package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
  47. package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
  48. package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
  49. package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
  50. package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
  51. package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
  52. package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
  53. package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
  54. package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
  55. package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
  56. package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
  57. package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
  58. package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
  59. package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
  60. package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
  61. package/lib/client.js.bak2 +0 -640
  62. package/src/plugin/dataflow.mjs +0 -72
  63. package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
  64. package/src/plugin/fleet-target.mjs.bak-archive +0 -812
  65. package/src/plugin/tofuture/report.mjs +0 -44
  66. package/src/plugin/tools.mjs.bak-c7 +0 -7029
  67. package/src/relay/tofuture-whitelist.mjs +0 -46
  68. package/src/util/data-plane.mjs +0 -180
  69. package/src/util/sealed-object.mjs +0 -141
  70. package/src/util/tofuture-hmac.mjs +0 -30
@@ -1,812 +0,0 @@
1
- /**
2
- * W2M fleet targeting: the session's chosen machine, and the browser-facing half of it.
3
- *
4
- * # What this is for
5
- *
6
- * The plugin's eight tools all converse with the relay, and none of them takes a machine argument:
7
- * `w2m_run` fans out to every machine the relay holds. That is the right default for a fleet-wide
8
- * comparison and the wrong one for "run this on the Mac". This module is the missing half of the
9
- * second case — a *target selection* the operator makes in the composer, which is then applied to
10
- * the next dispatch **from that session** when the caller names no machines of its own.
11
- *
12
- * # Why the selection lives here and not in the browser
13
- *
14
- * Two things have to agree on it: the control the operator clicks, and `w2m_run`, which runs in the
15
- * host process with no browser in reach. The browser cannot be the source of truth for state the
16
- * dispatch path must read, so the selection is written here, in this process, and the browser is a
17
- * view over it. The side effect that matters: a selection survives a page reload, and the same
18
- * session id in a second window reads the same target.
19
- *
20
- * # Scope, and why it is per session
21
- *
22
- * `conversation.input.left` is a `session`-scoped seat, so the control renders once per session and
23
- * the browser sends that session's id on every call. The store is keyed by it, which is what stops
24
- * one chat's target from silently steering another's dispatch. A dispatch with no session in reach
25
- * (a bare `execute()` in a test, a future non-session caller) falls back to {@link NO_SESSION}, so
26
- * there is exactly one bucket for "no session" rather than an exception.
27
- *
28
- * # The wire, and why it is this small
29
- *
30
- * Two exact Fetch routes on the host's `/api` carrier, mounted inside its authentication fence.
31
- * The `/api` prefix is the registry's own channel — see {@link TARGET_ROUTE_PATH} — so these are the
32
- * paths as the host registers them, and the page fetches the same path document-relative:
33
- *
34
- * GET /api/w2m/target?session_id=… the fleet and this session's selection, as one snapshot
35
- * POST /api/w2m/target { session_id, machine_id|null } → the same snapshot
36
- *
37
- * A refusal is `{ok: false, error, reason_code, detail}` with a `{@link TARGET_REFUSAL}` code and a
38
- * `detail` that says what would change it; a `PUT`/`DELETE`/`PATCH` is a `405` rather than a write
39
- * (only exactly `GET` and `POST` are answered), a machine the relay does not list is a `409` exactly
40
- * like one it lists as offline, and `session_id` is capped at {@link MAX_SESSION_ID_LENGTH} characters
41
- * on both verbs because the store keys on the whole string.
42
- *
43
- * Plain `Response.json`, no Typert descriptor: this package is dependency-free, and a generated
44
- * strict Remote contribution is neither reachable from plain JavaScript nor worth a codec for one
45
- * object. Nothing else in the protocol changes — no new relay endpoint, no new tool parameter, no
46
- * `envelope_version` movement. The dispatch path is where the selection becomes real, and it is
47
- * spent on fields the relay already accepts (§4.1 `target_machines`, §4.3 `executor_machine_id`).
48
- *
49
- * # Failure is a value here, never an exception
50
- *
51
- * The relay may be down, this machine may be unpaired, the host may not provide a fetch-route
52
- * registry at all. Each of those becomes a field on the snapshot (`fleet.error`,
53
- * `detail_unavailable`) so the control can say what is wrong instead of rendering an empty list that
54
- * looks like an empty fleet. Only a *write* of a selection the relay does not list is refused with a
55
- * non-2xx status, because that is a caller error and the browser must not print a success it did not
56
- * get.
57
- */
58
-
59
- /**
60
- * Both routes hang off this exact path; the browser half reads the same constant.
61
- *
62
- * The `/api` prefix is not decoration and not ours: `@deepseek-ai/dsh-client-connection` mounts the
63
- * whole fetch-route registry under its `/api` channel and validates every registration with
64
- * `endpointFromPath('/api', path)`, which returns undefined for anything that does not start with
65
- * `/api/`. A `/w2m/target` registration is therefore refused — `connection: invalid exact Fetch route
66
- * "/w2m/target"` — inside the injected callback, where it failed the *child* fiber and left the host
67
- * entry looking perfectly healthy. The shipped browser halves all follow the same rule
68
- * (`dsh-client-file-upload` uses `/api/session/uploadFileBinary`), and the page fetches the same
69
- * document-relative path.
70
- */
71
- export const TARGET_ROUTE_PATH = '/api/w2m/target';
72
-
73
- /**
74
- * The store bucket used when a dispatch carries no session.
75
- *
76
- * A named constant rather than `null`/`undefined`: the fallback is a real bucket with real
77
- * behaviour ("one target for callers that are not a session"), and naming it keeps that visible in
78
- * `listSelections()` instead of hiding an accidental key.
79
- */
80
- export const NO_SESSION = '(no-session)';
81
-
82
- /** Session buckets retained before the oldest is evicted; a browse session cannot grow this. */
83
- export const MAX_SELECTIONS = 512;
84
-
85
- /**
86
- * Longest `session_id` this route accepts, in characters (UTF-16 code units).
87
- *
88
- * `MAX_SELECTIONS` bounds how many buckets exist; it says nothing about how large one key is, and the
89
- * store keeps the whole string. A caller that sends a 200,000-character id therefore allocates as much
90
- * as it likes per request and gets it echoed back in the snapshot — the count cap bounds the *number*
91
- * of sessions, never the *bytes*. 200 is a bound with room to spare: a DSH session id is a UUID (36
92
- * characters), and 200 leaves room for a ULID, a path-shaped id or a test's own naming without
93
- * inviting an unbounded key.
94
- */
95
- export const MAX_SESSION_ID_LENGTH = 200;
96
-
97
- /**
98
- * The `/api/w2m/target` route's refusals, in the house `{reason_code, detail}` shape.
99
- *
100
- * Deliberately a map of its own rather than a widening of `tools.mjs`'s `REFUSAL_CODE`: those codes name
101
- * refusals on the *dispatch* path (which the eight tools report), while these name refusals at the
102
- * route the browser calls, and `fleet-target.mjs` is imported *by* `tools.mjs` — importing back would
103
- * be a cycle. `reason_code` is the greppable, stable identifier and `detail` says what would change it.
104
- */
105
- export const TARGET_REFUSAL = Object.freeze({
106
- /** A verb this route does not answer. Only exactly `GET` and `POST` are read or written. */
107
- METHOD_NOT_ALLOWED: 'TARGET_METHOD_NOT_ALLOWED',
108
- /** `GET` carried no `session_id`, so there is no session to answer for. */
109
- SESSION_ID_REQUIRED: 'TARGET_SESSION_ID_REQUIRED',
110
- /** `session_id` is longer than {@link MAX_SESSION_ID_LENGTH}. */
111
- SESSION_ID_TOO_LONG: 'TARGET_SESSION_ID_TOO_LONG',
112
- /** A `POST` body that could not be parsed as JSON at all. */
113
- BODY_NOT_JSON: 'TARGET_BODY_NOT_JSON',
114
- /** A `POST` body that is not a JSON object. */
115
- BODY_NOT_AN_OBJECT: 'TARGET_BODY_NOT_AN_OBJECT',
116
- /** `machine_id` was neither a string nor null. */
117
- MACHINE_ID_NOT_A_STRING: 'TARGET_MACHINE_ID_NOT_A_STRING',
118
- /** `machine_name` was neither a string nor null. */
119
- MACHINE_NAME_NOT_A_STRING: 'TARGET_MACHINE_NAME_NOT_A_STRING',
120
- /** The named machine is not one the relay lists at all. */
121
- MACHINE_UNKNOWN: 'TARGET_MACHINE_UNKNOWN',
122
- /** The named machine exists, and the relay does not call it online. */
123
- MACHINE_OFFLINE: 'TARGET_MACHINE_OFFLINE',
124
- /** The fleet could not be read, so no selection could be checked against it. */
125
- FLEET_UNAVAILABLE: 'TARGET_FLEET_UNAVAILABLE',
126
- });
127
-
128
- /** How long the fleet roster may be reused by the control before it is asked for again. */
129
- export const FLEET_CACHE_MS = 10_000;
130
-
131
- /**
132
- * Is a fleet entry one this control may dispatch to?
133
- *
134
- * The relay reports both `online` (a boolean it computes) and `state` (`online` | `expired` | …).
135
- * `online` wins when present because it is the closer measurement; `state` is the fallback, and a
136
- * device with neither is **not** treated as online — "unknown" is not "reachable", and a target list
137
- * that quietly includes an unknown machine is how a dispatch goes somewhere nobody chose.
138
- *
139
- * @param {object|null|undefined} device One entry of `GET /v1/devices`'s `devices`.
140
- * @returns {boolean}
141
- */
142
- export function isSelectableDevice(device) {
143
- if (device === null || typeof device !== 'object') return false;
144
- if (typeof device.online === 'boolean') return device.online;
145
- return String(device.state ?? '') === 'online';
146
- }
147
-
148
- /**
149
- * Normalize one relay device record into the shape the control renders.
150
- *
151
- * `id`/`name` are the only fields the UI depends on; everything else is detail it may show. Nulls
152
- * are kept rather than omitted so the browser never has to distinguish "absent" from "null" to
153
- * decide whether to render a line.
154
- *
155
- * @param {object|null|undefined} device One entry of `GET /v1/devices`'s `devices`.
156
- * @param {string|null} localMachineId This machine's id, for the "this machine" mark.
157
- * @returns {object}
158
- */
159
- export function describeTargetMachine(device, localMachineId = null) {
160
- const machineId = typeof device?.machine_id === 'string' && device.machine_id !== '' ? device.machine_id : null;
161
- return {
162
- machine_id: machineId,
163
- name: typeof device?.machine_name === 'string' && device.machine_name !== ''
164
- ? device.machine_name
165
- : (typeof device?.name === 'string' && device.name !== '' ? device.name : null),
166
- os: device?.platform?.os ?? device?.os ?? null,
167
- arch: device?.platform?.arch ?? device?.arch ?? null,
168
- state: typeof device?.state === 'string' && device.state !== '' ? device.state : (isSelectableDevice(device) ? 'online' : 'unknown'),
169
- online: isSelectableDevice(device),
170
- last_seen: device?.last_seen ?? device?.last_seen_at ?? null,
171
- rtt_ms: typeof device?.rtt_ms === 'number' ? device.rtt_ms : null,
172
- local: machineId !== null && machineId === localMachineId,
173
- };
174
- }
175
-
176
- /**
177
- * A selection store: one chosen machine per session, in this process.
178
- *
179
- * The clock is injectable so a test can assert `selected_at` without sleeping, and the cap is a
180
- * constructor argument so the eviction order is testable at two entries instead of 513.
181
- *
182
- * @param {{now?: () => number, max?: number}} [options]
183
- * @returns {object} The store described by {@link SelectionStore} below.
184
- */
185
- export function createSelectionStore(options = {}) {
186
- const now = typeof options.now === 'function' ? options.now : () => Date.now();
187
- const max = Number.isInteger(options.max) && options.max > 0 ? options.max : MAX_SELECTIONS;
188
- /** @type {Map<string, {machine_id: string, machine_name: string|null, selected_at: number, selected_by: string|null}>} */
189
- const selections = new Map();
190
-
191
- /**
192
- * Read one session's selection.
193
- *
194
- * @param {string} sessionId
195
- * @returns {object|null}
196
- */
197
- function get(sessionId) {
198
- return selections.get(sessionId) ?? null;
199
- }
200
-
201
- /**
202
- * Record a selection, replacing any earlier one for that session.
203
- *
204
- * Re-inserting on write (rather than leaving the original position) is what makes the eviction
205
- * order least-recently-*chosen*; a session that keeps switching targets is exactly the one that
206
- * must not be evicted first.
207
- *
208
- * @param {string} sessionId
209
- * @param {{machine_id: string, machine_name?: string|null}} machine
210
- * @param {{selectedBy?: string|null}} [context]
211
- * @returns {object} The stored selection.
212
- */
213
- function set(sessionId, machine, context = {}) {
214
- const selection = {
215
- machine_id: String(machine.machine_id),
216
- machine_name: typeof machine.machine_name === 'string' && machine.machine_name !== '' ? machine.machine_name : null,
217
- selected_at: now(),
218
- selected_by: typeof context.selectedBy === 'string' && context.selectedBy !== '' ? context.selectedBy : null,
219
- };
220
- selections.delete(sessionId);
221
- selections.set(sessionId, selection);
222
- while (selections.size > max) {
223
- const oldest = selections.keys().next();
224
- if (oldest.done === true) break;
225
- selections.delete(oldest.value);
226
- }
227
- return selection;
228
- }
229
-
230
- /**
231
- * Drop one session's selection: dispatch goes back to every machine the relay holds.
232
- *
233
- * @param {string} sessionId
234
- * @returns {boolean} Whether anything was removed.
235
- */
236
- function clear(sessionId) {
237
- return selections.delete(sessionId);
238
- }
239
-
240
- /**
241
- * Snapshot every selection, oldest chosen first. For tests and for a host-side diagnostic; no
242
- * tool exposes it, because who selected what is the operator's business, not the model's.
243
- *
244
- * @returns {Array<{session_id: string} & object>}
245
- */
246
- function list() {
247
- return [...selections.entries()].map(([session_id, selection]) => ({ session_id, ...selection }));
248
- }
249
-
250
- /**
251
- * Drop every selection. A test-facing reset: the installed plugin never calls it, because
252
- * forgetting one session's target when another asks for a fleet read would be a bug, not a
253
- * feature.
254
- *
255
- * @returns {number} How many selections were dropped.
256
- */
257
- function clearAll() {
258
- const count = selections.size;
259
- selections.clear();
260
- return count;
261
- }
262
-
263
- return { get, set, clear, clearAll, list, get size() { return selections.size; } };
264
- }
265
-
266
- /**
267
- * The process-wide store, used by the installed plugin.
268
- *
269
- * A module-level singleton rather than a service, because Cordis contexts are per-row and the
270
- * dispatch path reaches this from a tool definition, not from a service lookup; one plugin is one
271
- * store. Tests call {@link createSelectionStore} directly, so nothing here is load-bearing for them.
272
- */
273
- export const defaultSelectionStore = createSelectionStore();
274
-
275
- /**
276
- * Resolve the session id a dispatch belongs to.
277
- *
278
- * Reached through `exec.agent.session`, whose `id` is a getter over the session header. A missing
279
- * agent is not an error: it means "not a session", which is {@link NO_SESSION}.
280
- *
281
- * @param {object|null|undefined} exec The tool execution context.
282
- * @returns {string} A session id, or {@link NO_SESSION}.
283
- */
284
- export function sessionKeyFor(exec) {
285
- const session = exec?.agent?.session ?? null;
286
- const id = session === null ? null : (typeof session.id === 'string' && session.id !== ''
287
- ? session.id
288
- : (typeof session.header?.id === 'string' && session.header.id !== '' ? session.header.id : null));
289
- return id ?? NO_SESSION;
290
- }
291
-
292
- /**
293
- * Turn one selection into the dispatch fields that make it real.
294
- *
295
- * The mapping is per mode because the relay's two selection fields are not interchangeable:
296
- *
297
- * replicate / split / pipeline → `target_machines: [id]` constrains **which machines run**.
298
- * broadcast → `executor_machine_id: id` names **which one runs**, while the
299
- * others stay in the task as observers. Sending
300
- * `target_machines` here would *remove* the observers, which is a
301
- * different task than the caller asked for.
302
- *
303
- * Both fields already exist in the relay's `POST /v1/task` (§4.1, §4.3) and neither is invented
304
- * here, so no protocol moves. `null` in a field means "the caller said nothing"; the caller's own
305
- * value always wins, which is enforced by the call site, not here.
306
- *
307
- * @param {object|null} selection A stored selection, or null for "no selection".
308
- * @param {string} mode The dispatch mode.
309
- * @returns {{fields: object, applied_as: string|null}}
310
- */
311
- export function dispatchSelectionFields(selection, mode) {
312
- const machineId = typeof selection?.machine_id === 'string' && selection.machine_id !== ''
313
- ? selection.machine_id
314
- : null;
315
- if (machineId === null) return { fields: {}, applied_as: null };
316
- if (mode === 'broadcast') {
317
- return { fields: { executor_machine_id: machineId }, applied_as: 'executor_machine_id' };
318
- }
319
- return { fields: { target_machines: [machineId] }, applied_as: 'target_machines' };
320
- }
321
-
322
- /**
323
- * Read the fleet from the relay, in the shape the control renders.
324
- *
325
- * One helper behind both `w2m_devices` and the control, so the two can never disagree about who is
326
- * online — the failure that would matter most here, because the control's promise is "this is where
327
- * your next dispatch goes". The tool re-renders the machine records it gets back into its own
328
- * published shape; what is shared is the read, the liveness rule, and the "the local machine has no
329
- * identity yet" case.
330
- *
331
- * Never throws. A relay that refuses, a machine that is unpaired, a socket that is down: each is
332
- * reported as `{ok: false, error}` and the caller decides whether that is fatal (`w2m_devices`
333
- * throws a typed error) or merely reported (the control says so and shows nothing selectable).
334
- *
335
- * @param {object} deps
336
- * @param {string} deps.rabbitUrl Base relay URL.
337
- * @param {string|null} [deps.token] Device token, or null for an unpaired machine.
338
- * @param {string|null} [deps.localMachineId] This machine's id, for the "this machine" mark.
339
- * @param {AbortSignal} [deps.signal]
340
- * @param {(options: object) => Promise<object>} deps.request The plugin's bounded HTTP helper.
341
- * @returns {Promise<object>} `{ok, error, status, machines, records, online, total, rabbit_time, excluded}`.
342
- * `records` are the relay's own device records, passed through for callers that report the fleet's
343
- * fields rather than this module's projection of them.
344
- */
345
- export async function readFleet(deps) {
346
- const { rabbitUrl, token = null, localMachineId = null, signal, request } = deps;
347
- if (typeof rabbitUrl !== 'string' || rabbitUrl === '') {
348
- return {
349
- ok: false,
350
- error: 'no Rabbit URL is configured, so the fleet cannot be listed',
351
- hint: 'set `rabbitUrl` in this plugin\'s profile patch, for example http://127.0.0.1:8787',
352
- status: 0,
353
- machines: [],
354
- records: [],
355
- online: 0,
356
- total: 0,
357
- rabbit_time: null,
358
- excluded: 0,
359
- };
360
- }
361
- let result;
362
- try {
363
- result = await request({ rabbitUrl, token, signal, pathname: '/v1/devices' });
364
- } catch (error) {
365
- return {
366
- ok: false,
367
- error: error instanceof Error ? error.message : String(error),
368
- // The helper's own typed error carries the remediation in its `hint` ("check that the Rabbit
369
- // is running and that `rabbitUrl` points at it"). Dropping it here would turn a fixable
370
- // unreachable-relay failure into "something went wrong", so it is carried through and the
371
- // callers that throw re-attach it verbatim.
372
- hint: typeof error?.hint === 'string' && error.hint !== '' ? error.hint : null,
373
- status: 0,
374
- machines: [],
375
- records: [],
376
- online: 0,
377
- total: 0,
378
- rabbit_time: null,
379
- excluded: 0,
380
- };
381
- }
382
- if (result?.ok !== true) {
383
- const detail = result?.error ? ` (${result.error.code}: ${result.error.message})` : '';
384
- return {
385
- ok: false,
386
- error: `the Rabbit refused GET /v1/devices with HTTP ${result?.status ?? 'unknown'}${detail}`,
387
- hint: result?.status === 401 ? 'this machine is not paired, or its device_token is stale; pair the agent again' : 'check the Rabbit log for the reason it gave',
388
- status: Number.isInteger(result?.status) ? result.status : 0,
389
- machines: [],
390
- records: [],
391
- online: 0,
392
- total: 0,
393
- rabbit_time: null,
394
- excluded: 0,
395
- };
396
- }
397
- const records = Array.isArray(result.json?.devices)
398
- ? result.json.devices
399
- : (Array.isArray(result.json?.machines) ? result.json.machines : []);
400
- const machines = records.map((record) => describeTargetMachine(record, localMachineId));
401
- return {
402
- ok: true,
403
- error: null,
404
- status: Number.isInteger(result.status) ? result.status : 200,
405
- machines,
406
- records,
407
- online: machines.filter((machine) => machine.online).length,
408
- total: machines.length,
409
- rabbit_time: result.json?.rabbit_time ?? null,
410
- // Machines the relay still lists but whose lease is not live. Reported so "3 machines" is never
411
- // mistaken for "3 machines you can use".
412
- excluded: machines.filter((machine) => !machine.online).length,
413
- };
414
- }
415
-
416
- /**
417
- * Build the snapshot the browser renders.
418
- *
419
- * @param {object} deps
420
- * @param {object} deps.store A selection store.
421
- * @param {string} deps.sessionId The session the control belongs to.
422
- * @param {{ok: boolean, error: string|null, machines: object[], online: number, total: number,
423
- * rabbit_time: string|null, excluded: number}} deps.fleet Output of {@link readFleet}.
424
- * @param {string|null} deps.localMachineName This machine's configured fleet name.
425
- * @param {boolean} deps.detailUnavailable True when the host provides no fetch-route registry.
426
- * @returns {object}
427
- */
428
- export function buildUiState(deps) {
429
- const { store, sessionId, fleet, localMachineName = null, detailUnavailable = false } = deps;
430
- const selection = store.get(sessionId);
431
- const machines = Array.isArray(fleet?.machines) ? fleet.machines : [];
432
- const selected = selection === null ? null : machines.find((machine) => machine.machine_id === selection.machine_id) ?? null;
433
- return {
434
- session_id: sessionId,
435
- selected: selection === null
436
- ? { machine_id: null, name: null, selected_at: null, online: null, stale: false }
437
- : {
438
- machine_id: selection.machine_id,
439
- name: selected?.name ?? selection.machine_name ?? null,
440
- selected_at: selection.selected_at,
441
- // `online: null` is "the fleet could not be read", which is not "offline". The control
442
- // must be able to say "the target I am holding was not confirmed", not invent a state.
443
- online: selected === null ? (fleet?.ok === true ? false : null) : selected.online,
444
- stale: fleet?.ok === true && (selected === null || selected.online !== true),
445
- },
446
- machines,
447
- fleet: {
448
- ok: fleet?.ok === true,
449
- error: fleet?.error ?? null,
450
- online: fleet?.online ?? 0,
451
- total: fleet?.total ?? 0,
452
- excluded: fleet?.excluded ?? 0,
453
- rabbit_time: fleet?.rabbit_time ?? null,
454
- },
455
- host: {
456
- local_machine_name: localMachineName,
457
- // True when the host process has no Connection fetch registry at all: the control is rendered
458
- // but cannot reach this half, and the honest answer is to say so rather than to look empty.
459
- detail_unavailable: detailUnavailable === true,
460
- },
461
- };
462
- }
463
-
464
- /**
465
- * Parse the JSON body of a selection write, refusing anything outside the declared shape.
466
- *
467
- * Every refusal is a *value with a name*: `reason_code` is stable and greppable, and `detail` says what
468
- * would have to change for the same request to be accepted. The route turns both into the response body
469
- * verbatim, so the browser can show the reason without re-deriving it.
470
- *
471
- * @param {unknown} body
472
- * @returns {{ok: true, value: {session_id: string, machine_id: string|null, machine_name: string|null}}
473
- * |{ok: false, error: string, reason_code: string, detail: string}}
474
- */
475
- export function parseSelectionWrite(body) {
476
- const refuse = (error, reason_code, detail) => ({ ok: false, error, reason_code, detail });
477
- if (body === null || typeof body !== 'object' || Array.isArray(body)) {
478
- return refuse(
479
- 'body must be a JSON object',
480
- TARGET_REFUSAL.BODY_NOT_AN_OBJECT,
481
- 'send `{"session_id": "<id>", "machine_id": "<id>|null"}`',
482
- );
483
- }
484
- const record = /** @type {Record<string, unknown>} */ (body);
485
- const sessionId = record.session_id;
486
- if (typeof sessionId !== 'string' || sessionId === '') {
487
- return refuse(
488
- '`session_id` must be a non-empty string',
489
- TARGET_REFUSAL.SESSION_ID_REQUIRED,
490
- 'send the id of the session this selection belongs to',
491
- );
492
- }
493
- if (sessionId.length > MAX_SESSION_ID_LENGTH) {
494
- return refuse(
495
- `\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters, got ${sessionId.length}`,
496
- TARGET_REFUSAL.SESSION_ID_TOO_LONG,
497
- `send the id the host gave this session (a UUID is 36 characters); anything longer than `
498
- + `${MAX_SESSION_ID_LENGTH} characters is not a session id`,
499
- );
500
- }
501
- const machineId = record.machine_id;
502
- if (machineId !== null && machineId !== undefined && typeof machineId !== 'string') {
503
- return refuse(
504
- '`machine_id` must be a string or null',
505
- TARGET_REFUSAL.MACHINE_ID_NOT_A_STRING,
506
- 'send a `machine_id` from the roster this route reports, or null to clear the selection',
507
- );
508
- }
509
- const machineName = record.machine_name;
510
- if (machineName !== null && machineName !== undefined && typeof machineName !== 'string') {
511
- return refuse(
512
- '`machine_name` must be a string or null',
513
- TARGET_REFUSAL.MACHINE_NAME_NOT_A_STRING,
514
- 'send the machine\'s display name as a string, or leave it out and the relay\'s name is used',
515
- );
516
- }
517
- return {
518
- ok: true,
519
- value: {
520
- session_id: sessionId,
521
- machine_id: typeof machineId === 'string' && machineId !== '' ? machineId : null,
522
- machine_name: typeof machineName === 'string' && machineName !== '' ? machineName : null,
523
- },
524
- };
525
- }
526
-
527
- /**
528
- * Resolve this machine's fleet identity, once per process.
529
- *
530
- * Memoized on a **successful** read only. A machine that is not paired yet resolves to
531
- * `{machine_id: null}` and is re-read on the next call, because the fix for that is pairing the
532
- * agent — which can happen while this process is running, and a cached failure would leave the
533
- * control insisting this machine has no identity long after it got one.
534
- *
535
- * @param {() => Promise<{machine_id: string|null, machine_name: string|null, machineName?: string|null}>} read
536
- * A reader that returns the device's identity and the configured fleet name.
537
- * @returns {() => Promise<{machine_id: string|null, machine_name: string|null}>}
538
- */
539
- export function createLocalIdentity(read) {
540
- let cached = null;
541
- return async () => {
542
- if (cached !== null) return cached;
543
- const identity = await read();
544
- const resolved = {
545
- machine_id: typeof identity?.machine_id === 'string' && identity.machine_id !== '' ? identity.machine_id : null,
546
- machine_name: typeof identity?.machine_name === 'string' && identity.machine_name !== ''
547
- ? identity.machine_name
548
- : (typeof identity?.machineName === 'string' && identity.machineName !== '' ? identity.machineName : null),
549
- };
550
- if (resolved.machine_id !== null) cached = resolved;
551
- return resolved;
552
- };
553
- }
554
-
555
- /** The one diagnostic for "this host cannot carry the composer control", shared by both causes. */
556
- const NO_ROUTE_REGISTRY =
557
- 'this host provides no `connection.fetch` route registry, so the composer control cannot reach the plugin';
558
-
559
- /**
560
- * Mount the two browser routes, if this host has a fetch-route registry to mount them on.
561
- *
562
- * Returning a status object instead of throwing keeps the one interesting case loud *and*
563
- * survivable: a host built without `@deepseek-ai/dsh-client-connection` still gets a working plugin
564
- * with all eight tools and no composer control, which is strictly better than a plugin that refuses
565
- * to load because a UI nicety has nowhere to attach.
566
- *
567
- * # Why the service is declared with `ctx.inject`, and never read as `ctx.connection`
568
- *
569
- * Cordis resolves services per fiber: a property read of a name this plugin did not declare in its
570
- * `inject` list **throws** — `cannot get property "connection" without inject` — and it throws
571
- * whether or not some other plugin provides that service. `ctx.inject(names, callback)` is the
572
- * declaration that makes the read legal, and it is the *optional* form: the plugin's own list stays
573
- * `['tools']`, so a host with no Connection service still activates and still registers all eight
574
- * tools — the callback below simply never runs. v0.5.4 shipped the plain property read, and the whole
575
- * entry then failed on a web host and on a base-only host alike, because the throw does not depend on
576
- * the service existing.
577
- *
578
- * `ctx.inject` also drops the ordering assumption a one-shot read would carry: the Loader creates
579
- * sibling entries concurrently (`EntryGroup.update` → `Promise.all`), so a host that *does* provide
580
- * Connection may still be activating it while this plugin's `apply` runs. A synchronous read would
581
- * report "no registry" for a control that was about to become attachable.
582
- *
583
- * The status object is mutated in place when the callback runs, so `w2m_status` — called long after
584
- * activation — reads what happened rather than what was knowable at `apply` time.
585
- *
586
- * @param {object|null|undefined} ctx Cordis context; `ctx.inject(['connection'], …)` is how the
587
- * optional service is declared and read. A context without `inject` gets no route and no warning,
588
- * which is the same status as a host that has no Connection at all.
589
- * @param {object} deps
590
- * @param {() => object} deps.cfg Current merged plugin config.
591
- * @param {object} deps.store The selection store.
592
- * @param {() => Promise<{machine_id: string|null, machine_name: string|null}>} deps.localIdentity
593
- * @param {(options: object) => Promise<object>} deps.request The plugin's bounded HTTP helper.
594
- * @param {(cfg: object) => Promise<string|null>} deps.resolveToken
595
- * @returns {{mounted: boolean, unexpected: boolean, reason: string|null}} `unexpected` is true only
596
- * when the host has a Connection service that lacks the registry — the one case worth a warning.
597
- */
598
- export function registerTargetRoutes(ctx, deps) {
599
- const status = {
600
- mounted: false,
601
- unexpected: false,
602
- reason: NO_ROUTE_REGISTRY,
603
- };
604
- if (typeof ctx?.inject === 'function') {
605
- ctx.inject(['connection'], (webCtx) => {
606
- mountTargetRoutes(webCtx.connection, deps, status, ctx.logger);
607
- });
608
- }
609
- return status;
610
- }
611
-
612
- /**
613
- * Attach the route pair to one host's fetch registry, recording the outcome on `status`.
614
- *
615
- * The registration is owned by the context it is made through — `register` is a Connection method
616
- * bound to the calling fiber, so it is released with the injected child fiber — which is why there
617
- * is no `ctx.effect` wrapper here.
618
- *
619
- * @param {object|null|undefined} connection The `connection` service, when the host has one.
620
- * @param {object} deps
621
- * @param {{mounted: boolean, unexpected: boolean, reason: string|null}} status Mutated in place.
622
- * @param {object} [logger] The plugin's logger, for the one mismatch worth a line.
623
- * @returns {void}
624
- */
625
- function mountTargetRoutes(connection, deps, status, logger) {
626
- const registry = connection?.fetch;
627
- if (registry === undefined || typeof registry.register !== 'function') {
628
- // The distinction matters to the log: a host with **no** Connection at all is simply not a web
629
- // composition, and warning about it would be noise on every headless run. A host that has
630
- // Connection but no fetch registry is a real mismatch worth one line.
631
- status.unexpected = connection !== null && connection !== undefined;
632
- if (status.unexpected) {
633
- logger?.warn?.(`w2m: the composer target control is inactive — ${NO_ROUTE_REGISTRY}`);
634
- }
635
- return;
636
- }
637
-
638
- let cache = { at: 0, value: null };
639
-
640
- /**
641
- * The fleet for one call, cached for {@link FLEET_CACHE_MS}.
642
- *
643
- * The control asks again every time it opens; a click-happy operator must not turn into a burst of
644
- * relay reads, and the relay's own answer is already seconds old by the time it arrives.
645
- */
646
- const fleetFor = async (signal) => {
647
- const now = Date.now();
648
- if (cache.value !== null && now - cache.at < FLEET_CACHE_MS) return cache.value;
649
- const cfg = deps.cfg();
650
- const identity = await deps.localIdentity();
651
- const value = await readFleet({
652
- rabbitUrl: cfg.rabbitUrl,
653
- token: await deps.resolveToken(cfg),
654
- localMachineId: identity.machine_id,
655
- signal,
656
- request: deps.request,
657
- });
658
- cache = { at: now, value };
659
- return value;
660
- };
661
-
662
- const snapshot = async (sessionId, signal) => {
663
- const cfg = deps.cfg();
664
- const identity = await deps.localIdentity();
665
- const fleet = await fleetFor(signal);
666
- return buildUiState({
667
- store: deps.store,
668
- sessionId,
669
- fleet,
670
- localMachineName: identity.machine_name ?? cfg.machineName ?? null,
671
- detailUnavailable: false,
672
- });
673
- };
674
-
675
- // One route object for both verbs, not two: the registry is keyed by path and a second
676
- // registration for the same path is refused outright (`exact Fetch route … is already
677
- // registered`), so the method split has to happen inside the handler.
678
- registry.register({
679
- path: TARGET_ROUTE_PATH,
680
- methods: ['GET', 'POST'],
681
- requestBody: 'buffered',
682
- fetch: async (request) => {
683
- const url = new URL(request.url);
684
- const noStore = { 'cache-control': 'no-store' };
685
- /**
686
- * One refusal shape for the whole route: `{ok:false, error, reason_code, detail}`.
687
- *
688
- * @param {number} status
689
- * @param {string} reasonCode A {@link TARGET_REFUSAL} value.
690
- * @param {string} error What happened, in the host's own words (the control prints this).
691
- * @param {string} detail What would change it.
692
- * @returns {Response}
693
- */
694
- const refuse = (status, reasonCode, error, detail) =>
695
- Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status, headers: noStore });
696
-
697
- // Defence in depth, and the one place it is cheap. The registration above declares
698
- // `methods: ['GET', 'POST']`, so the host should never route a `PUT`/`DELETE`/`PATCH` here at all
699
- // — but the handler used to treat *anything* that was not a GET as a write, which means a
700
- // registration mistake, a host that forwards other verbs, or a future `methods` change would
701
- // silently turn `DELETE /api/w2m/target` into "clear this selection". A route that writes must
702
- // say which verbs write; everything else is refused by name rather than guessed at.
703
- if (request.method !== 'GET' && request.method !== 'POST') {
704
- return refuse(
705
- 405,
706
- TARGET_REFUSAL.METHOD_NOT_ALLOWED,
707
- `${request.method} is not a method this route answers`,
708
- 'GET reads the fleet and this session\'s selection and POST writes one; any other verb is refused '
709
- + 'rather than treated as a write',
710
- );
711
- }
712
-
713
- if (request.method === 'GET') {
714
- const sessionId = url.searchParams.get('session_id');
715
- if (typeof sessionId !== 'string' || sessionId === '') {
716
- return refuse(
717
- 400,
718
- TARGET_REFUSAL.SESSION_ID_REQUIRED,
719
- '`session_id` query parameter is required',
720
- 'add `?session_id=<the session the control belongs to>`',
721
- );
722
- }
723
- if (sessionId.length > MAX_SESSION_ID_LENGTH) {
724
- return refuse(
725
- 400,
726
- TARGET_REFUSAL.SESSION_ID_TOO_LONG,
727
- `\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters, got ${sessionId.length}`,
728
- `send the id the host gave this session (a UUID is 36 characters)`,
729
- );
730
- }
731
- try {
732
- return Response.json(await snapshot(sessionId, request.signal), { headers: noStore });
733
- } catch (error) {
734
- return Response.json(
735
- { ok: false, error: error instanceof Error ? error.message : String(error) },
736
- { status: 502 },
737
- );
738
- }
739
- }
740
-
741
- let body;
742
- try {
743
- body = await request.json();
744
- } catch {
745
- return refuse(
746
- 400,
747
- TARGET_REFUSAL.BODY_NOT_JSON,
748
- 'body is not JSON',
749
- 'send `{"session_id": "<id>", "machine_id": "<id>|null"}` as a JSON object',
750
- );
751
- }
752
- const parsed = parseSelectionWrite(body);
753
- if (!parsed.ok) {
754
- return refuse(400, parsed.reason_code, parsed.error, parsed.detail);
755
- }
756
- const { session_id: sessionId, machine_id: machineId, machine_name: machineName } = parsed.value;
757
-
758
- if (machineId === null) {
759
- deps.store.clear(sessionId);
760
- return Response.json(await snapshot(sessionId, request.signal), { headers: noStore });
761
- }
762
-
763
- // A selection is only accepted for a machine the relay *currently* lists as online. Refusing
764
- // here rather than at dispatch time is the whole point of the control: "the machine I chose"
765
- // must not be a claim the fleet cannot honour, and the refusal reaches the operator while they
766
- // are still looking at the list.
767
- const identity = await deps.localIdentity();
768
- const cfg = deps.cfg();
769
- const fleet = await fleetFor(request.signal);
770
- if (fleet.ok !== true) {
771
- return refuse(
772
- 503,
773
- TARGET_REFUSAL.FLEET_UNAVAILABLE,
774
- `the fleet could not be read, so no target was changed: ${fleet.error ?? 'unknown cause'}`,
775
- 'bring the relay back (or fix `rabbitUrl`) and choose again; nothing was selected in the meantime',
776
- );
777
- }
778
- const machine = fleet.machines.find((entry) => entry.machine_id === machineId) ?? null;
779
- // A machine the relay does not list *at all* is refused in exactly the same breath as one it lists
780
- // as offline. The comment above always claimed this and the code did not do it: `machine === null`
781
- // fell through to `store.set`, so `{"machine_id":"NOT-A-MACHINE"}` was accepted with a 200 and a
782
- // selection the fleet could never honour. Dispatch re-validates, which is what bounded the impact
783
- // — but a control whose whole promise is "you cannot select what the fleet cannot run" has to
784
- // refuse the id it has never heard of too.
785
- if (machine === null) {
786
- return refuse(
787
- 409,
788
- TARGET_REFUSAL.MACHINE_UNKNOWN,
789
- `${machineId} is not a machine this relay lists`,
790
- 'pick a machine from the roster this route reports; if it has just paired, reload the list and '
791
- + 'choose it again',
792
- );
793
- }
794
- if (machine.online !== true) {
795
- return refuse(
796
- 409,
797
- TARGET_REFUSAL.MACHINE_OFFLINE,
798
- `${machine.name ?? machineId} is listed as ${machine.state}, so it cannot be selected`,
799
- 'the machine must be online: wait for its next heartbeat, or select another machine',
800
- );
801
- }
802
- deps.store.set(sessionId, {
803
- machine_id: machineId,
804
- machine_name: machine?.name ?? machineName,
805
- }, { selectedBy: identity.machine_name ?? cfg.machineName ?? null });
806
- return Response.json(await snapshot(sessionId, request.signal), { headers: noStore });
807
- },
808
- });
809
-
810
- status.mounted = true;
811
- status.reason = null;
812
- }