@twinsearth/w2m-dsh-plugin 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,513 @@
1
+ /**
2
+ * DSH Web client half of `@twinsearth/w2m-dsh-plugin`: the composer's target-machine control.
3
+ *
4
+ * # What this file is
5
+ *
6
+ * This is the *shipped* client bundle, not compiled output: the package has no build step and no
7
+ * dependencies, so the half the browser loads is written here in the exact module format the DSH
8
+ * web shell's loader consumes (`window.__ModuleLoader__.load({ id, factory })`, a lazy CJS table
9
+ * whose factory runs at materialization). `package.json` declares it with
10
+ * `dsh.client: { platform: 'web' }` and `exports['./client'] = './lib/client.js'`, which is what the
11
+ * host scans for; `src/client/w2m-target.mjs` is the same code laid out for a human reader.
12
+ *
13
+ * Two consequences, both deliberate:
14
+ *
15
+ * * **No JSX and no bundler.** `React.createElement` keeps the file plain JavaScript a reviewer
16
+ * can read, and the module system's platform seed table already holds React (`react`,
17
+ * `react/jsx-runtime`, `react-dom`, Cordis and the shell's static UI libraries are frozen seeds
18
+ * for every dynamic bundle), so `require('react')` resolves without this package shipping or
19
+ * declaring anything.
20
+ * * **Only React is required.** `@deepseek-ai/dsh-client-ui-primitives` is *available* to a
21
+ * dynamic bundle — the shipped permission control next to this one uses its `Menu` — but a
22
+ * package that must stay dependency-free should not take a rendering dependency for one
23
+ * dropdown. The control is styled with the shell's own theme tokens instead, which is what
24
+ * makes it correct in both themes rather than "similar in one".
25
+ *
26
+ * # Where it sits
27
+ *
28
+ * `conversation.input.left` — `kind: list`, `scope: session`, `replaceRisk: none`. The shipped
29
+ * permission control owns `conversation.input.permission` (`single`, `replaceRisk:
30
+ * shadows-shipped-ui`) and is **not** touched: this entry is appended to the composer's left group,
31
+ * so it renders to the right of the permission control and never competes with it for a seat.
32
+ *
33
+ * # What it shows
34
+ *
35
+ * The machine this session's work will be dispatched to, and — on click — the fleet the relay
36
+ * currently holds. Liveness comes from the host plugin, never from this file: a machine is
37
+ * selectable because the relay lists it as online, and one that is not listed that way is rendered
38
+ * with its state and cannot be chosen. A selection is written to the host process, so it survives a
39
+ * reload and is visible to `w2m_run`, which is where it actually means something.
40
+ */
41
+
42
+ window.__ModuleLoader__.load({
43
+ id: '@twinsearth/w2m-dsh-plugin',
44
+ factory: (require) => {
45
+ var module = { exports: {} };
46
+ var exports = module.exports;
47
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
48
+
49
+ const React = require('react');
50
+
51
+ // -------------------------------------------------------------------------------------------
52
+ // Wire
53
+ // -------------------------------------------------------------------------------------------
54
+ //
55
+ // Mirrors TARGET_ROUTE_PATH in src/plugin/fleet-target.mjs. Plain same-origin `fetch` rather
56
+ // than a Remote method: the route is mounted inside the host's authentication fence, so the
57
+ // page's own cookie is the credential and there is no descriptor to keep in sync.
58
+ const ROUTE = '/w2m/target';
59
+
60
+ /**
61
+ * The locale namespace and its two dictionaries.
62
+ *
63
+ * Registered through `ctx.locale` when the composition provides it, exactly as the shipped
64
+ * permission control beside this one does; the Chinese dictionary is the fallback when it does
65
+ * not, because a missing localisation service is not a reason for an operator to lose the
66
+ * control. Only the *visible* strings are here. A refusal from the host is reported in the
67
+ * host's own words — it is a diagnostic naming a machine, a relay or an HTTP status, and
68
+ * translating it would put a second, drifting copy of that vocabulary in the browser.
69
+ */
70
+ const NS = 'w2m.target';
71
+ const DICT = {
72
+ zh: {
73
+ all: '全部机器',
74
+ machine: '机器',
75
+ local: '本机',
76
+ online: '在线',
77
+ noRoster: '无列表',
78
+ empty: '机器列表里没有机器',
79
+ costAll: '本次会话的派发会送到所有在线机器',
80
+ costOne: '本次会话的派发会送到 {name}',
81
+ unconfirmed: '(未确认在线)',
82
+ targetLabel: '目标机器:{name}',
83
+ unreachable: '无法连接插件',
84
+ routeMissing: '这个 Harness 没有提供插件目标路由',
85
+ fleetUnreadable: '机器列表读取失败',
86
+ },
87
+ en: {
88
+ all: 'All machines',
89
+ machine: 'Machine',
90
+ local: 'This machine',
91
+ online: 'online',
92
+ noRoster: 'no roster',
93
+ empty: 'the fleet lists no machines',
94
+ costAll: 'Work from this session goes to every online machine',
95
+ costOne: 'Work from this session goes to {name}',
96
+ unconfirmed: ' (not confirmed online)',
97
+ targetLabel: 'target machine: {name}',
98
+ unreachable: 'the plugin could not be reached',
99
+ routeMissing: 'this host does not serve the W2M target route',
100
+ fleetUnreadable: 'the fleet could not be read',
101
+ },
102
+ };
103
+
104
+ /**
105
+ * A translator over {@link DICT}, preferring the shell's locale service.
106
+ *
107
+ * `{name}` substitution is deliberately the only templating: these strings have one variable
108
+ * each, and a message format would be a dependency this package does not have.
109
+ *
110
+ * @param ctx - the client plugin context.
111
+ * @returns {(key: string, values?: object) => string}
112
+ */
113
+ function makeTranslator(ctx) {
114
+ const bound = typeof ctx?.locale?.bind === 'function' ? ctx.locale.bind(NS) : null;
115
+ return (key, values) => {
116
+ const template = bound === null
117
+ ? (DICT.zh[key] ?? DICT.en[key] ?? key)
118
+ : bound(key, values);
119
+ const text = typeof template === 'string' && template !== '' ? template : (DICT.en[key] ?? key);
120
+ if (values === undefined) return text;
121
+ return text.replace(/\{(\w+)\}/g, (match, name) => (values[name] === undefined ? match : String(values[name])));
122
+ };
123
+ }
124
+
125
+ /** One JSON call to the host half, folded into a value: a refusal is never an exception here. */
126
+ async function callHost(options) {
127
+ let response;
128
+ try {
129
+ response = await fetch(options.url, options.init);
130
+ } catch (error) {
131
+ return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}`, status: 0 };
132
+ }
133
+ if (response.status === 404) {
134
+ return { ok: false, status: 404, error: 'this host does not serve the W2M target route' };
135
+ }
136
+ let body = null;
137
+ try {
138
+ body = await response.json();
139
+ } catch {
140
+ return { ok: false, status: response.status, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
141
+ }
142
+ if (response.ok !== true) {
143
+ return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
144
+ }
145
+ return { ok: true, status: response.status, state: body };
146
+ }
147
+
148
+ const readState = (sessionId, signal) => callHost({
149
+ url: `${ROUTE}?session_id=${encodeURIComponent(sessionId)}`,
150
+ init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
151
+ });
152
+
153
+ const writeSelection = (sessionId, machineId, machineName) => callHost({
154
+ url: ROUTE,
155
+ init: {
156
+ method: 'POST',
157
+ credentials: 'same-origin',
158
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
159
+ body: JSON.stringify({ session_id: sessionId, machine_id: machineId, machine_name: machineName }),
160
+ },
161
+ });
162
+
163
+ const labelOf = (state, t) => state?.selected?.machine_id != null
164
+ ? (state.selected.name ?? state.selected.machine_id)
165
+ : t('all');
166
+
167
+ /** The one-line reason the list cannot be trusted, or null when it can. */
168
+ function fleetProblem(state, t) {
169
+ if (state === null || state === undefined) return null;
170
+ if (state.host?.detail_unavailable === true) return t('routeMissing');
171
+ if (state.fleet?.ok === true) return null;
172
+ return state.fleet?.error ?? t('fleetUnreadable');
173
+ }
174
+
175
+ // -------------------------------------------------------------------------------------------
176
+ // Styles
177
+ // -------------------------------------------------------------------------------------------
178
+ //
179
+ // Token colours only: `--dsw-*` is the shell's own theme surface, so the control follows the
180
+ // light/dark switch instead of inventing a palette that is wrong in one of them. The geometry
181
+ // (28px height, `--dsw-radius-sm`, 13px/500 label, 4px gap, 8px leading pad) is copied from the
182
+ // shipped permission trigger in the same row, and the status dot uses the theme's state colours:
183
+ // business blue for this machine, success green for a reachable peer, warn/error for the two
184
+ // states that are not "ready".
185
+ const TAG_ID = '@twinsearth/w2m-dsh-plugin/TargetSelect.css';
186
+ const CSS = [
187
+ '.w2mTarget_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;max-width:220px;min-width:0;',
188
+ 'padding:0 4px 0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;',
189
+ 'color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
190
+ '.w2mTarget_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
191
+ '.w2mTarget_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
192
+ '.w2mTarget_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
193
+ '.w2mTarget_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);',
194
+ 'box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
195
+ '.w2mTarget_dot[data-state="local"]{background:var(--dsw-alias-state-business-primary)}',
196
+ '.w2mTarget_dot[data-state="online"]{background:var(--dsw-alias-state-success-primary)}',
197
+ '.w2mTarget_dot[data-state="stale"]{background:transparent;border:1px solid var(--dsw-alias-state-warn-primary)}',
198
+ '.w2mTarget_dot[data-state="none"]{background:transparent;border:1px solid var(--dsw-alias-label-caption)}',
199
+ '.w2mTarget_label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
200
+ '.w2mTarget_chevron{flex:none;display:inline-flex;color:var(--dsw-alias-label-caption);transition:transform .12s}',
201
+ '.w2mTarget_chevron[data-open="true"]{transform:rotate(180deg)}',
202
+ '.w2mTarget_menu{position:fixed;z-index:60;box-sizing:border-box;min-width:200px;max-width:300px;',
203
+ 'padding:4px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);',
204
+ 'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
205
+ 'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
206
+ '.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
207
+ '.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
208
+ 'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
209
+ '.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
210
+ '.w2mTarget_option:disabled{cursor:default;color:var(--dsw-alias-label-dimmed)}',
211
+ '.w2mTarget_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
212
+ '.w2mTarget_row{display:flex;align-items:center;gap:8px}',
213
+ '.w2mTarget_name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
214
+ '.w2mTarget_detail{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400}',
215
+ '.w2mTarget_option:disabled .w2mTarget_detail{color:var(--dsw-alias-label-caption)}',
216
+ ].join('');
217
+
218
+ /**
219
+ * Install the stylesheet once per document, and hand back a disposer for the plugin's lifetime.
220
+ *
221
+ * Idempotent by the tag id, not by a module flag: two plugin instances in one page (a reload
222
+ * that did not evict the old one, two compositions) must not produce two stylesheets, and an
223
+ * unload must take exactly the one this call created. When a tag is already present this is a
224
+ * no-op that returns nothing, because removing a tag this call did not add would be a
225
+ * disposer that breaks whichever instance owns it.
226
+ *
227
+ * @returns {(() => void)|null} Disposer, or null when the stylesheet was already installed.
228
+ */
229
+ function injectStyle(hostDocument) {
230
+ if (hostDocument === undefined || hostDocument === null) return null;
231
+ if (hostDocument.querySelector(`style[data-plugin-css="${TAG_ID}"]`) !== null) return null;
232
+ const tag = hostDocument.createElement('style');
233
+ tag.dataset.plugin = '@twinsearth/w2m-dsh-plugin';
234
+ tag.dataset.pluginCss = TAG_ID;
235
+ tag.textContent = CSS;
236
+ hostDocument.head.appendChild(tag);
237
+ return () => {
238
+ tag.remove();
239
+ };
240
+ }
241
+
242
+ const cls = (name) => `w2mTarget_${name}`;
243
+ const h = React.createElement;
244
+
245
+ /** The status dot: the colour is the machine's state, and the title says it in words. */
246
+ function StatusDot({ kind, title }) {
247
+ return h('span', { className: cls('dot'), 'data-state': kind, title, 'aria-hidden': 'true' });
248
+ }
249
+
250
+ /**
251
+ * This machine is `local` — the one whose composer this is — and the blue dot marks it; a
252
+ * reachable peer is green; anything else is drawn as an outline in the state's own colour so an
253
+ * offline row can never be mistaken for a live one.
254
+ */
255
+ function dotKind(machine) {
256
+ if (machine.online !== true) return machine.state === 'expired' ? 'none' : 'stale';
257
+ return machine.local === true ? 'local' : 'online';
258
+ }
259
+
260
+ /** The label the trigger shows before the host has answered: the configured fleet name. */
261
+ const provisionalLabel = (state) => state?.host?.local_machine_name ?? null;
262
+
263
+ /**
264
+ * `locked` is accepted for parity with the seat's other occupants and deliberately unused: the
265
+ * target is a choice about the *next* dispatch, which is legitimate to change while a turn runs.
266
+ * `t` arrives through the entry's inject face (see {@link apply}).
267
+ */
268
+ function TargetSelect({ sessionId, locked: _locked, t }) {
269
+ const [open, setOpen] = React.useState(false);
270
+ const [reloadSeq, setReloadSeq] = React.useState(0);
271
+ const [state, setState] = React.useState(null);
272
+ const [error, setError] = React.useState(null);
273
+ const [busy, setBusy] = React.useState(false);
274
+ const anchorRef = React.useRef(null);
275
+ const menuRef = React.useRef(null);
276
+ const [placement, setPlacement] = React.useState(null);
277
+
278
+ // The host half is the source of truth, so every read replaces the mirror rather than merging
279
+ // into it: a stale "online" must not outlive the answer that disproved it.
280
+ React.useEffect(() => {
281
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
282
+ const controller = new AbortController();
283
+ readState(sessionId, controller.signal).then((result) => {
284
+ if (controller.signal.aborted) return;
285
+ if (result.ok) {
286
+ setState(result.state);
287
+ setError(null);
288
+ } else {
289
+ setError(result.error);
290
+ }
291
+ });
292
+ return () => controller.abort();
293
+ }, [sessionId, reloadSeq]);
294
+
295
+ // Opening the menu is a read: the roster and the selection are re-asked for every time, so a
296
+ // machine that went away while the menu was closed cannot be clicked as if it were live. The
297
+ // host caches its own relay read for a few seconds, so this is not a request storm.
298
+ React.useEffect(() => {
299
+ if (open) setReloadSeq((seq) => seq + 1);
300
+ }, [open]);
301
+
302
+ React.useEffect(() => {
303
+ if (!open) return undefined;
304
+ const reposition = () => {
305
+ const anchor = anchorRef.current;
306
+ if (anchor === null) return;
307
+ const rect = anchor.getBoundingClientRect();
308
+ const width = Math.min(300, Math.max(200, Math.round(rect.width) + 96));
309
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
310
+ // Above the composer by default — it is at the bottom of the window — and below it only
311
+ // when the space above is genuinely unusable.
312
+ const roomAbove = rect.top;
313
+ const maxHeight = Math.max(160, Math.min(320, roomAbove > 240 ? roomAbove - 16 : window.innerHeight - rect.bottom - 24));
314
+ setPlacement(roomAbove > 240
315
+ ? { left, width, maxHeight, bottom: window.innerHeight - rect.top + 6, top: 'auto' }
316
+ : { left, width, maxHeight, top: rect.bottom + 6, bottom: 'auto' });
317
+ };
318
+ const onKeyDown = (event) => {
319
+ if (event.key === 'Escape') {
320
+ setOpen(false);
321
+ anchorRef.current?.focus?.();
322
+ }
323
+ };
324
+ const onPointerDown = (event) => {
325
+ const target = event.target;
326
+ if (anchorRef.current?.contains(target) === true) return;
327
+ if (menuRef.current?.contains(target) === true) return;
328
+ setOpen(false);
329
+ };
330
+ reposition();
331
+ // `capture` matters: the composer's own editor listens on the document too, and Escape has
332
+ // to close this menu before anything else reads it.
333
+ document.addEventListener('keydown', onKeyDown, true);
334
+ document.addEventListener('pointerdown', onPointerDown, true);
335
+ window.addEventListener('resize', reposition);
336
+ window.addEventListener('scroll', reposition, true);
337
+ return () => {
338
+ document.removeEventListener('keydown', onKeyDown, true);
339
+ document.removeEventListener('pointerdown', onPointerDown, true);
340
+ window.removeEventListener('resize', reposition);
341
+ window.removeEventListener('scroll', reposition, true);
342
+ };
343
+ }, [open]);
344
+
345
+ const chosen = state?.selected?.machine_id ?? null;
346
+ const machines = Array.isArray(state?.machines) ? state.machines : [];
347
+ const problem = error ?? fleetProblem(state, t);
348
+ const disabled = typeof sessionId !== 'string' || sessionId === '';
349
+ const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
350
+ const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
351
+ const selectedOnline = state === null
352
+ ? null
353
+ : (selectedMachine !== null ? selectedMachine.online === true : (chosen === null ? null : state?.selected?.online ?? null));
354
+ const dot = chosen === null
355
+ ? 'none'
356
+ : (selectedOnline === true ? (selectedMachine?.local === true ? 'local' : 'online') : 'stale');
357
+ const title = problem ?? (chosen === null
358
+ ? t('costAll')
359
+ : `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
360
+
361
+ const choose = (machineId, machineName) => {
362
+ setOpen(false);
363
+ if (machineId === chosen) return;
364
+ setBusy(true);
365
+ writeSelection(sessionId, machineId, machineName).then((result) => {
366
+ setBusy(false);
367
+ if (result.ok) {
368
+ setState(result.state);
369
+ setError(null);
370
+ return;
371
+ }
372
+ // The write is refused for a reason the operator can act on (the machine went away, the
373
+ // fleet could not be read), so it is shown where they are looking rather than swallowed.
374
+ setError(result.error);
375
+ setReloadSeq((seq) => seq + 1);
376
+ });
377
+ };
378
+
379
+ const menu = open && placement !== null
380
+ ? h('div', {
381
+ className: cls('menu'),
382
+ role: 'listbox',
383
+ 'aria-label': t('targetLabel', { name: label }),
384
+ ref: menuRef,
385
+ style: {
386
+ left: `${placement.left}px`,
387
+ top: placement.top === 'auto' ? 'auto' : `${placement.top}px`,
388
+ bottom: placement.bottom === undefined || placement.bottom === 'auto' ? 'auto' : `${placement.bottom}px`,
389
+ width: `${placement.width}px`,
390
+ maxHeight: `${placement.maxHeight}px`,
391
+ overflowY: 'auto',
392
+ },
393
+ }, [
394
+ h('button', {
395
+ key: '__all__',
396
+ type: 'button',
397
+ role: 'option',
398
+ className: cls('option'),
399
+ 'data-active': chosen === null ? 'true' : 'false',
400
+ 'aria-selected': chosen === null,
401
+ onClick: () => choose(null, null),
402
+ title: t('costAll'),
403
+ }, h('span', { className: cls('row') }, [
404
+ h(StatusDot, { key: 'dot', kind: 'none', title: t('all') }),
405
+ h('span', { key: 'name', className: cls('name') }, t('all')),
406
+ h('span', { key: 'detail', className: cls('detail') }, state?.fleet?.ok === true ? `${state.fleet.online} ${t('online')}` : '—'),
407
+ ])),
408
+ problem === null ? null : h('div', { key: '__problem__', className: cls('note'), role: 'status' }, problem),
409
+ machines.length === 0
410
+ ? h('div', { key: '__empty__', className: cls('note') }, problem === null ? t('empty') : t('noRoster'))
411
+ : machines.map((machine) => h('button', {
412
+ key: machine.machine_id ?? machine.name ?? Math.random().toString(36).slice(2),
413
+ type: 'button',
414
+ role: 'option',
415
+ className: cls('option'),
416
+ 'data-active': machine.machine_id !== null && machine.machine_id === chosen ? 'true' : 'false',
417
+ 'aria-selected': machine.machine_id !== null && machine.machine_id === chosen,
418
+ disabled: machine.online !== true,
419
+ title: machine.online === true
420
+ ? `${machine.name ?? machine.machine_id} — ${machine.local === true ? t('local') : t('online')}`
421
+ : `${machine.name ?? machine.machine_id} — ${machine.state}`,
422
+ onClick: () => choose(machine.machine_id, machine.name),
423
+ }, h('span', { className: cls('row') }, [
424
+ h(StatusDot, { key: 'dot', kind: dotKind(machine), title: machine.state }),
425
+ h('span', { key: 'name', className: cls('name') }, machine.name ?? machine.machine_id ?? 'unnamed'),
426
+ h('span', { key: 'detail', className: cls('detail') }, machine.local === true
427
+ ? t('local')
428
+ : (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
429
+ ]))),
430
+ ])
431
+ : null;
432
+
433
+ return h(React.Fragment, null, [
434
+ h('button', {
435
+ key: 'trigger',
436
+ type: 'button',
437
+ ref: anchorRef,
438
+ className: cls('trigger'),
439
+ 'aria-label': t('targetLabel', { name: label }),
440
+ 'aria-haspopup': 'listbox',
441
+ 'aria-expanded': open,
442
+ 'aria-busy': busy,
443
+ title,
444
+ disabled,
445
+ onClick: () => setOpen((wasOpen) => !wasOpen),
446
+ }, [
447
+ h(StatusDot, { key: 'dot', kind: dot, title: title ?? '' }),
448
+ h('span', { key: 'label', className: cls('label') }, label),
449
+ h('span', { key: 'chevron', className: cls('chevron'), 'data-open': open ? 'true' : 'false', 'aria-hidden': 'true' },
450
+ h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none' },
451
+ h('path', {
452
+ d: 'M3 4.5L6 7.5L9 4.5',
453
+ stroke: 'currentColor',
454
+ strokeWidth: 1.5,
455
+ strokeLinecap: 'round',
456
+ strokeLinejoin: 'round',
457
+ }))),
458
+ ]),
459
+ menu,
460
+ ]);
461
+ }
462
+
463
+ /**
464
+ * Client plugin body.
465
+ *
466
+ * `slots.inject` (not a bare `register`) is what the slot's declaration requires of an entry
467
+ * from a package that does not own the seat: the parent declared `conversation.input.left`, and
468
+ * an entry may only register into a key it has announced. The returned `inject` factory is
469
+ * called per render position with that session's id, and its keys become this component's props
470
+ * — which is how the control knows which session's selection it is showing.
471
+ *
472
+ * The stylesheet is installed here rather than at module scope, so its lifetime is the plugin's:
473
+ * `ctx.effect` disposes it on unload, and nothing a bundle factory does at materialization can
474
+ * outlive the entry that materialized it.
475
+ *
476
+ * @param ctx - the client root context.
477
+ */
478
+ function apply(ctx) {
479
+ const styleDisposer = injectStyle(typeof document === 'undefined' ? null : document);
480
+ if (styleDisposer !== null && typeof ctx.effect === 'function') {
481
+ ctx.effect(() => styleDisposer, 'w2m: composer target control styles');
482
+ }
483
+ // Dictionaries first: a namespace registered after the UI has mounted is still picked up.
484
+ if (typeof ctx.locale?.register === 'function') {
485
+ ctx.effect(() => ctx.locale.register(NS, DICT), 'w2m: composer target control dictionaries');
486
+ }
487
+ const t = makeTranslator(ctx);
488
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
489
+ name: 'conversation.input.left',
490
+ id: 'w2m-target',
491
+ // After the shipped permission control and the plan control, which sit earlier in the
492
+ // composer row: the target is a per-session choice like them, and the row reads left to
493
+ // right as "attach, permissions, plan, where it runs".
494
+ order: 40,
495
+ label: 'dispatch target',
496
+ inject: (sessionId) => ({
497
+ sessionId,
498
+ t,
499
+ }),
500
+ }, TargetSelect));
501
+ }
502
+
503
+ /** Cordis services this entry needs; `ctx.slots` is read directly, as every client plugin does. */
504
+ const inject = [];
505
+
506
+ exports.apply = apply;
507
+ exports.inject = inject;
508
+ exports.TargetSelect = TargetSelect;
509
+ exports.injectStyle = injectStyle;
510
+ exports.makeTranslator = makeTranslator;
511
+ return module.exports;
512
+ },
513
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -12,6 +12,7 @@
12
12
  "exports": {
13
13
  ".": "./lib/index.js",
14
14
  "./tools": "./lib/tools.js",
15
+ "./client": "./lib/client.js",
15
16
  "./rabbit": "./bin/w2m-rabbit.mjs",
16
17
  "./localside": "./bin/w2m-localside.mjs",
17
18
  "./stun": "./bin/w2m-stun.mjs",
@@ -38,6 +39,9 @@
38
39
  "kind": "server",
39
40
  "bundle": {
40
41
  "patch": "cordis.patch.yml"
42
+ },
43
+ "client": {
44
+ "platform": "web"
41
45
  }
42
46
  },
43
47
  "disclosure": {
@@ -92,13 +96,13 @@
92
96
  }
93
97
  },
94
98
  "scripts": {
95
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/stun-server.test.mjs",
99
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/stun-server.test.mjs",
96
100
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
97
101
  "test:relay": "node --test test/relay.test.mjs",
98
102
  "test:agent": "node --test test/agent.test.mjs",
99
103
  "test:tools": "node --test test/tools.test.mjs",
100
104
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
101
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs",
105
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs",
102
106
  "pack": "node scripts/pack.mjs",
103
107
  "lint": "eslint .",
104
108
  "lint:fix": "eslint . --fix",